From 66657db0bee2e26a3966e6ca04b2483f88918754 Mon Sep 17 00:00:00 2001 From: Peter Baratta Date: Mon, 4 Mar 2013 09:49:41 -0700 Subject: [PATCH 001/128] Added support for superscripts in variables and fixed bug with normal subscripted variables raised to powers --- lms/lib/symmath/formula.py | 60 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/lms/lib/symmath/formula.py b/lms/lib/symmath/formula.py index c34156da52..db74d5b271 100644 --- a/lms/lib/symmath/formula.py +++ b/lms/lib/symmath/formula.py @@ -247,6 +247,65 @@ class formula(object): fix_hat(k) fix_hat(xml) + def flatten_pmathml(xml): + ''' + Give the text version of PMathML elements + ''' + tag = gettag(xml) + if tag == 'mn': return xml.text + elif tag == 'mi': return xml.text + # elif tag == 'msub': return '_'.join([flatten_pmathml(y) for y in xml]) + # elif tag == 'msup': return '^'.join([flatten_pmathml(y) for y in xml]) + elif tag == 'mrow': return ''.join([flatten_pmathml(y) for y in xml]) + raise Exception, '[flatten_pmathml] unknown tag %s' % tag + + # find "tagged" superscripts + # they have the character \u200b in the superscript + # replace them with a__b so snuggle doesn't get confused + def fix_superscripts(xml): + for k in xml: + tag = gettag(k) + + # match node to a superscript + if (tag == 'msup' and + len(k) == 2 and gettag(k[1]) == 'mrow' and + gettag(k[1][0]) == 'mo' and k[1][0].text == u'\u200b'): # whew + + k[1].remove(k[1][0]) + newk = etree.Element('mi') + newk.text = '%s__%s' % (flatten_pmathml(k[0]), flatten_pmathml(k[1])) + xml.replace(k, newk) + + if (tag == 'msubsup' and + len(k) == 3 and gettag(k[2]) == 'mrow' and + gettag(k[2][0]) == 'mo' and k[2][0].text == u'\u200b'): # whew + + k[2].remove(k[2][0]) + newk = etree.Element('mi') + newk.text = '%s_%s__%s' % (flatten_pmathml(k[0]), flatten_pmathml(k[1]), flatten_pmathml(k[2])) + xml.replace(k, newk) + + fix_superscripts(k) + fix_superscripts(xml) + + # Snuggle returns an error when it sees an + # replace such elements with an , except the first element is of + # the form a_b. I.e. map a_b^c => (a_b)^c + def fix_msubsup(parent): + for child in parent: + # fix msubsup + if (gettag(child) == 'msubsup' and len(child) == 3): + newchild = etree.Element('msup') + newbase = etree.Element('mi') + newbase.text = '%s_%s' % (flatten_pmathml(child[0]), flatten_pmathml(child[1])) + newexp = child[2] + newchild.append(newbase) + newchild.append(newexp) + parent.replace(child, newchild) + + fix_msubsup(child) + fix_msubsup(xml) + self.xml = xml return self.xml @@ -257,6 +316,7 @@ class formula(object): try: xml = self.preprocess_pmathml(self.expr) except Exception, err: + # print 'Err %s while preprocessing; expr=%s' % (err, self.expr) return "Error! Cannot process pmathml" pmathml = etree.tostring(xml, pretty_print=True) self.the_pmathml = pmathml From c6545eb092d7bcbe2d934ac2753d6fb8113f0468 Mon Sep 17 00:00:00 2001 From: Peter Baratta Date: Wed, 6 Mar 2013 06:21:08 -0700 Subject: [PATCH 002/128] Begin to document symmath as we go --- .../js/capa/symbolic_mathjax_preprocessor.js | 22 +++++++ .../course_data_formats/symbolic_response.rst | 26 ++++++++ lms/lib/symmath/formula.py | 59 +++++++++++++++++-- 3 files changed, 101 insertions(+), 6 deletions(-) create mode 100644 common/static/js/capa/symbolic_mathjax_preprocessor.js create mode 100644 doc/public/course_data_formats/symbolic_response.rst diff --git a/common/static/js/capa/symbolic_mathjax_preprocessor.js b/common/static/js/capa/symbolic_mathjax_preprocessor.js new file mode 100644 index 0000000000..19104553dc --- /dev/null +++ b/common/static/js/capa/symbolic_mathjax_preprocessor.js @@ -0,0 +1,22 @@ +window.SymbolicMathjaxPreprocessor = function () { + this.fn = function (eqn) { + // flags and config + var superscriptsOn = true; + + if (superscriptsOn) { + // find instances of "__" and make them superscripts ("^") and tag them + // as such. Specifcally replace instances of "__X" or "__{XYZ}" with + // "^{CHAR$1}", marking superscripts as different from powers + + // a zero width space--this is an invisible character that no one would + // use, that gets passed through MathJax and to the server + var c = "\u200b"; + eqn = eqn.replace(/__(?:([^\{])|\{([^\}]+)\})/g, '^{' + c + '$1$2}'); + + // NOTE: MathJax supports '\class{name}{mathcode}' but not for asciimath + // input, which is too bad. This would be preferable to the char tag + } + + return eqn; + }; +}; diff --git a/doc/public/course_data_formats/symbolic_response.rst b/doc/public/course_data_formats/symbolic_response.rst new file mode 100644 index 0000000000..773821766e --- /dev/null +++ b/doc/public/course_data_formats/symbolic_response.rst @@ -0,0 +1,26 @@ +################# +Symbolic Response +################# + +This document plans to document features that the current symbolic response +supports. In general it allows the input and validation of math expressions, +up to commutativity and some identities. + + +******** +Features +******** + +This is a partial list of features, to be revised as we go along: + * sub and superscripts: an expression following the ``^`` character + indicates exponentiation. To use superscripts in variables, the syntax + is ``b_x__d`` for the variable ``b`` with subscript ``x`` and super + ``d``. + + An example of a problem:: + + + + + + It's a bit of a pain to enter that. diff --git a/lms/lib/symmath/formula.py b/lms/lib/symmath/formula.py index 7c4ea084d6..914a65d1b0 100644 --- a/lms/lib/symmath/formula.py +++ b/lms/lib/symmath/formula.py @@ -248,14 +248,21 @@ class formula(object): fix_hat(xml) def flatten_pmathml(xml): - ''' - Give the text version of PMathML elements + ''' Give the text version of certain PMathML elements + + Sometimes MathML will be given with each letter separated (it + doesn't know if its implicit multiplication or what). From an xml + node, find the (text only) variable name it represents. So it takes + + m + a + x + + and returns 'max', for easier use later on. ''' tag = gettag(xml) if tag == 'mn': return xml.text elif tag == 'mi': return xml.text - # elif tag == 'msub': return '_'.join([flatten_pmathml(y) for y in xml]) - # elif tag == 'msup': return '^'.join([flatten_pmathml(y) for y in xml]) elif tag == 'mrow': return ''.join([flatten_pmathml(y) for y in xml]) raise Exception, '[flatten_pmathml] unknown tag %s' % tag @@ -263,23 +270,63 @@ class formula(object): # they have the character \u200b in the superscript # replace them with a__b so snuggle doesn't get confused def fix_superscripts(xml): + ''' Look for and replace sup elements with 'X__Y' or 'X_Y__Z' + + In the javascript, variables with '__X' in them had an invisible + character inserted into the sup (to distinguish from powers) + E.g. normal: + + a + b + c + + to be interpreted '(a_b)^c' (nothing done by this method) + + And modified: + + b + x + + + d + + + to be interpreted 'a_b__c' + + also: + + x + + + B + + + to be 'x__B' + ''' for k in xml: tag = gettag(k) - # match node to a superscript + # match things like the last example-- + # the second item in msub is an mrow with the first + # character equal to \u200b if (tag == 'msup' and len(k) == 2 and gettag(k[1]) == 'mrow' and gettag(k[1][0]) == 'mo' and k[1][0].text == u'\u200b'): # whew + # replace the msup with 'X__Y' k[1].remove(k[1][0]) newk = etree.Element('mi') newk.text = '%s__%s' % (flatten_pmathml(k[0]), flatten_pmathml(k[1])) xml.replace(k, newk) + # match things like the middle example- + # the third item in msubsup is an mrow with the first + # character equal to \u200b if (tag == 'msubsup' and len(k) == 3 and gettag(k[2]) == 'mrow' and gettag(k[2][0]) == 'mo' and k[2][0].text == u'\u200b'): # whew + # replace the msubsup with 'X_Y__Z' k[2].remove(k[2][0]) newk = etree.Element('mi') newk.text = '%s_%s__%s' % (flatten_pmathml(k[0]), flatten_pmathml(k[1]), flatten_pmathml(k[2])) @@ -316,7 +363,7 @@ class formula(object): try: xml = self.preprocess_pmathml(self.expr) except Exception, err: - # print 'Err %s while preprocessing; expr=%s' % (err, self.expr) + log.warning('Err %s while preprocessing; expr=%s' % (err, self.expr)) return "Error! Cannot process pmathml" pmathml = etree.tostring(xml, pretty_print=True) self.the_pmathml = pmathml From 49f85211fa5c5550897d25aceb786ac82d1259ee Mon Sep 17 00:00:00 2001 From: Peter Baratta Date: Fri, 8 Mar 2013 03:39:34 -0700 Subject: [PATCH 003/128] More documentation for the javascript --- .../js/capa/symbolic_mathjax_preprocessor.js | 45 ++++++++++++------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/common/static/js/capa/symbolic_mathjax_preprocessor.js b/common/static/js/capa/symbolic_mathjax_preprocessor.js index 19104553dc..766e5efc03 100644 --- a/common/static/js/capa/symbolic_mathjax_preprocessor.js +++ b/common/static/js/capa/symbolic_mathjax_preprocessor.js @@ -1,22 +1,35 @@ +/* This file defines a processor in between the student's math input + (AsciiMath) and what is read by MathJax. It allows for our own + customizations, such as use of the syntax "a_b__x" in superscripts, or + possibly coloring certain variables, etc&. + + It is used in the definition like the following: + + + + +*/ window.SymbolicMathjaxPreprocessor = function () { - this.fn = function (eqn) { - // flags and config - var superscriptsOn = true; + this.fn = function (eqn) { + // flags and config + var superscriptsOn = true; - if (superscriptsOn) { - // find instances of "__" and make them superscripts ("^") and tag them - // as such. Specifcally replace instances of "__X" or "__{XYZ}" with - // "^{CHAR$1}", marking superscripts as different from powers + if (superscriptsOn) { + // find instances of "__" and make them superscripts ("^") and tag them + // as such. Specifcally replace instances of "__X" or "__{XYZ}" with + // "^{CHAR$1}", marking superscripts as different from powers - // a zero width space--this is an invisible character that no one would - // use, that gets passed through MathJax and to the server - var c = "\u200b"; - eqn = eqn.replace(/__(?:([^\{])|\{([^\}]+)\})/g, '^{' + c + '$1$2}'); + // a zero width space--this is an invisible character that no one would + // use, that gets passed through MathJax and to the server + var c = "\u200b"; + eqn = eqn.replace(/__(?:([^\{])|\{([^\}]+)\})/g, '^{' + c + '$1$2}'); - // NOTE: MathJax supports '\class{name}{mathcode}' but not for asciimath - // input, which is too bad. This would be preferable to the char tag - } + // NOTE: MathJax supports '\class{name}{mathcode}' but not for asciimath + // input, which is too bad. This would be preferable to this char tag + } - return eqn; - }; + return eqn; + }; }; From 75b561267c0f8162f9e69b8c085da0544c30bc6b Mon Sep 17 00:00:00 2001 From: Peter Baratta Date: Thu, 14 Mar 2013 05:09:15 -0600 Subject: [PATCH 004/128] Script feature fix --- .../course_data_formats/symbolic_response.rst | 20 ++- lms/lib/symmath/formula.py | 25 ++++ lms/lib/symmath/test_formula.py | 115 ++++++++++++++++++ 3 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 lms/lib/symmath/test_formula.py diff --git a/doc/public/course_data_formats/symbolic_response.rst b/doc/public/course_data_formats/symbolic_response.rst index 773821766e..8463faab3c 100644 --- a/doc/public/course_data_formats/symbolic_response.rst +++ b/doc/public/course_data_formats/symbolic_response.rst @@ -19,8 +19,22 @@ This is a partial list of features, to be revised as we go along: An example of a problem:: - - - + + + It's a bit of a pain to enter that. + + * The script-style math variant. What would be outputted in latex if you + entered ``\mathcal{N}``. This is used in some variables. + + An example:: + + + + + + There is no fancy preprocessing needed, but if you had superscripts or + something, you would need to include that part. diff --git a/lms/lib/symmath/formula.py b/lms/lib/symmath/formula.py index 914a65d1b0..604941ffdd 100644 --- a/lms/lib/symmath/formula.py +++ b/lms/lib/symmath/formula.py @@ -74,6 +74,15 @@ def to_latex(x): # LatexPrinter._print_dot = _print_dot xs = latex(x) xs = xs.replace(r'\XI', 'XI') # workaround for strange greek + + # substitute back into latex form for scripts + # literally something of the form + # 'scriptN' becomes '\\mathcal{N}' + # note: can't use something akin to the _print_hat method above because we sometimes get 'script(N)__B' or more complicated terms + xs = re.sub(r'script([a-zA-Z0-9]+)', + '\\mathcal{\\1}', + xs) + #return '%s{}{}' % (xs[1:-1]) if xs[0] == '$': return '[mathjax]%s[/mathjax]
' % (xs[1:-1]) # for sympy v6 @@ -106,6 +115,7 @@ def my_sympify(expr, normphase=False, matrix=False, abcsym=False, do_qubit=False 'i': sympy.I, # lowercase i is also sqrt(-1) 'Q': sympy.Symbol('Q'), # otherwise it is a sympy "ask key" 'I': sympy.Symbol('I'), # otherwise it is sqrt(-1) + 'N': sympy.Symbol('N'), # or it is some kind of sympy function #'X':sympy.sympify('Matrix([[0,1],[1,0]])'), #'Y':sympy.sympify('Matrix([[0,-I],[I,0]])'), #'Z':sympy.sympify('Matrix([[1,0],[0,-1]])'), @@ -266,6 +276,21 @@ class formula(object): elif tag == 'mrow': return ''.join([flatten_pmathml(y) for y in xml]) raise Exception, '[flatten_pmathml] unknown tag %s' % tag + def fix_mathvariant(parent): + '''Fix certain kinds of math variants + + Literally replace N + with 'scriptN'. There have been problems using script_N or script(N) + ''' + for child in parent: + if (gettag(child) == 'mstyle' and child.get('mathvariant') == 'script'): + newchild = etree.Element('mi') + newchild.text = 'script%s' % flatten_pmathml(child[0]) + parent.replace(child, newchild) + fix_mathvariant(child) + fix_mathvariant(xml) + + # find "tagged" superscripts # they have the character \u200b in the superscript # replace them with a__b so snuggle doesn't get confused diff --git a/lms/lib/symmath/test_formula.py b/lms/lib/symmath/test_formula.py new file mode 100644 index 0000000000..d3f16ed6b3 --- /dev/null +++ b/lms/lib/symmath/test_formula.py @@ -0,0 +1,115 @@ +""" +Tests of symbolic math +""" + + +import unittest +import formula +import re +from lxml import etree + +def stripXML(xml): + xml = xml.replace('\n', '') + xml = re.sub(r'\> +\<', '><', xml) + return xml + +class FormulaTest(unittest.TestCase): + # for readability later + mathml_start = '' + mathml_end = '' + + def setUp(self): + self.formulaInstance = formula.formula('') + + def test_replace_mathvariants(self): + expr = ''' + + N +''' + + expected = 'scriptN' + + # wrap + expr = stripXML(self.mathml_start + expr + self.mathml_end) + expected = stripXML(self.mathml_start + expected + self.mathml_end) + + # process the expression + xml = etree.fromstring(expr) + xml = self.formulaInstance.preprocess_pmathml(xml) + test = etree.tostring(xml) + + # success? + self.assertEqual(test, expected) + + + def test_fix_simple_superscripts(self): + expr = ''' + + a + + + b + +''' + + expected = 'a__b' + + # wrap + expr = stripXML(self.mathml_start + expr + self.mathml_end) + expected = stripXML(self.mathml_start + expected + self.mathml_end) + + # process the expression + xml = etree.fromstring(expr) + xml = self.formulaInstance.preprocess_pmathml(xml) + test = etree.tostring(xml) + + # success? + self.assertEqual(test, expected) + + def test_fix_complex_superscripts(self): + expr = ''' + + a + b + + + c + +''' + + expected = 'a_b__c' + + # wrap + expr = stripXML(self.mathml_start + expr + self.mathml_end) + expected = stripXML(self.mathml_start + expected + self.mathml_end) + + # process the expression + xml = etree.fromstring(expr) + xml = self.formulaInstance.preprocess_pmathml(xml) + test = etree.tostring(xml) + + # success? + self.assertEqual(test, expected) + + + def test_fix_msubsup(self): + expr = ''' + + a + b + c +''' + + expected = 'a_bc' # which is (a_b)^c + + # wrap + expr = stripXML(self.mathml_start + expr + self.mathml_end) + expected = stripXML(self.mathml_start + expected + self.mathml_end) + + # process the expression + xml = etree.fromstring(expr) + xml = self.formulaInstance.preprocess_pmathml(xml) + test = etree.tostring(xml) + + # success? + self.assertEqual(test, expected) From 4c8a45f85ecfb6422bd10de3b79f3a5ef51c70f9 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 14 Mar 2013 13:29:26 -0400 Subject: [PATCH 005/128] 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 006/128] 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 007/128] 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 008/128] 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 df935d422d31fcf34489f8b0fa501a4ac627212a Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Tue, 26 Mar 2013 09:52:26 -0400 Subject: [PATCH 009/128] Fix issues with open ended image grading and peer grading centralized module finder. --- .../open_ended_grading_classes/openendedchild.py | 4 ---- lms/djangoapps/courseware/module_render.py | 10 +++------- lms/djangoapps/open_ended_grading/views.py | 2 +- 3 files changed, 4 insertions(+), 12 deletions(-) diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py index 2e49565bec..b9341f0cbe 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py @@ -357,10 +357,6 @@ class OpenEndedChild(object): if get_data['can_upload_files'] in ['true', '1']: has_file_to_upload = True file = get_data['student_file'][0] - if self.system.track_fuction: - self.system.track_function('open_ended_image_upload', {'filename': file.name}) - else: - log.info("No tracking function found when uploading image.") uploaded_to_s3, image_ok, s3_public_url = self.upload_image_to_s3(file) if uploaded_to_s3: image_tag = self.generate_image_tag_from_url(s3_public_url, file.name) diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 973940d784..a1c09d3d83 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -208,9 +208,6 @@ def get_module_for_descriptor(user, request, descriptor, model_data_cache, cours 'waittime': settings.XQUEUE_WAITTIME_BETWEEN_REQUESTS } - def get_or_default(key, default): - getattr(settings, key, default) - #This is a hacky way to pass settings to the combined open ended xmodule #It needs an S3 interface to upload images to S3 #It needs the open ended grading interface in order to get peer grading to be done @@ -226,12 +223,11 @@ def get_module_for_descriptor(user, request, descriptor, model_data_cache, cours open_ended_grading_interface['mock_staff_grading'] = settings.MOCK_STAFF_GRADING if is_descriptor_combined_open_ended: s3_interface = { - 'access_key' : get_or_default('AWS_ACCESS_KEY_ID',''), - 'secret_access_key' : get_or_default('AWS_SECRET_ACCESS_KEY',''), - 'storage_bucket_name' : get_or_default('AWS_STORAGE_BUCKET_NAME','') + 'access_key' : getattr(settings,'AWS_ACCESS_KEY_ID',''), + 'secret_access_key' : getattr(settings,'AWS_SECRET_ACCESS_KEY',''), + 'storage_bucket_name' : getattr(settings,'AWS_STORAGE_BUCKET_NAME','') } - def inner_get_module(descriptor): """ Delegate to get_module. It does an access check, so may return None diff --git a/lms/djangoapps/open_ended_grading/views.py b/lms/djangoapps/open_ended_grading/views.py index 65cfe22ed0..78da00bf2b 100644 --- a/lms/djangoapps/open_ended_grading/views.py +++ b/lms/djangoapps/open_ended_grading/views.py @@ -111,7 +111,7 @@ def peer_grading(request, course_id): #Get the peer grading modules currently in the course items = modulestore().get_items(['i4x', None, course_id_parts[1], 'peergrading', None]) #See if any of the modules are centralized modules (ie display info from multiple problems) - items = [i for i in items if i.metadata.get("use_for_single_location", True) in false_dict] + items = [i for i in items if getattr(i,"use_for_single_location", True) in false_dict] #Get the first one item_location = items[0].location #Generate a url for the first module and redirect the user to it From d4615da555f77a15ba7c4f70d380f813f195a6f7 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Tue, 26 Mar 2013 09:57:52 -0400 Subject: [PATCH 010/128] Adjust max image dim, add in safety for rewriting links --- .../combined_open_ended_modulev1.py | 6 +++++- .../open_ended_image_submission.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) 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 98a54601de..c7df87fd45 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 @@ -363,7 +363,11 @@ class CombinedOpenEndedV1Module(): """ self.update_task_states() html = self.current_task.get_html(self.system) - return_html = rewrite_links(html, self.rewrite_content_links) + return_html = html + try: + return_html = rewrite_links(html, self.rewrite_content_links) + except: + pass return return_html def get_current_attributes(self, task_number): diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_image_submission.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_image_submission.py index 6956f336a5..759645840f 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_image_submission.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_image_submission.py @@ -36,7 +36,7 @@ ALLOWABLE_IMAGE_SUFFIXES = [ ] #Maximum allowed dimensions (x and y) for an uploaded image -MAX_ALLOWED_IMAGE_DIM = 1500 +MAX_ALLOWED_IMAGE_DIM = 2000 #Dimensions to which image is resized before it is evaluated for color count, etc MAX_IMAGE_DIM = 150 From 8afe2eb001a925bd49e9e5fb9678c3572e47ad0e Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Tue, 26 Mar 2013 10:35:47 -0400 Subject: [PATCH 011/128] Increase max score allowed --- .../open_ended_grading_classes/combined_open_ended_modulev1.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c7df87fd45..f88fd9ab82 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 @@ -24,7 +24,7 @@ MAX_ATTEMPTS = 1 MAX_SCORE = 1 #The highest score allowed for the overall xmodule and for each rubric point -MAX_SCORE_ALLOWED = 3 +MAX_SCORE_ALLOWED = 50 #If true, default behavior is to score module as a practice problem. Otherwise, no grade at all is shown in progress #Metadata overrides this. From 97cb4910a7b8d36123941538776a1d53ec4be034 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Tue, 26 Mar 2013 11:04:14 -0400 Subject: [PATCH 012/128] Add in default bucket, edit image url checks --- .../open_ended_grading_classes/open_ended_image_submission.py | 2 +- lms/djangoapps/courseware/module_render.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_image_submission.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_image_submission.py index 759645840f..2eb9502269 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_image_submission.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_image_submission.py @@ -178,7 +178,7 @@ class URLProperties(object): Runs all available url tests @return: True if URL passes tests, false if not. """ - url_is_okay = self.check_suffix() and self.check_if_parses() and self.check_domain() + url_is_okay = self.check_suffix() and self.check_if_parses() return url_is_okay def check_domain(self): diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index a1c09d3d83..15f95f1beb 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -225,7 +225,7 @@ def get_module_for_descriptor(user, request, descriptor, model_data_cache, cours s3_interface = { 'access_key' : getattr(settings,'AWS_ACCESS_KEY_ID',''), 'secret_access_key' : getattr(settings,'AWS_SECRET_ACCESS_KEY',''), - 'storage_bucket_name' : getattr(settings,'AWS_STORAGE_BUCKET_NAME','') + 'storage_bucket_name' : getattr(settings,'AWS_STORAGE_BUCKET_NAME','openended') } def inner_get_module(descriptor): From 24301d2a0761510143f7bc62bc9d7d0d01abd5ca Mon Sep 17 00:00:00 2001 From: Will Daly Date: Fri, 22 Mar 2013 11:30:31 -0400 Subject: [PATCH 013/128] Moved helper functions from terrain/steps.py to terrain/helpers.py --- common/djangoapps/terrain/helpers.py | 152 +++++++++++++++++++++++++ common/djangoapps/terrain/steps.py | 164 ++------------------------- 2 files changed, 159 insertions(+), 157 deletions(-) create mode 100644 common/djangoapps/terrain/helpers.py diff --git a/common/djangoapps/terrain/helpers.py b/common/djangoapps/terrain/helpers.py new file mode 100644 index 0000000000..55c8f3db5a --- /dev/null +++ b/common/djangoapps/terrain/helpers.py @@ -0,0 +1,152 @@ +from lettuce import world, step +from .factories import * +from django.conf import settings +from django.http import HttpRequest +from django.contrib.auth.models import User +from django.contrib.auth import authenticate, login +from django.contrib.auth.middleware import AuthenticationMiddleware +from django.contrib.sessions.middleware import SessionMiddleware +from student.models import CourseEnrollment +from bs4 import BeautifulSoup +import os.path +from selenium.common.exceptions import WebDriverException +from urllib import quote_plus +from lettuce.django import django_url + +@world.absorb +def wait(seconds): + time.sleep(float(seconds)) + +@world.absorb +def scroll_to_bottom(): + # Maximize the browser + world.browser.execute_script("window.scrollTo(0, screen.height);") + + +@world.absorb +def create_user(uname): + + # If the user already exists, don't try to create it again + if len(User.objects.filter(username=uname)) > 0: + return + + portal_user = UserFactory.build(username=uname, email=uname + '@edx.org') + portal_user.set_password('test') + portal_user.save() + + registration = world.RegistrationFactory(user=portal_user) + registration.register(portal_user) + registration.activate() + + user_profile = world.UserProfileFactory(user=portal_user) + + +@world.absorb +def log_in(username, password): + ''' + Log the user in programatically + ''' + + # Authenticate the user + user = authenticate(username=username, password=password) + assert(user is not None and user.is_active) + + # Send a fake HttpRequest to log the user in + # We need to process the request using + # Session middleware and Authentication middleware + # to ensure that session state can be stored + request = HttpRequest() + SessionMiddleware().process_request(request) + AuthenticationMiddleware().process_request(request) + login(request, user) + + # Save the session + request.session.save() + + # Retrieve the sessionid and add it to the browser's cookies + cookie_dict = {settings.SESSION_COOKIE_NAME: request.session.session_key} + try: + world.browser.cookies.add(cookie_dict) + + # WebDriver has an issue where we cannot set cookies + # before we make a GET request, so if we get an error, + # we load the '/' page and try again + except: + world.browser.visit(django_url('/')) + world.browser.cookies.add(cookie_dict) + + +@world.absorb +def register_by_course_id(course_id, is_staff=False): + create_user('robot') + u = User.objects.get(username='robot') + if is_staff: + u.is_staff = True + u.save() + CourseEnrollment.objects.get_or_create(user=u, course_id=course_id) + + +@world.absorb +def save_the_html(path='/tmp'): + u = world.browser.url + html = world.browser.html.encode('ascii', 'ignore') + filename = '%s.html' % quote_plus(u) + f = open('%s/%s' % (path, filename), 'w') + f.write(html) + f.close + + +@world.absorb +def save_the_course_content(path='/tmp'): + html = world.browser.html.encode('ascii', 'ignore') + soup = BeautifulSoup(html) + + # get rid of the header, we only want to compare the body + soup.head.decompose() + + # for now, remove the data-id attributes, because they are + # causing mismatches between cms-master and master + for item in soup.find_all(attrs={'data-id': re.compile('.*')}): + del item['data-id'] + + # we also need to remove them from unrendered problems, + # where they are contained in the text of divs instead of + # in attributes of tags + # Be careful of whether or not it was the last attribute + # and needs a trailing space + for item in soup.find_all(text=re.compile(' data-id=".*?" ')): + s = unicode(item.string) + item.string.replace_with(re.sub(' data-id=".*?" ', ' ', s)) + + for item in soup.find_all(text=re.compile(' data-id=".*?"')): + s = unicode(item.string) + item.string.replace_with(re.sub(' data-id=".*?"', ' ', s)) + + # prettify the html so it will compare better, with + # each HTML tag on its own line + output = soup.prettify() + + # use string slicing to grab everything after 'courseware/' in the URL + u = world.browser.url + section_url = u[u.find('courseware/') + 11:] + + + if not os.path.exists(path): + os.makedirs(path) + + filename = '%s.html' % (quote_plus(section_url)) + f = open('%s/%s' % (path, filename), 'w') + f.write(output) + f.close + +@world.absorb +def css_click(css_selector): + try: + world.browser.find_by_css(css_selector).click() + + except WebDriverException: + # Occassionally, MathJax or other JavaScript can cover up + # an element temporarily. + # If this happens, wait a second, then try again + time.sleep(1) + world.browser.find_by_css(css_selector).click() diff --git a/common/djangoapps/terrain/steps.py b/common/djangoapps/terrain/steps.py index 3bc838a6af..ae36227fee 100644 --- a/common/djangoapps/terrain/steps.py +++ b/common/djangoapps/terrain/steps.py @@ -1,20 +1,8 @@ from lettuce import world, step -from .factories import * +from .helpers import * from lettuce.django import django_url -from django.conf import settings -from django.http import HttpRequest -from django.contrib.auth.models import User -from django.contrib.auth import authenticate, login -from django.contrib.auth.middleware import AuthenticationMiddleware -from django.contrib.sessions.middleware import SessionMiddleware -from student.models import CourseEnrollment -from urllib import quote_plus from nose.tools import assert_equals -from bs4 import BeautifulSoup import time -import re -import os.path -from selenium.common.exceptions import WebDriverException from logging import getLogger logger = getLogger(__name__) @@ -22,8 +10,7 @@ logger = getLogger(__name__) @step(u'I wait (?:for )?"(\d+)" seconds?$') def wait(step, seconds): - time.sleep(float(seconds)) - + world.wait(seconds) @step('I reload the page$') def reload_the_page(step): @@ -87,8 +74,8 @@ def the_page_title_should_contain(step, title): @step('I am a logged in user$') def i_am_logged_in_user(step): - create_user('robot') - log_in('robot', 'test') + world.create_user('robot') + world.log_in('robot', 'test') @step('I am not logged in$') @@ -98,151 +85,14 @@ def i_am_not_logged_in(step): @step('I am staff for course "([^"]*)"$') def i_am_staff_for_course_by_id(step, course_id): - register_by_course_id(course_id, True) + world.register_by_course_id(course_id, True) @step('I log in$') def i_log_in(step): - log_in('robot', 'test') + world.log_in('robot', 'test') @step(u'I am an edX user$') def i_am_an_edx_user(step): - create_user('robot') - -#### helper functions - - -@world.absorb -def scroll_to_bottom(): - # Maximize the browser - world.browser.execute_script("window.scrollTo(0, screen.height);") - - -@world.absorb -def create_user(uname): - - # If the user already exists, don't try to create it again - if len(User.objects.filter(username=uname)) > 0: - return - - portal_user = UserFactory.build(username=uname, email=uname + '@edx.org') - portal_user.set_password('test') - portal_user.save() - - registration = world.RegistrationFactory(user=portal_user) - registration.register(portal_user) - registration.activate() - - user_profile = world.UserProfileFactory(user=portal_user) - - -@world.absorb -def log_in(username, password): - ''' - Log the user in programatically - ''' - - # Authenticate the user - user = authenticate(username=username, password=password) - assert(user is not None and user.is_active) - - # Send a fake HttpRequest to log the user in - # We need to process the request using - # Session middleware and Authentication middleware - # to ensure that session state can be stored - request = HttpRequest() - SessionMiddleware().process_request(request) - AuthenticationMiddleware().process_request(request) - login(request, user) - - # Save the session - request.session.save() - - # Retrieve the sessionid and add it to the browser's cookies - cookie_dict = {settings.SESSION_COOKIE_NAME: request.session.session_key} - try: - world.browser.cookies.add(cookie_dict) - - # WebDriver has an issue where we cannot set cookies - # before we make a GET request, so if we get an error, - # we load the '/' page and try again - except: - world.browser.visit(django_url('/')) - world.browser.cookies.add(cookie_dict) - - -@world.absorb -def register_by_course_id(course_id, is_staff=False): - create_user('robot') - u = User.objects.get(username='robot') - if is_staff: - u.is_staff = True - u.save() - CourseEnrollment.objects.get_or_create(user=u, course_id=course_id) - - -@world.absorb -def save_the_html(path='/tmp'): - u = world.browser.url - html = world.browser.html.encode('ascii', 'ignore') - filename = '%s.html' % quote_plus(u) - f = open('%s/%s' % (path, filename), 'w') - f.write(html) - f.close - - -@world.absorb -def save_the_course_content(path='/tmp'): - html = world.browser.html.encode('ascii', 'ignore') - soup = BeautifulSoup(html) - - # get rid of the header, we only want to compare the body - soup.head.decompose() - - # for now, remove the data-id attributes, because they are - # causing mismatches between cms-master and master - for item in soup.find_all(attrs={'data-id': re.compile('.*')}): - del item['data-id'] - - # we also need to remove them from unrendered problems, - # where they are contained in the text of divs instead of - # in attributes of tags - # Be careful of whether or not it was the last attribute - # and needs a trailing space - for item in soup.find_all(text=re.compile(' data-id=".*?" ')): - s = unicode(item.string) - item.string.replace_with(re.sub(' data-id=".*?" ', ' ', s)) - - for item in soup.find_all(text=re.compile(' data-id=".*?"')): - s = unicode(item.string) - item.string.replace_with(re.sub(' data-id=".*?"', ' ', s)) - - # prettify the html so it will compare better, with - # each HTML tag on its own line - output = soup.prettify() - - # use string slicing to grab everything after 'courseware/' in the URL - u = world.browser.url - section_url = u[u.find('courseware/') + 11:] - - - if not os.path.exists(path): - os.makedirs(path) - - filename = '%s.html' % (quote_plus(section_url)) - f = open('%s/%s' % (path, filename), 'w') - f.write(output) - f.close - -@world.absorb -def css_click(css_selector): - try: - world.browser.find_by_css(css_selector).click() - - except WebDriverException: - # Occassionally, MathJax or other JavaScript can cover up - # an element temporarily. - # If this happens, wait a second, then try again - time.sleep(1) - world.browser.find_by_css(css_selector).click() + world.create_user('robot') From 315b360e4cafeab3fec798272ed2e5ee22cb88d0 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Fri, 22 Mar 2013 11:31:41 -0400 Subject: [PATCH 014/128] Fixed an import error in terrain/helpers.py --- common/djangoapps/terrain/helpers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/common/djangoapps/terrain/helpers.py b/common/djangoapps/terrain/helpers.py index 55c8f3db5a..12d6818659 100644 --- a/common/djangoapps/terrain/helpers.py +++ b/common/djangoapps/terrain/helpers.py @@ -12,6 +12,7 @@ import os.path from selenium.common.exceptions import WebDriverException from urllib import quote_plus from lettuce.django import django_url +import time @world.absorb def wait(seconds): From e494d529fc48f21c1bb01bdee7dc8515035b6219 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Fri, 22 Mar 2013 11:38:30 -0400 Subject: [PATCH 015/128] Split terrain/helpers.py into ui_helpers.py and course_helpers.py --- .../terrain/{helpers.py => course_helpers.py} | 32 ------------------- common/djangoapps/terrain/steps.py | 3 +- common/djangoapps/terrain/ui_helpers.py | 30 +++++++++++++++++ 3 files changed, 32 insertions(+), 33 deletions(-) rename common/djangoapps/terrain/{helpers.py => course_helpers.py} (82%) create mode 100644 common/djangoapps/terrain/ui_helpers.py diff --git a/common/djangoapps/terrain/helpers.py b/common/djangoapps/terrain/course_helpers.py similarity index 82% rename from common/djangoapps/terrain/helpers.py rename to common/djangoapps/terrain/course_helpers.py index 12d6818659..dbdaa2a21c 100644 --- a/common/djangoapps/terrain/helpers.py +++ b/common/djangoapps/terrain/course_helpers.py @@ -12,17 +12,6 @@ import os.path from selenium.common.exceptions import WebDriverException from urllib import quote_plus from lettuce.django import django_url -import time - -@world.absorb -def wait(seconds): - time.sleep(float(seconds)) - -@world.absorb -def scroll_to_bottom(): - # Maximize the browser - world.browser.execute_script("window.scrollTo(0, screen.height);") - @world.absorb def create_user(uname): @@ -87,15 +76,6 @@ def register_by_course_id(course_id, is_staff=False): CourseEnrollment.objects.get_or_create(user=u, course_id=course_id) -@world.absorb -def save_the_html(path='/tmp'): - u = world.browser.url - html = world.browser.html.encode('ascii', 'ignore') - filename = '%s.html' % quote_plus(u) - f = open('%s/%s' % (path, filename), 'w') - f.write(html) - f.close - @world.absorb def save_the_course_content(path='/tmp'): @@ -139,15 +119,3 @@ def save_the_course_content(path='/tmp'): f = open('%s/%s' % (path, filename), 'w') f.write(output) f.close - -@world.absorb -def css_click(css_selector): - try: - world.browser.find_by_css(css_selector).click() - - except WebDriverException: - # Occassionally, MathJax or other JavaScript can cover up - # an element temporarily. - # If this happens, wait a second, then try again - time.sleep(1) - world.browser.find_by_css(css_selector).click() diff --git a/common/djangoapps/terrain/steps.py b/common/djangoapps/terrain/steps.py index ae36227fee..6e54b71aa6 100644 --- a/common/djangoapps/terrain/steps.py +++ b/common/djangoapps/terrain/steps.py @@ -1,5 +1,6 @@ from lettuce import world, step -from .helpers import * +from .course_helpers import * +from .ui_helpers import * from lettuce.django import django_url from nose.tools import assert_equals import time diff --git a/common/djangoapps/terrain/ui_helpers.py b/common/djangoapps/terrain/ui_helpers.py new file mode 100644 index 0000000000..4667957e87 --- /dev/null +++ b/common/djangoapps/terrain/ui_helpers.py @@ -0,0 +1,30 @@ +from lettuce import world, step +import time +from urllib import quote_plus + +@world.absorb +def wait(seconds): + time.sleep(float(seconds)) + + +@world.absorb +def css_click(css_selector): + try: + world.browser.find_by_css(css_selector).click() + + except WebDriverException: + # Occassionally, MathJax or other JavaScript can cover up + # an element temporarily. + # If this happens, wait a second, then try again + time.sleep(1) + world.browser.find_by_css(css_selector).click() + +@world.absorb +def save_the_html(path='/tmp'): + u = world.browser.url + html = world.browser.html.encode('ascii', 'ignore') + filename = '%s.html' % quote_plus(u) + f = open('%s/%s' % (path, filename), 'w') + f.write(html) + f.close + From 0562f11c5622c94214162ac5c43fd69b8851601f Mon Sep 17 00:00:00 2001 From: Will Daly Date: Fri, 22 Mar 2013 11:41:30 -0400 Subject: [PATCH 016/128] Fixed import issue with WebDriverException --- common/djangoapps/terrain/course_helpers.py | 1 - common/djangoapps/terrain/ui_helpers.py | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/common/djangoapps/terrain/course_helpers.py b/common/djangoapps/terrain/course_helpers.py index dbdaa2a21c..8c949de1ad 100644 --- a/common/djangoapps/terrain/course_helpers.py +++ b/common/djangoapps/terrain/course_helpers.py @@ -9,7 +9,6 @@ from django.contrib.sessions.middleware import SessionMiddleware from student.models import CourseEnrollment from bs4 import BeautifulSoup import os.path -from selenium.common.exceptions import WebDriverException from urllib import quote_plus from lettuce.django import django_url diff --git a/common/djangoapps/terrain/ui_helpers.py b/common/djangoapps/terrain/ui_helpers.py index 4667957e87..2ad7150740 100644 --- a/common/djangoapps/terrain/ui_helpers.py +++ b/common/djangoapps/terrain/ui_helpers.py @@ -1,6 +1,7 @@ from lettuce import world, step import time from urllib import quote_plus +from selenium.common.exceptions import WebDriverException @world.absorb def wait(seconds): From b0eb73302b9753acbc53f3ddc4fe86226f51292b Mon Sep 17 00:00:00 2001 From: Will Daly Date: Fri, 22 Mar 2013 11:50:50 -0400 Subject: [PATCH 017/128] Moved some courseware/features/common.py steps into terrain/steps.py --- common/djangoapps/terrain/steps.py | 38 ++++++++- lms/djangoapps/courseware/features/common.py | 83 -------------------- 2 files changed, 36 insertions(+), 85 deletions(-) diff --git a/common/djangoapps/terrain/steps.py b/common/djangoapps/terrain/steps.py index 6e54b71aa6..8356b5446d 100644 --- a/common/djangoapps/terrain/steps.py +++ b/common/djangoapps/terrain/steps.py @@ -72,6 +72,9 @@ def the_page_title_should_be(step, title): def the_page_title_should_contain(step, title): assert(title in world.browser.title) +@step('I log in$') +def i_log_in(step): + world.log_in('robot', 'test') @step('I am a logged in user$') def i_am_logged_in_user(step): @@ -89,11 +92,42 @@ def i_am_staff_for_course_by_id(step, course_id): world.register_by_course_id(course_id, True) -@step('I log in$') -def i_log_in(step): +@step(r'click (?:the|a) link (?:called|with the text) "([^"]*)"$') +def click_the_link_called(step, text): + world.browser.find_link_by_text(text).click() + + +@step(r'should see that the url is "([^"]*)"$') +def should_have_the_url(step, url): + assert_equals(world.browser.url, url) + +@step(r'should see (?:the|a) link (?:called|with the text) "([^"]*)"$') +def should_see_a_link_called(step, text): + assert len(world.browser.find_link_by_text(text)) > 0 + +@step(r'should see "(.*)" (?:somewhere|anywhere) in (?:the|this) page') +def should_see_in_the_page(step, text): + assert_in(text, world.browser.html) + + +@step('I am logged in$') +def i_am_logged_in(step): + world.create_user('robot') world.log_in('robot', 'test') + world.browser.visit(django_url('/')) + + +@step('I am not logged in$') +def i_am_not_logged_in(step): + world.browser.cookies.delete() @step(u'I am an edX user$') def i_am_an_edx_user(step): world.create_user('robot') + + +@step(u'User "([^"]*)" is an edX user$') +def registered_edx_user(step, uname): + world.create_user(uname) + diff --git a/lms/djangoapps/courseware/features/common.py b/lms/djangoapps/courseware/features/common.py index 7d41637c8e..8477347580 100644 --- a/lms/djangoapps/courseware/features/common.py +++ b/lms/djangoapps/courseware/features/common.py @@ -6,83 +6,10 @@ from student.models import CourseEnrollment from xmodule.modulestore import Location from xmodule.modulestore.django import _MODULESTORES, modulestore from xmodule.templates import update_templates -import time from logging import getLogger logger = getLogger(__name__) - -@step(u'I wait (?:for )?"(\d+)" seconds?$') -def wait(step, seconds): - time.sleep(float(seconds)) - - -@step('I (?:visit|access|open) the homepage$') -def i_visit_the_homepage(step): - world.browser.visit(django_url('/')) - assert world.browser.is_element_present_by_css('header.global', 10) - - -@step(u'I (?:visit|access|open) the dashboard$') -def i_visit_the_dashboard(step): - world.browser.visit(django_url('/dashboard')) - assert world.browser.is_element_present_by_css('section.container.dashboard', 5) - - -@step(r'click (?:the|a) link (?:called|with the text) "([^"]*)"$') -def click_the_link_called(step, text): - world.browser.find_link_by_text(text).click() - - -@step('I should be on the dashboard page$') -def i_should_be_on_the_dashboard(step): - assert world.browser.is_element_present_by_css('section.container.dashboard', 5) - assert world.browser.title == 'Dashboard' - - -@step(u'I (?:visit|access|open) the courses page$') -def i_am_on_the_courses_page(step): - world.browser.visit(django_url('/courses')) - assert world.browser.is_element_present_by_css('section.courses') - - -@step('I should see that the path is "([^"]*)"$') -def i_should_see_that_the_path_is(step, path): - assert world.browser.url == django_url(path) - - -@step(u'the page title should be "([^"]*)"$') -def the_page_title_should_be(step, title): - assert world.browser.title == title - - -@step(r'should see that the url is "([^"]*)"$') -def should_have_the_url(step, url): - assert_equals(world.browser.url, url) - - -@step(r'should see (?:the|a) link (?:called|with the text) "([^"]*)"$') -def should_see_a_link_called(step, text): - assert len(world.browser.find_link_by_text(text)) > 0 - - -@step(r'should see "(.*)" (?:somewhere|anywhere) in (?:the|this) page') -def should_see_in_the_page(step, text): - assert_in(text, world.browser.html) - - -@step('I am logged in$') -def i_am_logged_in(step): - world.create_user('robot') - world.log_in('robot', 'test') - world.browser.visit(django_url('/')) - - -@step('I am not logged in$') -def i_am_not_logged_in(step): - world.browser.cookies.delete() - - TEST_COURSE_ORG = 'edx' TEST_COURSE_NAME = 'Test Course' TEST_SECTION_NAME = "Problem" @@ -135,16 +62,6 @@ def add_tab_to_course(step, course, extra_tab_name): display_name=str(extra_tab_name)) -@step(u'I am an edX user$') -def i_am_an_edx_user(step): - world.create_user('robot') - - -@step(u'User "([^"]*)" is an edX user$') -def registered_edx_user(step, uname): - world.create_user(uname) - - def flush_xmodule_store(): # Flush and initialize the module store # It needs the templates because it creates new records From c12e1fb1cec0fabd3d825dc7f270381146b1a2e7 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Fri, 22 Mar 2013 11:54:17 -0400 Subject: [PATCH 018/128] Added missing import statement --- common/djangoapps/terrain/steps.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/djangoapps/terrain/steps.py b/common/djangoapps/terrain/steps.py index 8356b5446d..8dac372a64 100644 --- a/common/djangoapps/terrain/steps.py +++ b/common/djangoapps/terrain/steps.py @@ -2,7 +2,7 @@ from lettuce import world, step from .course_helpers import * from .ui_helpers import * from lettuce.django import django_url -from nose.tools import assert_equals +from nose.tools import assert_equals, assert_in import time from logging import getLogger From 5e69050a163fc19e6ce042b206e8a25f105ac509 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Fri, 22 Mar 2013 12:01:55 -0400 Subject: [PATCH 019/128] Elminated unused functions from courseware/features/courses.py and moved the rest to common.py --- lms/djangoapps/courseware/features/common.py | 87 +++++++ lms/djangoapps/courseware/features/courses.py | 234 ------------------ .../courseware/features/smart-accordion.py | 2 +- 3 files changed, 88 insertions(+), 235 deletions(-) delete mode 100644 lms/djangoapps/courseware/features/courses.py diff --git a/lms/djangoapps/courseware/features/common.py b/lms/djangoapps/courseware/features/common.py index 8477347580..2d366d462d 100644 --- a/lms/djangoapps/courseware/features/common.py +++ b/lms/djangoapps/courseware/features/common.py @@ -6,6 +6,9 @@ from student.models import CourseEnrollment from xmodule.modulestore import Location from xmodule.modulestore.django import _MODULESTORES, modulestore from xmodule.templates import update_templates +from xmodule.course_module import CourseDescriptor +from courseware.courses import get_course_by_id +from xmodule import seq_module, vertical_module from logging import getLogger logger = getLogger(__name__) @@ -94,3 +97,87 @@ def section_location(course_num): course=course_num, category='sequential', name=TEST_SECTION_NAME.replace(" ", "_")) + + +def get_courses(): + ''' + Returns dict of lists of courses available, keyed by course.org (ie university). + Courses are sorted by course.number. + ''' + courses = [c for c in modulestore().get_courses() + if isinstance(c, CourseDescriptor)] + courses = sorted(courses, key=lambda course: course.number) + return courses + + +def get_courseware_with_tabs(course_id): + """ + Given a course_id (string), return a courseware array of dictionaries for the + top three levels of navigation. Same as get_courseware() except include + the tabs on the right hand main navigation page. + + This hides the appropriate courseware as defined by the hide_from_toc field: + chapter.lms.hide_from_toc + + Example: + + [{ + 'chapter_name': 'Overview', + 'sections': [{ + 'clickable_tab_count': 0, + 'section_name': 'Welcome', + 'tab_classes': [] + }, { + 'clickable_tab_count': 1, + 'section_name': 'System Usage Sequence', + 'tab_classes': ['VerticalDescriptor'] + }, { + 'clickable_tab_count': 0, + 'section_name': 'Lab0: Using the tools', + 'tab_classes': ['HtmlDescriptor', 'HtmlDescriptor', 'CapaDescriptor'] + }, { + 'clickable_tab_count': 0, + 'section_name': 'Circuit Sandbox', + 'tab_classes': [] + }] + }, { + 'chapter_name': 'Week 1', + 'sections': [{ + 'clickable_tab_count': 4, + 'section_name': 'Administrivia and Circuit Elements', + 'tab_classes': ['VerticalDescriptor', 'VerticalDescriptor', 'VerticalDescriptor', 'VerticalDescriptor'] + }, { + 'clickable_tab_count': 0, + 'section_name': 'Basic Circuit Analysis', + 'tab_classes': ['CapaDescriptor', 'CapaDescriptor', 'CapaDescriptor'] + }, { + 'clickable_tab_count': 0, + 'section_name': 'Resistor Divider', + 'tab_classes': [] + }, { + 'clickable_tab_count': 0, + 'section_name': 'Week 1 Tutorials', + 'tab_classes': [] + }] + }, { + 'chapter_name': 'Midterm Exam', + 'sections': [{ + 'clickable_tab_count': 2, + 'section_name': 'Midterm Exam', + 'tab_classes': ['VerticalDescriptor', 'VerticalDescriptor'] + }] + }] + """ + + course = get_course_by_id(course_id) + chapters = [chapter for chapter in course.get_children() if not chapter.lms.hide_from_toc] + courseware = [{'chapter_name': c.display_name_with_default, + 'sections': [{'section_name': s.display_name_with_default, + 'clickable_tab_count': len(s.get_children()) if (type(s) == seq_module.SequenceDescriptor) else 0, + 'tabs': [{'children_count': len(t.get_children()) if (type(t) == vertical_module.VerticalDescriptor) else 0, + 'class': t.__class__.__name__} + for t in s.get_children()]} + for s in c.get_children() if not s.lms.hide_from_toc]} + for c in chapters] + + return courseware diff --git a/lms/djangoapps/courseware/features/courses.py b/lms/djangoapps/courseware/features/courses.py deleted file mode 100644 index c99fb58b85..0000000000 --- a/lms/djangoapps/courseware/features/courses.py +++ /dev/null @@ -1,234 +0,0 @@ -from lettuce import world -from xmodule.course_module import CourseDescriptor -from xmodule.modulestore.django import modulestore -from courseware.courses import get_course_by_id -from xmodule import seq_module, vertical_module - -from logging import getLogger -logger = getLogger(__name__) - -## support functions - - -def get_courses(): - ''' - Returns dict of lists of courses available, keyed by course.org (ie university). - Courses are sorted by course.number. - ''' - courses = [c for c in modulestore().get_courses() - if isinstance(c, CourseDescriptor)] - courses = sorted(courses, key=lambda course: course.number) - return courses - - -def get_courseware_with_tabs(course_id): - """ - Given a course_id (string), return a courseware array of dictionaries for the - top three levels of navigation. Same as get_courseware() except include - the tabs on the right hand main navigation page. - - This hides the appropriate courseware as defined by the hide_from_toc field: - chapter.lms.hide_from_toc - - Example: - - [{ - 'chapter_name': 'Overview', - 'sections': [{ - 'clickable_tab_count': 0, - 'section_name': 'Welcome', - 'tab_classes': [] - }, { - 'clickable_tab_count': 1, - 'section_name': 'System Usage Sequence', - 'tab_classes': ['VerticalDescriptor'] - }, { - 'clickable_tab_count': 0, - 'section_name': 'Lab0: Using the tools', - 'tab_classes': ['HtmlDescriptor', 'HtmlDescriptor', 'CapaDescriptor'] - }, { - 'clickable_tab_count': 0, - 'section_name': 'Circuit Sandbox', - 'tab_classes': [] - }] - }, { - 'chapter_name': 'Week 1', - 'sections': [{ - 'clickable_tab_count': 4, - 'section_name': 'Administrivia and Circuit Elements', - 'tab_classes': ['VerticalDescriptor', 'VerticalDescriptor', 'VerticalDescriptor', 'VerticalDescriptor'] - }, { - 'clickable_tab_count': 0, - 'section_name': 'Basic Circuit Analysis', - 'tab_classes': ['CapaDescriptor', 'CapaDescriptor', 'CapaDescriptor'] - }, { - 'clickable_tab_count': 0, - 'section_name': 'Resistor Divider', - 'tab_classes': [] - }, { - 'clickable_tab_count': 0, - 'section_name': 'Week 1 Tutorials', - 'tab_classes': [] - }] - }, { - 'chapter_name': 'Midterm Exam', - 'sections': [{ - 'clickable_tab_count': 2, - 'section_name': 'Midterm Exam', - 'tab_classes': ['VerticalDescriptor', 'VerticalDescriptor'] - }] - }] - """ - - course = get_course_by_id(course_id) - chapters = [chapter for chapter in course.get_children() if not chapter.lms.hide_from_toc] - courseware = [{'chapter_name': c.display_name_with_default, - 'sections': [{'section_name': s.display_name_with_default, - 'clickable_tab_count': len(s.get_children()) if (type(s) == seq_module.SequenceDescriptor) else 0, - 'tabs': [{'children_count': len(t.get_children()) if (type(t) == vertical_module.VerticalDescriptor) else 0, - 'class': t.__class__.__name__} - for t in s.get_children()]} - for s in c.get_children() if not s.lms.hide_from_toc]} - for c in chapters] - - return courseware - - -def process_section(element, num_tabs=0): - ''' - Process section reads through whatever is in 'course-content' and classifies it according to sequence module type. - - This function is recursive - - There are 6 types, with 6 actions. - - Sequence Module - -contains one child module - - Vertical Module - -contains other modules - -process it and get its children, then process them - - Capa Module - -problem type, contains only one problem - -for this, the most complex type, we created a separate method, process_problem - - Video Module - -video type, contains only one video - -we only check to ensure that a section with class of video exists - - HTML Module - -html text - -we do not check anything about it - - Custom Tag Module - -a custom 'hack' module type - -there is a large variety of content that could go in a custom tag module, so we just pass if it is of this unusual type - - can be used like this: - e = world.browser.find_by_css('section.course-content section') - process_section(e) - - ''' - if element.has_class('xmodule_display xmodule_SequenceModule'): - logger.debug('####### Processing xmodule_SequenceModule') - child_modules = element.find_by_css("div>div>section[class^='xmodule']") - for mod in child_modules: - process_section(mod) - - elif element.has_class('xmodule_display xmodule_VerticalModule'): - logger.debug('####### Processing xmodule_VerticalModule') - vert_list = element.find_by_css("li section[class^='xmodule']") - for item in vert_list: - process_section(item) - - elif element.has_class('xmodule_display xmodule_CapaModule'): - logger.debug('####### Processing xmodule_CapaModule') - assert element.find_by_css("section[id^='problem']"), "No problems found in Capa Module" - p = element.find_by_css("section[id^='problem']").first - p_id = p['id'] - logger.debug('####################') - logger.debug('id is "%s"' % p_id) - logger.debug('####################') - process_problem(p, p_id) - - elif element.has_class('xmodule_display xmodule_VideoModule'): - logger.debug('####### Processing xmodule_VideoModule') - assert element.find_by_css("section[class^='video']"), "No video found in Video Module" - - elif element.has_class('xmodule_display xmodule_HtmlModule'): - logger.debug('####### Processing xmodule_HtmlModule') - pass - - elif element.has_class('xmodule_display xmodule_CustomTagModule'): - logger.debug('####### Processing xmodule_CustomTagModule') - pass - - else: - assert False, "Class for element not recognized!!" - - -def process_problem(element, problem_id): - ''' - Process problem attempts to - 1) scan all the input fields and reset them - 2) click the 'check' button and look for an incorrect response (p.status text should be 'incorrect') - 3) click the 'show answer' button IF it exists and IF the answer is not already displayed - 4) enter the correct answer in each input box - 5) click the 'check' button and verify that answers are correct - - Because of all the ajax calls happening, sometimes the test fails because objects disconnect from the DOM. - The basic functionality does exist, though, and I'm hoping that someone can take it over and make it super effective. - ''' - - prob_xmod = element.find_by_css("section.problem").first - input_fields = prob_xmod.find_by_css("section[id^='input']") - - ## clear out all input to ensure an incorrect result - for field in input_fields: - field.find_by_css("input").first.fill('') - - ## because of cookies or the application, only click the 'check' button if the status is not already 'incorrect' - # This would need to be reworked because multiple choice problems don't have this status - # if prob_xmod.find_by_css("p.status").first.text.strip().lower() != 'incorrect': - prob_xmod.find_by_css("section.action input.check").first.click() - - ## all elements become disconnected after the click - ## grab element and prob_xmod because the dom has changed (some classes/elements became hidden and changed the hierarchy) - # Wait for the ajax reload - assert world.browser.is_element_present_by_css("section[id='%s']" % problem_id, wait_time=5) - element = world.browser.find_by_css("section[id='%s']" % problem_id).first - prob_xmod = element.find_by_css("section.problem").first - input_fields = prob_xmod.find_by_css("section[id^='input']") - for field in input_fields: - assert field.find_by_css("div.incorrect"), "The 'check' button did not work for %s" % (problem_id) - - show_button = element.find_by_css("section.action input.show").first - ## this logic is to ensure we do not accidentally hide the answers - if show_button.value.lower() == 'show answer': - show_button.click() - else: - pass - - ## grab element and prob_xmod because the dom has changed (some classes/elements became hidden and changed the hierarchy) - assert world.browser.is_element_present_by_css("section[id='%s']" % problem_id, wait_time=5) - element = world.browser.find_by_css("section[id='%s']" % problem_id).first - prob_xmod = element.find_by_css("section.problem").first - input_fields = prob_xmod.find_by_css("section[id^='input']") - - ## in each field, find the answer, and send it to the field. - ## Note that this does not work if the answer type is a strange format, e.g. "either a or b" - for field in input_fields: - field.find_by_css("input").first.fill(field.find_by_css("p[id^='answer']").first.text) - - prob_xmod.find_by_css("section.action input.check").first.click() - - ## assert that we entered the correct answers - ## grab element and prob_xmod because the dom has changed (some classes/elements became hidden and changed the hierarchy) - assert world.browser.is_element_present_by_css("section[id='%s']" % problem_id, wait_time=5) - element = world.browser.find_by_css("section[id='%s']" % problem_id).first - prob_xmod = element.find_by_css("section.problem").first - input_fields = prob_xmod.find_by_css("section[id^='input']") - for field in input_fields: - ## if you don't use 'starts with ^=' the test will fail because the actual class is 'correct ' (with a space) - assert field.find_by_css("div[class^='correct']"), "The check answer values were not correct for %s" % problem_id diff --git a/lms/djangoapps/courseware/features/smart-accordion.py b/lms/djangoapps/courseware/features/smart-accordion.py index a7eb782722..539bce96ce 100644 --- a/lms/djangoapps/courseware/features/smart-accordion.py +++ b/lms/djangoapps/courseware/features/smart-accordion.py @@ -2,7 +2,7 @@ from lettuce import world, step from re import sub from nose.tools import assert_equals from xmodule.modulestore.django import modulestore -from courses import * +from common import * from logging import getLogger logger = getLogger(__name__) From 6dd86f7a97826ec7af6fcb608928d6f0a7c07660 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Fri, 22 Mar 2013 12:19:46 -0400 Subject: [PATCH 020/128] Refactored courseware_common and open_ended to use ui helpers --- common/djangoapps/terrain/ui_helpers.py | 16 +++++++++ .../courseware/features/courseware_common.py | 15 +++----- .../courseware/features/openended.py | 36 +++++++------------ 3 files changed, 34 insertions(+), 33 deletions(-) diff --git a/common/djangoapps/terrain/ui_helpers.py b/common/djangoapps/terrain/ui_helpers.py index 2ad7150740..d56ce3649b 100644 --- a/common/djangoapps/terrain/ui_helpers.py +++ b/common/djangoapps/terrain/ui_helpers.py @@ -20,6 +20,22 @@ def css_click(css_selector): time.sleep(1) world.browser.find_by_css(css_selector).click() +@world.absorb +def css_fill(css_selector, text): + world.browser.find_by_css(css_selector).first.fill(text) + +@world.absorb +def click_link(partial_text): + world.browser.find_link_by_partial_text(partial_text).first.click() + +@world.absorb +def css_text(css_selector): + return world.browser.find_by_css(css_selector).first.text + +@world.absorb +def css_visible(css_selector): + return world.browser.find_by_css(css_selector).visible + @world.absorb def save_the_html(path='/tmp'): u = world.browser.url diff --git a/lms/djangoapps/courseware/features/courseware_common.py b/lms/djangoapps/courseware/features/courseware_common.py index 96304e016f..567254c334 100644 --- a/lms/djangoapps/courseware/features/courseware_common.py +++ b/lms/djangoapps/courseware/features/courseware_common.py @@ -9,11 +9,10 @@ def i_click_on_view_courseware(step): @step('I click on the "([^"]*)" tab$') -def i_click_on_the_tab(step, tab): - world.browser.find_link_by_partial_text(tab).first.click() +def i_click_on_the_tab(step, tab_text): + world.click_link(tab_text) world.save_the_html() - @step('I visit the courseware URL$') def i_visit_the_course_info_url(step): url = django_url('/courses/MITx/6.002x/2012_Fall/courseware') @@ -32,13 +31,9 @@ def i_am_on_the_dashboard_page(step): @step('the "([^"]*)" tab is active$') -def the_tab_is_active(step, tab): - css = '.course-tabs a.active' - active_tab = world.browser.find_by_css(css) - assert (active_tab.text == tab) - +def the_tab_is_active(step, tab_text): + assert world.css_text('.course-tabs a.active') == tab_text @step('the login dialog is visible$') def login_dialog_visible(step): - css = 'form#login_form.login_form' - assert world.browser.find_by_css(css).visible + assert world.css_visible('form#login_form.login_form') diff --git a/lms/djangoapps/courseware/features/openended.py b/lms/djangoapps/courseware/features/openended.py index 0725a051ff..7601bfcc53 100644 --- a/lms/djangoapps/courseware/features/openended.py +++ b/lms/djangoapps/courseware/features/openended.py @@ -12,7 +12,7 @@ def navigate_to_an_openended_question(step): problem = '/courses/MITx/3.091x/2012_Fall/courseware/Week_10/Polymer_Synthesis/' world.browser.visit(django_url(problem)) tab_css = 'ol#sequence-list > li > a[data-element="5"]' - world.browser.find_by_css(tab_css).click() + world.css_click(tab_css) @step('I navigate to an openended question as staff$') @@ -22,50 +22,41 @@ def navigate_to_an_openended_question_as_staff(step): problem = '/courses/MITx/3.091x/2012_Fall/courseware/Week_10/Polymer_Synthesis/' world.browser.visit(django_url(problem)) tab_css = 'ol#sequence-list > li > a[data-element="5"]' - world.browser.find_by_css(tab_css).click() + world.css_click(tab_css) @step(u'I enter the answer "([^"]*)"$') def enter_the_answer_text(step, text): - textarea_css = 'textarea' - world.browser.find_by_css(textarea_css).first.fill(text) + world.css_fill('textarea', text) @step(u'I submit the answer "([^"]*)"$') def i_submit_the_answer_text(step, text): - textarea_css = 'textarea' - world.browser.find_by_css(textarea_css).first.fill(text) - check_css = 'input.check' - world.browser.find_by_css(check_css).click() + world.css_fill('textarea', text) + world.css_click('input.check') @step('I click the link for full output$') def click_full_output_link(step): - link_css = 'a.full' - world.browser.find_by_css(link_css).first.click() + world.css_click('a.full') @step(u'I visit the staff grading page$') def i_visit_the_staff_grading_page(step): - # course_u = '/courses/MITx/3.091x/2012_Fall' - # sg_url = '%s/staff_grading' % course_u - world.browser.click_link_by_text('Instructor') - world.browser.click_link_by_text('Staff grading') - # world.browser.visit(django_url(sg_url)) + world.click_link('Instructor') + world.click_link('Staff grading') @step(u'I see the grader message "([^"]*)"$') def see_grader_message(step, msg): message_css = 'div.external-grader-message' - grader_msg = world.browser.find_by_css(message_css).text - assert_in(msg, grader_msg) + assert_in(msg, world.css_text(message_css)) @step(u'I see the grader status "([^"]*)"$') def see_the_grader_status(step, status): status_css = 'div.grader-status' - grader_status = world.browser.find_by_css(status_css).text - assert_equals(status, grader_status) + assert_equals(status, world.css_text(status_css)) @step('I see the red X$') @@ -77,7 +68,7 @@ def see_the_red_x(step): @step(u'I see the grader score "([^"]*)"$') def see_the_grader_score(step, score): score_css = 'div.result-output > p' - score_text = world.browser.find_by_css(score_css).text + score_text = world.css_text(score_css) assert_equals(score_text, 'Score: %s' % score) @@ -89,14 +80,13 @@ def see_full_output_link(step): @step('I see the spelling grading message "([^"]*)"$') def see_spelling_msg(step, msg): - spelling_css = 'div.spelling' - spelling_msg = world.browser.find_by_css(spelling_css).text + spelling_msg = world.css_text('div.spelling') assert_equals('Spelling: %s' % msg, spelling_msg) @step(u'my answer is queued for instructor grading$') def answer_is_queued_for_instructor_grading(step): list_css = 'ul.problem-list > li > a' - actual_msg = world.browser.find_by_css(list_css).text + actual_msg = world.css_text(list_css) expected_msg = "(0 graded, 1 pending)" assert_in(expected_msg, actual_msg) From 4528490fac9050881eba0ff98df07782e71bbabc Mon Sep 17 00:00:00 2001 From: Will Daly Date: Fri, 22 Mar 2013 12:40:33 -0400 Subject: [PATCH 021/128] Refactored lms/coureware lettuce tests to use terrain helpers for common ui manipulations --- common/djangoapps/terrain/course_helpers.py | 1 + common/djangoapps/terrain/steps.py | 6 ++++- common/djangoapps/terrain/ui_helpers.py | 23 ++++++++++++++++++- .../courseware/features/courseware_common.py | 13 +++++------ lms/djangoapps/courseware/features/login.py | 4 +--- .../courseware/features/openended.py | 6 ++--- .../courseware/features/problems.py | 4 ++-- .../courseware/features/registration.py | 8 +++---- lms/djangoapps/courseware/features/signup.py | 2 +- .../courseware/features/smart-accordion.py | 10 ++++---- .../courseware/features/xqueue_setup.py | 1 + 11 files changed, 50 insertions(+), 28 deletions(-) diff --git a/common/djangoapps/terrain/course_helpers.py b/common/djangoapps/terrain/course_helpers.py index 8c949de1ad..ebf5745f11 100644 --- a/common/djangoapps/terrain/course_helpers.py +++ b/common/djangoapps/terrain/course_helpers.py @@ -12,6 +12,7 @@ import os.path from urllib import quote_plus from lettuce.django import django_url + @world.absorb def create_user(uname): diff --git a/common/djangoapps/terrain/steps.py b/common/djangoapps/terrain/steps.py index 8dac372a64..e99dec44b3 100644 --- a/common/djangoapps/terrain/steps.py +++ b/common/djangoapps/terrain/steps.py @@ -13,6 +13,7 @@ logger = getLogger(__name__) def wait(step, seconds): world.wait(seconds) + @step('I reload the page$') def reload_the_page(step): world.browser.reload() @@ -72,10 +73,12 @@ def the_page_title_should_be(step, title): def the_page_title_should_contain(step, title): assert(title in world.browser.title) + @step('I log in$') def i_log_in(step): world.log_in('robot', 'test') + @step('I am a logged in user$') def i_am_logged_in_user(step): world.create_user('robot') @@ -101,10 +104,12 @@ def click_the_link_called(step, text): def should_have_the_url(step, url): assert_equals(world.browser.url, url) + @step(r'should see (?:the|a) link (?:called|with the text) "([^"]*)"$') def should_see_a_link_called(step, text): assert len(world.browser.find_link_by_text(text)) > 0 + @step(r'should see "(.*)" (?:somewhere|anywhere) in (?:the|this) page') def should_see_in_the_page(step, text): assert_in(text, world.browser.html) @@ -130,4 +135,3 @@ def i_am_an_edx_user(step): @step(u'User "([^"]*)" is an edX user$') def registered_edx_user(step, uname): world.create_user(uname) - diff --git a/common/djangoapps/terrain/ui_helpers.py b/common/djangoapps/terrain/ui_helpers.py index d56ce3649b..1aac9cc72e 100644 --- a/common/djangoapps/terrain/ui_helpers.py +++ b/common/djangoapps/terrain/ui_helpers.py @@ -2,12 +2,29 @@ from lettuce import world, step import time from urllib import quote_plus from selenium.common.exceptions import WebDriverException +from lettuce.django import django_url + @world.absorb def wait(seconds): time.sleep(float(seconds)) +@world.absorb +def visit(url): + world.browser.visit(django_url(url)) + + +@world.absorb +def url_equals(url): + return world.browser.url == django_url(url) + + +@world.absorb +def is_css_present(css_selector): + return world.browser.is_element_present_by_css(css_selector, wait_time=4) + + @world.absorb def css_click(css_selector): try: @@ -20,22 +37,27 @@ def css_click(css_selector): time.sleep(1) world.browser.find_by_css(css_selector).click() + @world.absorb def css_fill(css_selector, text): world.browser.find_by_css(css_selector).first.fill(text) + @world.absorb def click_link(partial_text): world.browser.find_link_by_partial_text(partial_text).first.click() + @world.absorb def css_text(css_selector): return world.browser.find_by_css(css_selector).first.text + @world.absorb def css_visible(css_selector): return world.browser.find_by_css(css_selector).visible + @world.absorb def save_the_html(path='/tmp'): u = world.browser.url @@ -44,4 +66,3 @@ def save_the_html(path='/tmp'): f = open('%s/%s' % (path, filename), 'w') f.write(html) f.close - diff --git a/lms/djangoapps/courseware/features/courseware_common.py b/lms/djangoapps/courseware/features/courseware_common.py index 567254c334..6aa9559e65 100644 --- a/lms/djangoapps/courseware/features/courseware_common.py +++ b/lms/djangoapps/courseware/features/courseware_common.py @@ -1,11 +1,9 @@ from lettuce import world, step -from lettuce.django import django_url @step('I click on View Courseware') def i_click_on_view_courseware(step): - css = 'a.enter-course' - world.browser.find_by_css(css).first.click() + world.css_click('a.enter-course') @step('I click on the "([^"]*)" tab$') @@ -13,10 +11,10 @@ def i_click_on_the_tab(step, tab_text): world.click_link(tab_text) world.save_the_html() + @step('I visit the courseware URL$') def i_visit_the_course_info_url(step): - url = django_url('/courses/MITx/6.002x/2012_Fall/courseware') - world.browser.visit(url) + world.visit('/courses/MITx/6.002x/2012_Fall/courseware') @step(u'I do not see "([^"]*)" anywhere on the page') @@ -26,14 +24,15 @@ def i_do_not_see_text_anywhere_on_the_page(step, text): @step(u'I am on the dashboard page$') def i_am_on_the_dashboard_page(step): - assert world.browser.is_element_present_by_css('section.courses') - assert world.browser.url == django_url('/dashboard') + assert world.is_css_present('section.courses') + assert world.url_equals('/dashboard') @step('the "([^"]*)" tab is active$') def the_tab_is_active(step, tab_text): assert world.css_text('.course-tabs a.active') == tab_text + @step('the login dialog is visible$') def login_dialog_visible(step): assert world.css_visible('form#login_form.login_form') diff --git a/lms/djangoapps/courseware/features/login.py b/lms/djangoapps/courseware/features/login.py index 094db078ca..3e3c0efbc4 100644 --- a/lms/djangoapps/courseware/features/login.py +++ b/lms/djangoapps/courseware/features/login.py @@ -28,9 +28,7 @@ def i_should_see_the_login_error_message(step, msg): @step(u'click the dropdown arrow$') def click_the_dropdown(step): - css = ".dropdown" - e = world.browser.find_by_css(css) - e.click() + world.css_click('.dropdown') #### helper functions diff --git a/lms/djangoapps/courseware/features/openended.py b/lms/djangoapps/courseware/features/openended.py index 7601bfcc53..2f14b808a3 100644 --- a/lms/djangoapps/courseware/features/openended.py +++ b/lms/djangoapps/courseware/features/openended.py @@ -61,8 +61,7 @@ def see_the_grader_status(step, status): @step('I see the red X$') def see_the_red_x(step): - x_css = 'div.grader-status > span.incorrect' - assert world.browser.find_by_css(x_css) + assert world.is_css_present('div.grader-status > span.incorrect') @step(u'I see the grader score "([^"]*)"$') @@ -74,8 +73,7 @@ def see_the_grader_score(step, score): @step('I see the link for full output$') def see_full_output_link(step): - link_css = 'a.full' - assert world.browser.find_by_css(link_css) + assert world.is_css_present('a.full') @step('I see the spelling grading message "([^"]*)"$') diff --git a/lms/djangoapps/courseware/features/problems.py b/lms/djangoapps/courseware/features/problems.py index d2d379a212..bdd9062ef3 100644 --- a/lms/djangoapps/courseware/features/problems.py +++ b/lms/djangoapps/courseware/features/problems.py @@ -339,7 +339,7 @@ def assert_answer_mark(step, problem_type, correctness): # At least one of the correct selectors should be present for sel in selector_dict[problem_type]: - has_expected = world.browser.is_element_present_by_css(sel, wait_time=4) + has_expected = world.is_css_present(sel) # As soon as we find the selector, break out of the loop if has_expected: @@ -366,7 +366,7 @@ def inputfield(problem_type, choice=None, input_num=1): # If the input element doesn't exist, fail immediately - assert(world.browser.is_element_present_by_css(sel, wait_time=4)) + assert world.is_css_present(sel) # Retrieve the input element return world.browser.find_by_css(sel) diff --git a/lms/djangoapps/courseware/features/registration.py b/lms/djangoapps/courseware/features/registration.py index 94b9b50f6c..63f044b16f 100644 --- a/lms/djangoapps/courseware/features/registration.py +++ b/lms/djangoapps/courseware/features/registration.py @@ -13,17 +13,17 @@ def i_register_for_the_course(step, course): register_link = intro_section.find_by_css('a.register') register_link.click() - assert world.browser.is_element_present_by_css('section.container.dashboard') + assert world.is_css_present('section.container.dashboard') @step(u'I should see the course numbered "([^"]*)" in my dashboard$') def i_should_see_that_course_in_my_dashboard(step, course): course_link_css = 'section.my-courses a[href*="%s"]' % course - assert world.browser.is_element_present_by_css(course_link_css) + assert world.is_css_present(course_link_css) @step(u'I press the "([^"]*)" button in the Unenroll dialog') def i_press_the_button_in_the_unenroll_dialog(step, value): button_css = 'section#unenroll-modal input[value="%s"]' % value - world.browser.find_by_css(button_css).click() - assert world.browser.is_element_present_by_css('section.container.dashboard') + world.css_click(button_css) + assert world.is_css_present('section.container.dashboard') diff --git a/lms/djangoapps/courseware/features/signup.py b/lms/djangoapps/courseware/features/signup.py index 3a697a6102..d9edcb215b 100644 --- a/lms/djangoapps/courseware/features/signup.py +++ b/lms/djangoapps/courseware/features/signup.py @@ -22,4 +22,4 @@ def i_check_checkbox(step, checkbox): @step('I should see "([^"]*)" in the dashboard banner$') def i_should_see_text_in_the_dashboard_banner_section(step, text): css_selector = "section.dashboard-banner h2" - assert (text in world.browser.find_by_css(css_selector).text) + assert (text in world.css_text(css_selector)) diff --git a/lms/djangoapps/courseware/features/smart-accordion.py b/lms/djangoapps/courseware/features/smart-accordion.py index 539bce96ce..8240a13905 100644 --- a/lms/djangoapps/courseware/features/smart-accordion.py +++ b/lms/djangoapps/courseware/features/smart-accordion.py @@ -32,20 +32,20 @@ def i_verify_all_the_content_of_each_course(step): pass for test_course in registered_courses: - test_course.find_by_css('a').click() + test_course.css_click('a') check_for_errors() # Get the course. E.g. 'MITx/6.002x/2012_Fall' current_course = sub('/info', '', sub('.*/courses/', '', world.browser.url)) validate_course(current_course, ids) - world.browser.find_link_by_text('Courseware').click() - assert world.browser.is_element_present_by_id('accordion', wait_time=2) + world.click_link('Courseware') + assert world.is_css_present('accordion') check_for_errors() browse_course(current_course) # clicking the user link gets you back to the user's home page - world.browser.find_by_css('.user-link').click() + world.css_click('.user-link') check_for_errors() @@ -94,7 +94,7 @@ def browse_course(course_id): world.browser.find_by_css('#accordion > nav > div')[chapter_it].find_by_tag('li')[section_it].find_by_tag('a').click() ## sometimes the course-content takes a long time to load - assert world.browser.is_element_present_by_css('.course-content', wait_time=5) + assert world.is_css_present('.course-content') ## look for server error div check_for_errors() diff --git a/lms/djangoapps/courseware/features/xqueue_setup.py b/lms/djangoapps/courseware/features/xqueue_setup.py index 23706941a9..d6d7a13a5c 100644 --- a/lms/djangoapps/courseware/features/xqueue_setup.py +++ b/lms/djangoapps/courseware/features/xqueue_setup.py @@ -3,6 +3,7 @@ from lettuce import before, after, world from django.conf import settings import threading + @before.all def setup_mock_xqueue_server(): From dde0d1676b8176119d5f33bf234221836c781aac Mon Sep 17 00:00:00 2001 From: Will Daly Date: Fri, 22 Mar 2013 13:02:40 -0400 Subject: [PATCH 022/128] Refactored terrain/steps.py to use ui_helpers Added a wait time before checking the page HTML, and changed it to check just in the HTML body --- common/djangoapps/terrain/steps.py | 27 +++++++++++-------------- common/djangoapps/terrain/ui_helpers.py | 7 ++++++- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/common/djangoapps/terrain/steps.py b/common/djangoapps/terrain/steps.py index e99dec44b3..dc8d2f8b87 100644 --- a/common/djangoapps/terrain/steps.py +++ b/common/djangoapps/terrain/steps.py @@ -26,42 +26,40 @@ def browser_back(step): @step('I (?:visit|access|open) the homepage$') def i_visit_the_homepage(step): - world.browser.visit(django_url('/')) - assert world.browser.is_element_present_by_css('header.global', 10) - + world.visit('/') + assert world.is_css_present('header.global') @step(u'I (?:visit|access|open) the dashboard$') def i_visit_the_dashboard(step): - world.browser.visit(django_url('/dashboard')) - assert world.browser.is_element_present_by_css('section.container.dashboard', 5) - + world.visit('/dashboard') + assert world.is_css_present('section.container.dashboard') @step('I should be on the dashboard page$') def i_should_be_on_the_dashboard(step): - assert world.browser.is_element_present_by_css('section.container.dashboard', 5) + assert world.is_css_present('section.container.dashboard') assert world.browser.title == 'Dashboard' @step(u'I (?:visit|access|open) the courses page$') def i_am_on_the_courses_page(step): - world.browser.visit(django_url('/courses')) - assert world.browser.is_element_present_by_css('section.courses') + world.visit('/courses') + assert world.is_css_present('section.courses') @step(u'I press the "([^"]*)" button$') def and_i_press_the_button(step, value): button_css = 'input[value="%s"]' % value - world.browser.find_by_css(button_css).first.click() + world.css_click(button_css) @step(u'I click the link with the text "([^"]*)"$') def click_the_link_with_the_text_group1(step, linktext): - world.browser.find_link_by_text(linktext).first.click() + world.click_link(linktext) @step('I should see that the path is "([^"]*)"$') def i_should_see_that_the_path_is(step, path): - assert world.browser.url == django_url(path) + assert world.url_equals(path) @step(u'the page title should be "([^"]*)"$') @@ -97,8 +95,7 @@ def i_am_staff_for_course_by_id(step, course_id): @step(r'click (?:the|a) link (?:called|with the text) "([^"]*)"$') def click_the_link_called(step, text): - world.browser.find_link_by_text(text).click() - + world.click_link(text) @step(r'should see that the url is "([^"]*)"$') def should_have_the_url(step, url): @@ -112,7 +109,7 @@ def should_see_a_link_called(step, text): @step(r'should see "(.*)" (?:somewhere|anywhere) in (?:the|this) page') def should_see_in_the_page(step, text): - assert_in(text, world.browser.html) + assert_in(text, world.css_text('body')) @step('I am logged in$') diff --git a/common/djangoapps/terrain/ui_helpers.py b/common/djangoapps/terrain/ui_helpers.py index 1aac9cc72e..3009d1fa8d 100644 --- a/common/djangoapps/terrain/ui_helpers.py +++ b/common/djangoapps/terrain/ui_helpers.py @@ -50,7 +50,12 @@ def click_link(partial_text): @world.absorb def css_text(css_selector): - return world.browser.find_by_css(css_selector).first.text + + # Wait for the css selector to appear + if world.is_css_present(css_selector): + return world.browser.find_by_css(css_selector).first.text + else: + return "" @world.absorb From e69931ec5a06ecec9bc57b2875181e94b9b2f059 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Fri, 22 Mar 2013 13:45:25 -0400 Subject: [PATCH 023/128] Refactored studio lettuce tests to use terrain/ui_helpers for ui manipulation --- .../features/advanced-settings.py | 40 ++----- .../contentstore/features/common.py | 102 +++++------------- .../contentstore/features/courses.py | 15 ++- .../contentstore/features/section.py | 26 ++--- .../contentstore/features/signup.py | 2 +- .../features/studio-overview-togglesection.py | 24 ++--- .../contentstore/features/subsection.py | 15 ++- common/djangoapps/terrain/ui_helpers.py | 34 ++++++ 8 files changed, 109 insertions(+), 149 deletions(-) diff --git a/cms/djangoapps/contentstore/features/advanced-settings.py b/cms/djangoapps/contentstore/features/advanced-settings.py index 7e86e94a31..0232c3b908 100644 --- a/cms/djangoapps/contentstore/features/advanced-settings.py +++ b/cms/djangoapps/contentstore/features/advanced-settings.py @@ -2,8 +2,6 @@ from lettuce import world, step from common import * import time from terrain.steps import reload_the_page -from selenium.common.exceptions import WebDriverException -from selenium.webdriver.support import expected_conditions as EC from nose.tools import assert_true, assert_false, assert_equal @@ -22,9 +20,9 @@ DISPLAY_NAME_VALUE = '"Robot Super Course"' def i_select_advanced_settings(step): expand_icon_css = 'li.nav-course-settings i.icon-expand' if world.browser.is_element_present_by_css(expand_icon_css): - css_click(expand_icon_css) + world.css_click(expand_icon_css) link_css = 'li.nav-course-settings-advanced a' - css_click(link_css) + world.css_click(link_css) @step('I am on the Advanced Course Settings page in Studio$') @@ -35,24 +33,8 @@ def i_am_on_advanced_course_settings(step): @step(u'I press the "([^"]*)" notification button$') def press_the_notification_button(step, name): - def is_visible(driver): - return EC.visibility_of_element_located((By.CSS_SELECTOR, css,)) - - # def is_invisible(driver): - # return EC.invisibility_of_element_located((By.CSS_SELECTOR,css,)) - css = 'a.%s-button' % name.lower() - wait_for(is_visible) - time.sleep(float(1)) - css_click_at(css) - -# is_invisible is not returning a boolean, not working -# try: -# css_click_at(css) -# wait_for(is_invisible) -# except WebDriverException, e: -# css_click_at(css) -# wait_for(is_invisible) + world.css_click_at(css) @step(u'I edit the value of a policy key$') @@ -61,7 +43,7 @@ def edit_the_value_of_a_policy_key(step): It is hard to figure out how to get into the CodeMirror area, so cheat and do it from the policy key field :) """ - e = css_find(KEY_CSS)[get_index_of(DISPLAY_NAME_KEY)] + e = world.css_find(KEY_CSS)[get_index_of(DISPLAY_NAME_KEY)] e._element.send_keys(Keys.TAB, Keys.END, Keys.ARROW_LEFT, ' ', 'X') @@ -85,7 +67,7 @@ def i_see_default_advanced_settings(step): @step('the settings are alphabetized$') def they_are_alphabetized(step): - key_elements = css_find(KEY_CSS) + key_elements = world.css_find(KEY_CSS) all_keys = [] for key in key_elements: all_keys.append(key.value) @@ -118,13 +100,13 @@ def assert_policy_entries(expected_keys, expected_values): for counter in range(len(expected_keys)): index = get_index_of(expected_keys[counter]) assert_false(index == -1, "Could not find key: " + expected_keys[counter]) - assert_equal(expected_values[counter], css_find(VALUE_CSS)[index].value, "value is incorrect") + assert_equal(expected_values[counter], world.css_find(VALUE_CSS)[index].value, "value is incorrect") def get_index_of(expected_key): - for counter in range(len(css_find(KEY_CSS))): + for counter in range(len(world.css_find(KEY_CSS))): # Sometimes get stale reference if I hold on to the array of elements - key = css_find(KEY_CSS)[counter].value + key = world.css_find(KEY_CSS)[counter].value if key == expected_key: return counter @@ -133,14 +115,14 @@ def get_index_of(expected_key): def get_display_name_value(): index = get_index_of(DISPLAY_NAME_KEY) - return css_find(VALUE_CSS)[index].value + return world.css_find(VALUE_CSS)[index].value def change_display_name_value(step, new_value): - e = css_find(KEY_CSS)[get_index_of(DISPLAY_NAME_KEY)] + e = world.css_find(KEY_CSS)[get_index_of(DISPLAY_NAME_KEY)] display_name = get_display_name_value() for count in range(len(display_name)): e._element.send_keys(Keys.TAB, Keys.END, Keys.BACK_SPACE) # Must delete "" before typing the JSON value e._element.send_keys(Keys.TAB, Keys.END, Keys.BACK_SPACE, Keys.BACK_SPACE, new_value) - press_the_notification_button(step, "Save") \ No newline at end of file + press_the_notification_button(step, "Save") diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py index 820b60123b..4cc5759949 100644 --- a/cms/djangoapps/contentstore/features/common.py +++ b/cms/djangoapps/contentstore/features/common.py @@ -1,11 +1,6 @@ from lettuce import world, step -from lettuce.django import django_url from nose.tools import assert_true from nose.tools import assert_equal -from selenium.webdriver.support.ui import WebDriverWait -from selenium.common.exceptions import WebDriverException, StaleElementReferenceException -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.common.by import By from xmodule.modulestore.django import _MODULESTORES, modulestore from xmodule.templates import update_templates @@ -20,9 +15,9 @@ def i_visit_the_studio_homepage(step): # To make this go to port 8001, put # LETTUCE_SERVER_PORT = 8001 # in your settings.py file. - world.browser.visit(django_url('/')) + world.visit('/') signin_css = 'a.action-signin' - assert world.browser.is_element_present_by_css(signin_css, 10) + assert world.is_css_present(signin_css) @step('I am logged into Studio$') @@ -43,7 +38,7 @@ def i_press_the_category_delete_icon(step, category): css = 'a.delete-button.delete-subsection-button span.delete-icon' else: assert False, 'Invalid category: %s' % category - css_click(css) + world.css_click(css) @step('I have opened a new course in Studio$') @@ -87,56 +82,6 @@ def flush_xmodule_store(): update_templates() -def assert_css_with_text(css, text): - assert_true(world.browser.is_element_present_by_css(css, 5)) - assert_equal(world.browser.find_by_css(css).text, text) - - -def css_click(css): - ''' - First try to use the regular click method, - but if clicking in the middle of an element - doesn't work it might be that it thinks some other - element is on top of it there so click in the upper left - ''' - try: - css_find(css).first.click() - except WebDriverException, e: - css_click_at(css) - - -def css_click_at(css, x=10, y=10): - ''' - A method to click at x,y coordinates of the element - rather than in the center of the element - ''' - e = css_find(css).first - e.action_chains.move_to_element_with_offset(e._element, x, y) - e.action_chains.click() - e.action_chains.perform() - - -def css_fill(css, value): - world.browser.find_by_css(css).first.fill(value) - - -def css_find(css): - def is_visible(driver): - return EC.visibility_of_element_located((By.CSS_SELECTOR,css,)) - - world.browser.is_element_present_by_css(css, 5) - wait_for(is_visible) - return world.browser.find_by_css(css) - - -def wait_for(func): - WebDriverWait(world.browser.driver, 5).until(func) - - -def id_find(id): - return world.browser.find_by_id(id) - - def clear_courses(): flush_xmodule_store() @@ -145,9 +90,9 @@ def fill_in_course_info( name='Robot Super Course', org='MITx', num='101'): - css_fill('.new-course-name', name) - css_fill('.new-course-org', org) - css_fill('.new-course-number', num) + world.css_fill('.new-course-name', name) + world.css_fill('.new-course-org', org) + world.css_fill('.new-course-number', num) def log_into_studio( @@ -155,21 +100,22 @@ def log_into_studio( email='robot+studio@edx.org', password='test', is_staff=False): - create_studio_user(uname=uname, email=email, is_staff=is_staff) - world.browser.cookies.delete() - world.browser.visit(django_url('/')) - signin_css = 'a.action-signin' - world.browser.is_element_present_by_css(signin_css, 10) - # click the signin button - css_click(signin_css) + create_studio_user(uname=uname, email=email, is_staff=is_staff) + + world.browser.cookies.delete() + world.visit('/') + + signin_css = 'a.action-signin' + world.is_css_present(signin_css) + world.css_click(signin_css) login_form = world.browser.find_by_css('form#login_form') login_form.find_by_name('email').fill(email) login_form.find_by_name('password').fill(password) login_form.find_by_name('submit').click() - assert_true(world.browser.is_element_present_by_css('.new-course-button', 5)) + assert_true(world.is_css_present('.new-course-button')) def create_a_course(): @@ -184,26 +130,26 @@ def create_a_course(): world.browser.reload() course_link_css = 'span.class-name' - css_click(course_link_css) + world.css_click(course_link_css) course_title_css = 'span.course-title' - assert_true(world.browser.is_element_present_by_css(course_title_css, 5)) + assert_true(world.is_css_present(course_title_css)) def add_section(name='My Section'): link_css = 'a.new-courseware-section-button' - css_click(link_css) + world.css_click(link_css) name_css = 'input.new-section-name' save_css = 'input.new-section-name-save' - css_fill(name_css, name) - css_click(save_css) + world.css_fill(name_css, name) + world.css_click(save_css) span_css = 'span.section-name-span' - assert_true(world.browser.is_element_present_by_css(span_css, 5)) + assert_true(world.is_css_present(span_css)) def add_subsection(name='Subsection One'): css = 'a.new-subsection-item' - css_click(css) + world.css_click(css) name_css = 'input.new-subsection-name-input' save_css = 'input.new-subsection-name-save' - css_fill(name_css, name) - css_click(save_css) + world.css_fill(name_css, name) + world.css_click(save_css) diff --git a/cms/djangoapps/contentstore/features/courses.py b/cms/djangoapps/contentstore/features/courses.py index e394165f08..8301e6708f 100644 --- a/cms/djangoapps/contentstore/features/courses.py +++ b/cms/djangoapps/contentstore/features/courses.py @@ -11,7 +11,7 @@ def no_courses(step): @step('I click the New Course button$') def i_click_new_course(step): - css_click('.new-course-button') + world.css_click('.new-course-button') @step('I fill in the new course information$') @@ -27,7 +27,7 @@ def i_create_a_course(step): @step('I click the course link in My Courses$') def i_click_the_course_link_in_my_courses(step): course_css = 'span.class-name' - css_click(course_css) + world.css_click(course_css) ############ ASSERTIONS ################### @@ -35,28 +35,27 @@ def i_click_the_course_link_in_my_courses(step): @step('the Courseware page has loaded in Studio$') def courseware_page_has_loaded_in_studio(step): course_title_css = 'span.course-title' - assert world.browser.is_element_present_by_css(course_title_css) + assert world.is_css_present(course_title_css) @step('I see the course listed in My Courses$') def i_see_the_course_in_my_courses(step): course_css = 'span.class-name' - assert_css_with_text(course_css, 'Robot Super Course') - + assert world.css_has_text(course_css, 'Robot Super Course') @step('the course is loaded$') def course_is_loaded(step): class_css = 'a.class-name' - assert_css_with_text(class_css, 'Robot Super Course') + assert world.css_has_text(course_css, 'Robot Super Cousre') @step('I am on the "([^"]*)" tab$') def i_am_on_tab(step, tab_name): header_css = 'div.inner-wrapper h1' - assert_css_with_text(header_css, tab_name) + assert world.css_has_text(header_css, tab_name) @step('I see a link for adding a new section$') def i_see_new_section_link(step): link_css = 'a.new-courseware-section-button' - assert_css_with_text(link_css, '+ New Section') + assert world.css_has_text(link_css, '+ New Section') diff --git a/cms/djangoapps/contentstore/features/section.py b/cms/djangoapps/contentstore/features/section.py index b5ddb48a09..e57d50bbfe 100644 --- a/cms/djangoapps/contentstore/features/section.py +++ b/cms/djangoapps/contentstore/features/section.py @@ -10,7 +10,7 @@ import time @step('I click the new section link$') def i_click_new_section_link(step): link_css = 'a.new-courseware-section-button' - css_click(link_css) + world.css_click(link_css) @step('I enter the section name and click save$') @@ -31,19 +31,19 @@ def i_have_added_new_section(step): @step('I click the Edit link for the release date$') def i_click_the_edit_link_for_the_release_date(step): button_css = 'div.section-published-date a.edit-button' - css_click(button_css) + world.css_click(button_css) @step('I save a new section release date$') def i_save_a_new_section_release_date(step): date_css = 'input.start-date.date.hasDatepicker' time_css = 'input.start-time.time.ui-timepicker-input' - css_fill(date_css, '12/25/2013') + world.css_fill(date_css, '12/25/2013') # hit TAB to get to the time field - e = css_find(date_css).first + e = world.css_find(date_css).first e._element.send_keys(Keys.TAB) - css_fill(time_css, '12:00am') - e = css_find(time_css).first + world.css_fill(time_css, '12:00am') + e = world.css_find(time_css).first e._element.send_keys(Keys.TAB) time.sleep(float(1)) world.browser.click_link_by_text('Save') @@ -64,13 +64,13 @@ def i_see_my_section_name_with_quote_on_the_courseware_page(step): @step('I click to edit the section name$') def i_click_to_edit_section_name(step): - css_click('span.section-name-span') + world.css_click('span.section-name-span') @step('I see the complete section name with a quote in the editor$') def i_see_complete_section_name_with_quote_in_editor(step): css = '.edit-section-name' - assert world.browser.is_element_present_by_css(css, 5) + assert world.is_css_present(css) assert_equal(world.browser.find_by_css(css).value, 'Section with "Quote"') @@ -85,7 +85,7 @@ def i_see_a_release_date_for_my_section(step): import re css = 'span.published-status' - assert world.browser.is_element_present_by_css(css) + assert world.is_css_present(css) status_text = world.browser.find_by_css(css).text # e.g. 11/06/2012 at 16:25 @@ -99,7 +99,7 @@ def i_see_a_release_date_for_my_section(step): @step('I see a link to create a new subsection$') def i_see_a_link_to_create_a_new_subsection(step): css = 'a.new-subsection-item' - assert world.browser.is_element_present_by_css(css) + assert world.is_css_present(css) @step('the section release date picker is not visible$') @@ -120,10 +120,10 @@ def the_section_release_date_is_updated(step): def save_section_name(name): name_css = '.new-section-name' save_css = '.new-section-name-save' - css_fill(name_css, name) - css_click(save_css) + world.css_fill(name_css, name) + world.css_click(save_css) def see_my_section_on_the_courseware_page(name): section_css = 'span.section-name-span' - assert_css_with_text(section_css, name) + assert world.css_has_text(section_css, name) diff --git a/cms/djangoapps/contentstore/features/signup.py b/cms/djangoapps/contentstore/features/signup.py index e8d0dd8229..cd4adb79fb 100644 --- a/cms/djangoapps/contentstore/features/signup.py +++ b/cms/djangoapps/contentstore/features/signup.py @@ -17,7 +17,7 @@ def i_press_the_button_on_the_registration_form(step): submit_css = 'form#register_form button#submit' # Workaround for click not working on ubuntu # for some unknown reason. - e = css_find(submit_css) + e = world.css_find(submit_css) e.type(' ') @step('I should see be on the studio home page$') diff --git a/cms/djangoapps/contentstore/features/studio-overview-togglesection.py b/cms/djangoapps/contentstore/features/studio-overview-togglesection.py index 060d592cfd..85a25a55ac 100644 --- a/cms/djangoapps/contentstore/features/studio-overview-togglesection.py +++ b/cms/djangoapps/contentstore/features/studio-overview-togglesection.py @@ -49,7 +49,7 @@ def have_a_course_with_two_sections(step): def navigate_to_the_course_overview_page(step): log_into_studio(is_staff=True) course_locator = '.class-name' - css_click(course_locator) + world.css_click(course_locator) @step(u'I navigate to the courseware page of a course with multiple sections') @@ -66,44 +66,44 @@ def i_add_a_section(step): @step(u'I click the "([^"]*)" link$') def i_click_the_text_span(step, text): span_locator = '.toggle-button-sections span' - assert_true(world.browser.is_element_present_by_css(span_locator, 5)) + assert_true(world.browser.is_element_present_by_css(span_locator)) # first make sure that the expand/collapse text is the one you expected assert_equal(world.browser.find_by_css(span_locator).value, text) - css_click(span_locator) + world.css_click(span_locator) @step(u'I collapse the first section$') def i_collapse_a_section(step): collapse_locator = 'section.courseware-section a.collapse' - css_click(collapse_locator) + world.css_click(collapse_locator) @step(u'I expand the first section$') def i_expand_a_section(step): expand_locator = 'section.courseware-section a.expand' - css_click(expand_locator) + world.css_click(expand_locator) @step(u'I see the "([^"]*)" link$') def i_see_the_span_with_text(step, text): span_locator = '.toggle-button-sections span' - assert_true(world.browser.is_element_present_by_css(span_locator, 5)) - assert_equal(world.browser.find_by_css(span_locator).value, text) - assert_true(world.browser.find_by_css(span_locator).visible) + assert_true(world.is_css_present(span_locator)) + assert_equal(world.css_find(span_locator).value, text) + assert_true(world.css_visible(span_locator)) @step(u'I do not see the "([^"]*)" link$') def i_do_not_see_the_span_with_text(step, text): # Note that the span will exist on the page but not be visible span_locator = '.toggle-button-sections span' - assert_true(world.browser.is_element_present_by_css(span_locator)) - assert_false(world.browser.find_by_css(span_locator).visible) + assert_true(world.is_css_present(span_locator)) + assert_false(world.css_visible(span_locator)) @step(u'all sections are expanded$') def all_sections_are_expanded(step): subsection_locator = 'div.subsection-list' - subsections = world.browser.find_by_css(subsection_locator) + subsections = world.css_find(subsection_locator) for s in subsections: assert_true(s.visible) @@ -111,6 +111,6 @@ def all_sections_are_expanded(step): @step(u'all sections are collapsed$') def all_sections_are_expanded(step): subsection_locator = 'div.subsection-list' - subsections = world.browser.find_by_css(subsection_locator) + subsections = world.css_find(subsection_locator) for s in subsections: assert_false(s.visible) diff --git a/cms/djangoapps/contentstore/features/subsection.py b/cms/djangoapps/contentstore/features/subsection.py index 88e1424898..f5863be27b 100644 --- a/cms/djangoapps/contentstore/features/subsection.py +++ b/cms/djangoapps/contentstore/features/subsection.py @@ -15,8 +15,7 @@ def i_have_opened_a_new_course_section(step): @step('I click the New Subsection link') def i_click_the_new_subsection_link(step): - css = 'a.new-subsection-item' - css_click(css) + world.css_click('a.new-subsection-item') @step('I enter the subsection name and click save$') @@ -31,13 +30,13 @@ def i_save_subsection_name_with_quote(step): @step('I click to edit the subsection name$') def i_click_to_edit_subsection_name(step): - css_click('span.subsection-name-value') + world.css_click('span.subsection-name-value') @step('I see the complete subsection name with a quote in the editor$') def i_see_complete_subsection_name_with_quote_in_editor(step): css = '.subsection-display-name-input' - assert world.browser.is_element_present_by_css(css, 5) + assert world.is_css_present(css) assert_equal(world.browser.find_by_css(css).value, 'Subsection With "Quote"') @@ -70,11 +69,11 @@ def the_subsection_does_not_exist(step): def save_subsection_name(name): name_css = 'input.new-subsection-name-input' save_css = 'input.new-subsection-name-save' - css_fill(name_css, name) - css_click(save_css) + world.css_fill(name_css, name) + world.css_click(save_css) def see_subsection_name(name): css = 'span.subsection-name' - assert world.browser.is_element_present_by_css(css) + assert world.is_css_present(css) css = 'span.subsection-name-value' - assert_css_with_text(css, name) + assert world.css_has_text(css, name) diff --git a/common/djangoapps/terrain/ui_helpers.py b/common/djangoapps/terrain/ui_helpers.py index 3009d1fa8d..e2f701d089 100644 --- a/common/djangoapps/terrain/ui_helpers.py +++ b/common/djangoapps/terrain/ui_helpers.py @@ -2,6 +2,9 @@ from lettuce import world, step import time from urllib import quote_plus from selenium.common.exceptions import WebDriverException +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.common.by import By +from selenium.webdriver.support.ui import WebDriverWait from lettuce.django import django_url @@ -9,6 +12,9 @@ from lettuce.django import django_url def wait(seconds): time.sleep(float(seconds)) +@world.absorb +def wait_for(func): + WebDriverWait(world.browser.driver, 5).until(func) @world.absorb def visit(url): @@ -24,9 +30,27 @@ def url_equals(url): def is_css_present(css_selector): return world.browser.is_element_present_by_css(css_selector, wait_time=4) +@world.absorb +def css_has_text(css_selector, text): + return world.css_text(css_selector) == text + +@world.absorb +def css_find(css): + def is_visible(driver): + return EC.visibility_of_element_located((By.CSS_SELECTOR,css,)) + + world.browser.is_element_present_by_css(css, 5) + wait_for(is_visible) + return world.browser.find_by_css(css) @world.absorb def css_click(css_selector): + ''' + First try to use the regular click method, + but if clicking in the middle of an element + doesn't work it might be that it thinks some other + element is on top of it there so click in the upper left + ''' try: world.browser.find_by_css(css_selector).click() @@ -37,6 +61,16 @@ def css_click(css_selector): time.sleep(1) world.browser.find_by_css(css_selector).click() +@world.absorb +def css_click_at(css, x=10, y=10): + ''' + A method to click at x,y coordinates of the element + rather than in the center of the element + ''' + e = css_find(css).first + e.action_chains.move_to_element_with_offset(e._element, x, y) + e.action_chains.click() + e.action_chains.perform() @world.absorb def css_fill(css_selector, text): From a58ae9b62d60450b7bf18a49531487e2150cf094 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Fri, 22 Mar 2013 13:49:50 -0400 Subject: [PATCH 024/128] Refactored studio lettuce test section.py to use more of ui helpers --- cms/djangoapps/contentstore/features/section.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cms/djangoapps/contentstore/features/section.py b/cms/djangoapps/contentstore/features/section.py index e57d50bbfe..41236f6dfd 100644 --- a/cms/djangoapps/contentstore/features/section.py +++ b/cms/djangoapps/contentstore/features/section.py @@ -105,13 +105,13 @@ def i_see_a_link_to_create_a_new_subsection(step): @step('the section release date picker is not visible$') def the_section_release_date_picker_not_visible(step): css = 'div.edit-subsection-publish-settings' - assert False, world.browser.find_by_css(css).visible + assert not world.css_visible(css) @step('the section release date is updated$') def the_section_release_date_is_updated(step): css = 'span.published-status' - status_text = world.browser.find_by_css(css).text + status_text = world.css_text(css) assert_equal(status_text,'Will Release: 12/25/2013 at 12:00am') From 00d25b684cf10bd2c8dd39a5077e365b3259bfde Mon Sep 17 00:00:00 2001 From: Will Daly Date: Fri, 22 Mar 2013 14:04:04 -0400 Subject: [PATCH 025/128] Moved modulestore flush code into terrain/course_helpers --- .../contentstore/features/common.py | 19 +------------------ .../contentstore/features/courses.py | 2 +- .../features/studio-overview-togglesection.py | 6 +++--- .../contentstore/features/subsection.py | 2 +- common/djangoapps/terrain/course_helpers.py | 15 +++++++++++++++ lms/djangoapps/courseware/features/common.py | 15 +-------------- 6 files changed, 22 insertions(+), 37 deletions(-) diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py index 4cc5759949..0b5c9acbed 100644 --- a/cms/djangoapps/contentstore/features/common.py +++ b/cms/djangoapps/contentstore/features/common.py @@ -43,7 +43,7 @@ def i_press_the_category_delete_icon(step, category): @step('I have opened a new course in Studio$') def i_have_opened_a_new_course(step): - clear_courses() + world.clear_courses() log_into_studio() create_a_course() @@ -69,23 +69,6 @@ def create_studio_user( user_profile = world.UserProfileFactory(user=studio_user) -def flush_xmodule_store(): - # 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()" - _MODULESTORES = {} - modulestore().collection.drop() - update_templates() - - -def clear_courses(): - flush_xmodule_store() - - def fill_in_course_info( name='Robot Super Course', org='MITx', diff --git a/cms/djangoapps/contentstore/features/courses.py b/cms/djangoapps/contentstore/features/courses.py index 8301e6708f..348cc25e97 100644 --- a/cms/djangoapps/contentstore/features/courses.py +++ b/cms/djangoapps/contentstore/features/courses.py @@ -6,7 +6,7 @@ from common import * @step('There are no courses$') def no_courses(step): - clear_courses() + world.clear_courses() @step('I click the New Course button$') diff --git a/cms/djangoapps/contentstore/features/studio-overview-togglesection.py b/cms/djangoapps/contentstore/features/studio-overview-togglesection.py index 85a25a55ac..dc22d3ad1a 100644 --- a/cms/djangoapps/contentstore/features/studio-overview-togglesection.py +++ b/cms/djangoapps/contentstore/features/studio-overview-togglesection.py @@ -8,13 +8,13 @@ logger = getLogger(__name__) @step(u'I have a course with no sections$') def have_a_course(step): - clear_courses() + world.clear_courses() course = world.CourseFactory.create() @step(u'I have a course with 1 section$') def have_a_course_with_1_section(step): - clear_courses() + world.clear_courses() course = world.CourseFactory.create() section = world.ItemFactory.create(parent_location=course.location) subsection1 = world.ItemFactory.create( @@ -25,7 +25,7 @@ def have_a_course_with_1_section(step): @step(u'I have a course with multiple sections$') def have_a_course_with_two_sections(step): - clear_courses() + world.clear_courses() course = world.CourseFactory.create() section = world.ItemFactory.create(parent_location=course.location) subsection1 = world.ItemFactory.create( diff --git a/cms/djangoapps/contentstore/features/subsection.py b/cms/djangoapps/contentstore/features/subsection.py index f5863be27b..2094e65ccb 100644 --- a/cms/djangoapps/contentstore/features/subsection.py +++ b/cms/djangoapps/contentstore/features/subsection.py @@ -7,7 +7,7 @@ from nose.tools import assert_equal @step('I have opened a new course section in Studio$') def i_have_opened_a_new_course_section(step): - clear_courses() + world.clear_courses() log_into_studio() create_a_course() add_section() diff --git a/common/djangoapps/terrain/course_helpers.py b/common/djangoapps/terrain/course_helpers.py index ebf5745f11..2ac3befd82 100644 --- a/common/djangoapps/terrain/course_helpers.py +++ b/common/djangoapps/terrain/course_helpers.py @@ -7,6 +7,8 @@ from django.contrib.auth import authenticate, login from django.contrib.auth.middleware import AuthenticationMiddleware from django.contrib.sessions.middleware import SessionMiddleware from student.models import CourseEnrollment +from xmodule.modulestore.django import _MODULESTORES, modulestore +from xmodule.templates import update_templates from bs4 import BeautifulSoup import os.path from urllib import quote_plus @@ -119,3 +121,16 @@ def save_the_course_content(path='/tmp'): f = open('%s/%s' % (path, filename), 'w') f.write(output) f.close + +@world.absorb +def clear_courses(): + # 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()" + _MODULESTORES = {} + modulestore().collection.drop() + update_templates() diff --git a/lms/djangoapps/courseware/features/common.py b/lms/djangoapps/courseware/features/common.py index 2d366d462d..f015725ae9 100644 --- a/lms/djangoapps/courseware/features/common.py +++ b/lms/djangoapps/courseware/features/common.py @@ -24,7 +24,7 @@ def create_course(step, course): # First clear the modulestore so we don't try to recreate # the same course twice # This also ensures that the necessary templates are loaded - flush_xmodule_store() + world.clear_courses() # Create the course # We always use the same org and display name, @@ -65,19 +65,6 @@ def add_tab_to_course(step, course, extra_tab_name): display_name=str(extra_tab_name)) -def flush_xmodule_store(): - # 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()" - _MODULESTORES = {} - modulestore().collection.drop() - update_templates() - - def course_id(course_num): return "%s/%s/%s" % (TEST_COURSE_ORG, course_num, TEST_COURSE_NAME.replace(" ", "_")) From 27d5ebf027224239c5109820794d6e5c0098930d Mon Sep 17 00:00:00 2001 From: Will Daly Date: Fri, 22 Mar 2013 14:27:10 -0400 Subject: [PATCH 026/128] pep8 fixes --- .../features/advanced-settings.feature | 8 +++--- .../features/advanced-settings.py | 1 + .../contentstore/features/common.py | 1 + .../contentstore/features/courses.feature | 2 +- .../contentstore/features/courses.py | 1 + .../contentstore/features/section.py | 2 +- .../contentstore/features/signup.py | 1 + .../studio-overview-togglesection.feature | 28 +++++++++---------- .../contentstore/features/subsection.py | 1 + common/djangoapps/terrain/course_helpers.py | 1 + common/djangoapps/terrain/steps.py | 3 ++ common/djangoapps/terrain/ui_helpers.py | 11 ++++++-- .../features/high-level-tabs.feature | 2 +- 13 files changed, 39 insertions(+), 23 deletions(-) diff --git a/cms/djangoapps/contentstore/features/advanced-settings.feature b/cms/djangoapps/contentstore/features/advanced-settings.feature index af97709ad0..66039e19b1 100644 --- a/cms/djangoapps/contentstore/features/advanced-settings.feature +++ b/cms/djangoapps/contentstore/features/advanced-settings.feature @@ -1,6 +1,6 @@ Feature: Advanced (manual) course policy In order to specify course policy settings for which no custom user interface exists - I want to be able to manually enter JSON key/value pairs + I want to be able to manually enter JSON key /value pairs Scenario: A course author sees default advanced settings Given I have opened a new course in Studio @@ -27,16 +27,16 @@ Feature: Advanced (manual) course policy And I reload the page Then the policy key value is changed - Scenario: Test how multi-line input appears + Scenario: Test how multi -line input appears Given I am on the Advanced Course Settings page in Studio When I create a JSON object as a value Then it is displayed as formatted And I reload the page Then it is displayed as formatted - Scenario: Test automatic quoting of non-JSON values + Scenario: Test automatic quoting of non -JSON values Given I am on the Advanced Course Settings page in Studio - When I create a non-JSON value not in quotes + When I create a non -JSON value not in quotes Then it is displayed as a string And I reload the page Then it is displayed as a string diff --git a/cms/djangoapps/contentstore/features/advanced-settings.py b/cms/djangoapps/contentstore/features/advanced-settings.py index 0232c3b908..a2708d8c96 100644 --- a/cms/djangoapps/contentstore/features/advanced-settings.py +++ b/cms/djangoapps/contentstore/features/advanced-settings.py @@ -16,6 +16,7 @@ DISPLAY_NAME_KEY = "display_name" DISPLAY_NAME_VALUE = '"Robot Super Course"' ############### ACTIONS #################### + @step('I select the Advanced Settings$') def i_select_advanced_settings(step): expand_icon_css = 'li.nav-course-settings i.icon-expand' diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py index 0b5c9acbed..870ab89694 100644 --- a/cms/djangoapps/contentstore/features/common.py +++ b/cms/djangoapps/contentstore/features/common.py @@ -10,6 +10,7 @@ from logging import getLogger logger = getLogger(__name__) ########### STEP HELPERS ############## + @step('I (?:visit|access|open) the Studio homepage$') def i_visit_the_studio_homepage(step): # To make this go to port 8001, put diff --git a/cms/djangoapps/contentstore/features/courses.feature b/cms/djangoapps/contentstore/features/courses.feature index 39d39b50aa..455313b0e2 100644 --- a/cms/djangoapps/contentstore/features/courses.feature +++ b/cms/djangoapps/contentstore/features/courses.feature @@ -10,4 +10,4 @@ Feature: Create Course And I fill in the new course information And I press the "Save" button Then the Courseware page has loaded in Studio - And I see a link for adding a new section \ No newline at end of file + And I see a link for adding a new section diff --git a/cms/djangoapps/contentstore/features/courses.py b/cms/djangoapps/contentstore/features/courses.py index 348cc25e97..b3b6f91bdb 100644 --- a/cms/djangoapps/contentstore/features/courses.py +++ b/cms/djangoapps/contentstore/features/courses.py @@ -43,6 +43,7 @@ def i_see_the_course_in_my_courses(step): course_css = 'span.class-name' assert world.css_has_text(course_css, 'Robot Super Course') + @step('the course is loaded$') def course_is_loaded(step): class_css = 'a.class-name' diff --git a/cms/djangoapps/contentstore/features/section.py b/cms/djangoapps/contentstore/features/section.py index 41236f6dfd..65f3bd4897 100644 --- a/cms/djangoapps/contentstore/features/section.py +++ b/cms/djangoapps/contentstore/features/section.py @@ -112,7 +112,7 @@ def the_section_release_date_picker_not_visible(step): def the_section_release_date_is_updated(step): css = 'span.published-status' status_text = world.css_text(css) - assert_equal(status_text,'Will Release: 12/25/2013 at 12:00am') + assert_equal(status_text, 'Will Release: 12/25/2013 at 12:00am') ############ HELPER METHODS ################### diff --git a/cms/djangoapps/contentstore/features/signup.py b/cms/djangoapps/contentstore/features/signup.py index cd4adb79fb..2dcf0d63fe 100644 --- a/cms/djangoapps/contentstore/features/signup.py +++ b/cms/djangoapps/contentstore/features/signup.py @@ -20,6 +20,7 @@ def i_press_the_button_on_the_registration_form(step): e = world.css_find(submit_css) e.type(' ') + @step('I should see be on the studio home page$') def i_should_see_be_on_the_studio_home_page(step): assert world.browser.find_by_css('div.inner-wrapper') diff --git a/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature b/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature index 52c10e41a8..88492d55e3 100644 --- a/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature +++ b/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature @@ -1,30 +1,30 @@ Feature: Overview Toggle Section In order to quickly view the details of a course's section or to scan the inventory of sections - As a course author - I want to toggle the visibility of each section's subsection details in the overview listing + As a course author + I want to toggle the visibility of each section's subsection details in the overview listing Scenario: The default layout for the overview page is to show sections in expanded view Given I have a course with multiple sections - When I navigate to the course overview page - Then I see the "Collapse All Sections" link - And all sections are expanded + When I navigate to the course overview page + Then I see the "Collapse All Sections" link + And all sections are expanded - Scenario: Expand/collapse for a course with no sections + Scenario: Expand /collapse for a course with no sections Given I have a course with no sections - When I navigate to the course overview page - Then I do not see the "Collapse All Sections" link + When I navigate to the course overview page + Then I do not see the "Collapse All Sections" link Scenario: Collapse link appears after creating first section of a course Given I have a course with no sections - When I navigate to the course overview page - And I add a section - Then I see the "Collapse All Sections" link - And all sections are expanded + When I navigate to the course overview page + And I add a section + Then I see the "Collapse All Sections" link + And all sections are expanded - @skip-phantom + @skip -phantom Scenario: Collapse link is not removed after last section of a course is deleted Given I have a course with 1 section - And I navigate to the course overview page + And I navigate to the course overview page When I press the "section" delete icon And I confirm the alert Then I see the "Collapse All Sections" link diff --git a/cms/djangoapps/contentstore/features/subsection.py b/cms/djangoapps/contentstore/features/subsection.py index 2094e65ccb..8695ea1c4f 100644 --- a/cms/djangoapps/contentstore/features/subsection.py +++ b/cms/djangoapps/contentstore/features/subsection.py @@ -72,6 +72,7 @@ def save_subsection_name(name): world.css_fill(name_css, name) world.css_click(save_css) + def see_subsection_name(name): css = 'span.subsection-name' assert world.is_css_present(css) diff --git a/common/djangoapps/terrain/course_helpers.py b/common/djangoapps/terrain/course_helpers.py index 2ac3befd82..85dfa85b37 100644 --- a/common/djangoapps/terrain/course_helpers.py +++ b/common/djangoapps/terrain/course_helpers.py @@ -122,6 +122,7 @@ def save_the_course_content(path='/tmp'): f.write(output) f.close + @world.absorb def clear_courses(): # Flush and initialize the module store diff --git a/common/djangoapps/terrain/steps.py b/common/djangoapps/terrain/steps.py index dc8d2f8b87..bf78a1d2b7 100644 --- a/common/djangoapps/terrain/steps.py +++ b/common/djangoapps/terrain/steps.py @@ -29,11 +29,13 @@ def i_visit_the_homepage(step): world.visit('/') assert world.is_css_present('header.global') + @step(u'I (?:visit|access|open) the dashboard$') def i_visit_the_dashboard(step): world.visit('/dashboard') assert world.is_css_present('section.container.dashboard') + @step('I should be on the dashboard page$') def i_should_be_on_the_dashboard(step): assert world.is_css_present('section.container.dashboard') @@ -97,6 +99,7 @@ def i_am_staff_for_course_by_id(step, course_id): def click_the_link_called(step, text): world.click_link(text) + @step(r'should see that the url is "([^"]*)"$') def should_have_the_url(step, url): assert_equals(world.browser.url, url) diff --git a/common/djangoapps/terrain/ui_helpers.py b/common/djangoapps/terrain/ui_helpers.py index e2f701d089..6dadb976a7 100644 --- a/common/djangoapps/terrain/ui_helpers.py +++ b/common/djangoapps/terrain/ui_helpers.py @@ -12,10 +12,12 @@ from lettuce.django import django_url def wait(seconds): time.sleep(float(seconds)) + @world.absorb def wait_for(func): WebDriverWait(world.browser.driver, 5).until(func) + @world.absorb def visit(url): world.browser.visit(django_url(url)) @@ -30,23 +32,26 @@ def url_equals(url): def is_css_present(css_selector): return world.browser.is_element_present_by_css(css_selector, wait_time=4) + @world.absorb def css_has_text(css_selector, text): return world.css_text(css_selector) == text + @world.absorb def css_find(css): def is_visible(driver): - return EC.visibility_of_element_located((By.CSS_SELECTOR,css,)) + return EC.visibility_of_element_located((By.CSS_SELECTOR, css,)) world.browser.is_element_present_by_css(css, 5) wait_for(is_visible) return world.browser.find_by_css(css) + @world.absorb def css_click(css_selector): ''' - First try to use the regular click method, + First try to use the regular click method, but if clicking in the middle of an element doesn't work it might be that it thinks some other element is on top of it there so click in the upper left @@ -61,6 +66,7 @@ def css_click(css_selector): time.sleep(1) world.browser.find_by_css(css_selector).click() + @world.absorb def css_click_at(css, x=10, y=10): ''' @@ -72,6 +78,7 @@ def css_click_at(css, x=10, y=10): e.action_chains.click() e.action_chains.perform() + @world.absorb def css_fill(css_selector, text): world.browser.find_by_css(css_selector).first.fill(text) diff --git a/lms/djangoapps/courseware/features/high-level-tabs.feature b/lms/djangoapps/courseware/features/high-level-tabs.feature index 473f3f1572..c60ec7b374 100644 --- a/lms/djangoapps/courseware/features/high-level-tabs.feature +++ b/lms/djangoapps/courseware/features/high-level-tabs.feature @@ -3,7 +3,7 @@ Feature: All the high level tabs should work As a student I want to navigate through the high level tabs -Scenario: I can navigate to all high -level tabs in a course +Scenario: I can navigate to all high - level tabs in a course Given: I am registered for the course "6.002x" And The course "6.002x" has extra tab "Custom Tab" And I am logged in From 0500ba4dd5e4a8563a31c6557f8ca331cdba8cfa Mon Sep 17 00:00:00 2001 From: Will Daly Date: Tue, 26 Mar 2013 11:17:56 -0400 Subject: [PATCH 027/128] Disabled pylint warnings for lettuce steps: * Missing docstring * Redefining name from outer scope --- cms/djangoapps/contentstore/features/advanced-settings.py | 3 +++ cms/djangoapps/contentstore/features/common.py | 3 +++ cms/djangoapps/contentstore/features/courses.py | 3 +++ cms/djangoapps/contentstore/features/section.py | 3 +++ cms/djangoapps/contentstore/features/signup.py | 3 +++ .../contentstore/features/studio-overview-togglesection.py | 3 +++ cms/djangoapps/contentstore/features/subsection.py | 3 +++ common/djangoapps/terrain/course_helpers.py | 3 +++ common/djangoapps/terrain/steps.py | 3 +++ common/djangoapps/terrain/ui_helpers.py | 3 +++ lms/djangoapps/courseware/features/common.py | 3 +++ lms/djangoapps/courseware/features/courseware.py | 3 +++ lms/djangoapps/courseware/features/courseware_common.py | 3 +++ lms/djangoapps/courseware/features/homepage.py | 3 +++ lms/djangoapps/courseware/features/login.py | 3 +++ lms/djangoapps/courseware/features/openended.py | 3 +++ lms/djangoapps/courseware/features/problems.py | 2 ++ lms/djangoapps/courseware/features/registration.py | 3 +++ lms/djangoapps/courseware/features/signup.py | 4 +++- lms/djangoapps/courseware/features/smart-accordion.py | 3 +++ lms/djangoapps/courseware/features/xqueue_setup.py | 4 +++- 21 files changed, 62 insertions(+), 2 deletions(-) diff --git a/cms/djangoapps/contentstore/features/advanced-settings.py b/cms/djangoapps/contentstore/features/advanced-settings.py index a2708d8c96..16562b6b15 100644 --- a/cms/djangoapps/contentstore/features/advanced-settings.py +++ b/cms/djangoapps/contentstore/features/advanced-settings.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step from common import * import time diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py index 870ab89694..3878340af3 100644 --- a/cms/djangoapps/contentstore/features/common.py +++ b/cms/djangoapps/contentstore/features/common.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step from nose.tools import assert_true from nose.tools import assert_equal diff --git a/cms/djangoapps/contentstore/features/courses.py b/cms/djangoapps/contentstore/features/courses.py index b3b6f91bdb..5da7720945 100644 --- a/cms/djangoapps/contentstore/features/courses.py +++ b/cms/djangoapps/contentstore/features/courses.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step from common import * diff --git a/cms/djangoapps/contentstore/features/section.py b/cms/djangoapps/contentstore/features/section.py index 65f3bd4897..0c0f5536a0 100644 --- a/cms/djangoapps/contentstore/features/section.py +++ b/cms/djangoapps/contentstore/features/section.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step from common import * from nose.tools import assert_equal diff --git a/cms/djangoapps/contentstore/features/signup.py b/cms/djangoapps/contentstore/features/signup.py index 2dcf0d63fe..6ca358183b 100644 --- a/cms/djangoapps/contentstore/features/signup.py +++ b/cms/djangoapps/contentstore/features/signup.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step from common import * diff --git a/cms/djangoapps/contentstore/features/studio-overview-togglesection.py b/cms/djangoapps/contentstore/features/studio-overview-togglesection.py index dc22d3ad1a..7f717b731c 100644 --- a/cms/djangoapps/contentstore/features/studio-overview-togglesection.py +++ b/cms/djangoapps/contentstore/features/studio-overview-togglesection.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step from common import * from nose.tools import assert_true, assert_false, assert_equal diff --git a/cms/djangoapps/contentstore/features/subsection.py b/cms/djangoapps/contentstore/features/subsection.py index 8695ea1c4f..54f49f2fa6 100644 --- a/cms/djangoapps/contentstore/features/subsection.py +++ b/cms/djangoapps/contentstore/features/subsection.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step from common import * from nose.tools import assert_equal diff --git a/common/djangoapps/terrain/course_helpers.py b/common/djangoapps/terrain/course_helpers.py index 85dfa85b37..f0df456c80 100644 --- a/common/djangoapps/terrain/course_helpers.py +++ b/common/djangoapps/terrain/course_helpers.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step from .factories import * from django.conf import settings diff --git a/common/djangoapps/terrain/steps.py b/common/djangoapps/terrain/steps.py index bf78a1d2b7..a8a32db173 100644 --- a/common/djangoapps/terrain/steps.py +++ b/common/djangoapps/terrain/steps.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step from .course_helpers import * from .ui_helpers import * diff --git a/common/djangoapps/terrain/ui_helpers.py b/common/djangoapps/terrain/ui_helpers.py index 6dadb976a7..d4d99e17b5 100644 --- a/common/djangoapps/terrain/ui_helpers.py +++ b/common/djangoapps/terrain/ui_helpers.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step import time from urllib import quote_plus diff --git a/lms/djangoapps/courseware/features/common.py b/lms/djangoapps/courseware/features/common.py index f015725ae9..f6256adfa1 100644 --- a/lms/djangoapps/courseware/features/common.py +++ b/lms/djangoapps/courseware/features/common.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step from nose.tools import assert_equals, assert_in from lettuce.django import django_url diff --git a/lms/djangoapps/courseware/features/courseware.py b/lms/djangoapps/courseware/features/courseware.py index 7e99cc9f55..234f3a84d2 100644 --- a/lms/djangoapps/courseware/features/courseware.py +++ b/lms/djangoapps/courseware/features/courseware.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step from lettuce.django import django_url diff --git a/lms/djangoapps/courseware/features/courseware_common.py b/lms/djangoapps/courseware/features/courseware_common.py index 6aa9559e65..4e9aa3fb7b 100644 --- a/lms/djangoapps/courseware/features/courseware_common.py +++ b/lms/djangoapps/courseware/features/courseware_common.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step diff --git a/lms/djangoapps/courseware/features/homepage.py b/lms/djangoapps/courseware/features/homepage.py index 442098c161..62e9096e70 100644 --- a/lms/djangoapps/courseware/features/homepage.py +++ b/lms/djangoapps/courseware/features/homepage.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step from nose.tools import assert_in diff --git a/lms/djangoapps/courseware/features/login.py b/lms/djangoapps/courseware/features/login.py index 3e3c0efbc4..bc90ea301c 100644 --- a/lms/djangoapps/courseware/features/login.py +++ b/lms/djangoapps/courseware/features/login.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import step, world from django.contrib.auth.models import User diff --git a/lms/djangoapps/courseware/features/openended.py b/lms/djangoapps/courseware/features/openended.py index 2f14b808a3..d848eb55d7 100644 --- a/lms/djangoapps/courseware/features/openended.py +++ b/lms/djangoapps/courseware/features/openended.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step from lettuce.django import django_url from nose.tools import assert_equals, assert_in diff --git a/lms/djangoapps/courseware/features/problems.py b/lms/djangoapps/courseware/features/problems.py index bdd9062ef3..b25d606c4e 100644 --- a/lms/djangoapps/courseware/features/problems.py +++ b/lms/djangoapps/courseware/features/problems.py @@ -2,6 +2,8 @@ Steps for problem.feature lettuce tests ''' +#pylint: disable=C0111 +#pylint: disable=W0621 from lettuce import world, step from lettuce.django import django_url diff --git a/lms/djangoapps/courseware/features/registration.py b/lms/djangoapps/courseware/features/registration.py index 63f044b16f..72bde65f99 100644 --- a/lms/djangoapps/courseware/features/registration.py +++ b/lms/djangoapps/courseware/features/registration.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step from lettuce.django import django_url from common import TEST_COURSE_ORG, TEST_COURSE_NAME diff --git a/lms/djangoapps/courseware/features/signup.py b/lms/djangoapps/courseware/features/signup.py index d9edcb215b..5ba385ef54 100644 --- a/lms/djangoapps/courseware/features/signup.py +++ b/lms/djangoapps/courseware/features/signup.py @@ -1,5 +1,7 @@ -from lettuce import world, step +#pylint: disable=C0111 +#pylint: disable=W0621 +from lettuce import world, step @step('I fill in "([^"]*)" on the registration form with "([^"]*)"$') def when_i_fill_in_field_on_the_registration_form_with_value(step, field, value): diff --git a/lms/djangoapps/courseware/features/smart-accordion.py b/lms/djangoapps/courseware/features/smart-accordion.py index 8240a13905..63408d7683 100644 --- a/lms/djangoapps/courseware/features/smart-accordion.py +++ b/lms/djangoapps/courseware/features/smart-accordion.py @@ -1,3 +1,6 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step from re import sub from nose.tools import assert_equals diff --git a/lms/djangoapps/courseware/features/xqueue_setup.py b/lms/djangoapps/courseware/features/xqueue_setup.py index d6d7a13a5c..90a68961ee 100644 --- a/lms/djangoapps/courseware/features/xqueue_setup.py +++ b/lms/djangoapps/courseware/features/xqueue_setup.py @@ -1,9 +1,11 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from courseware.mock_xqueue_server.mock_xqueue_server import MockXQueueServer from lettuce import before, after, world from django.conf import settings import threading - @before.all def setup_mock_xqueue_server(): From 6298a4ceabe221adba40acfda523d17c3f172355 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Tue, 26 Mar 2013 13:05:09 -0400 Subject: [PATCH 028/128] Fixed lettuce tests in cms that were broken in the last rebase --- .../contentstore/features/checklists.py | 28 +++++++++++-------- .../contentstore/features/course-settings.py | 26 +++++++++-------- 2 files changed, 30 insertions(+), 24 deletions(-) diff --git a/cms/djangoapps/contentstore/features/checklists.py b/cms/djangoapps/contentstore/features/checklists.py index 9ef66c8096..dc399f5fac 100644 --- a/cms/djangoapps/contentstore/features/checklists.py +++ b/cms/djangoapps/contentstore/features/checklists.py @@ -1,15 +1,19 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step -from common import * +from nose.tools import assert_true, assert_equal from terrain.steps import reload_the_page +from selenium.common.exceptions import StaleElementReferenceException ############### ACTIONS #################### @step('I select Checklists from the Tools menu$') def i_select_checklists(step): expand_icon_css = 'li.nav-course-tools i.icon-expand' if world.browser.is_element_present_by_css(expand_icon_css): - css_click(expand_icon_css) + world.css_click(expand_icon_css) link_css = 'li.nav-course-tools-checklists a' - css_click(link_css) + world.css_click(link_css) @step('I have opened Checklists$') @@ -20,7 +24,7 @@ def i_have_opened_checklists(step): @step('I see the four default edX checklists$') def i_see_default_checklists(step): - checklists = css_find('.checklist-title') + checklists = world.css_find('.checklist-title') assert_equal(4, len(checklists)) assert_true(checklists[0].text.endswith('Getting Started With Studio')) assert_true(checklists[1].text.endswith('Draft a Rough Course Outline')) @@ -58,7 +62,7 @@ def i_select_a_link_to_the_course_outline(step): @step('I am brought to the course outline page$') def i_am_brought_to_course_outline(step): - assert_equal('Course Outline', css_find('.outline .title-1')[0].text) + assert_equal('Course Outline', world.css_find('.outline .title-1')[0].text) assert_equal(1, len(world.browser.windows)) @@ -90,30 +94,30 @@ def i_am_brought_to_help_page_in_new_window(step): def verifyChecklist2Status(completed, total, percentage): def verify_count(driver): try: - statusCount = css_find('#course-checklist1 .status-count').first + statusCount = world.css_find('#course-checklist1 .status-count').first return statusCount.text == str(completed) except StaleElementReferenceException: return False - wait_for(verify_count) - assert_equal(str(total), css_find('#course-checklist1 .status-amount').first.text) + world.wait_for(verify_count) + assert_equal(str(total), world.css_find('#course-checklist1 .status-amount').first.text) # Would like to check the CSS width, but not sure how to do that. - assert_equal(str(percentage), css_find('#course-checklist1 .viz-checklist-status-value .int').first.text) + assert_equal(str(percentage), world.css_find('#course-checklist1 .viz-checklist-status-value .int').first.text) def toggleTask(checklist, task): - css_click('#course-checklist' + str(checklist) +'-task' + str(task)) + world.css_click('#course-checklist' + str(checklist) +'-task' + str(task)) def clickActionLink(checklist, task, actionText): # toggle checklist item to make sure that the link button is showing toggleTask(checklist, task) - action_link = css_find('#course-checklist' + str(checklist) + ' a')[task] + action_link = world.css_find('#course-checklist' + str(checklist) + ' a')[task] # text will be empty initially, wait for it to populate def verify_action_link_text(driver): return action_link.text == actionText - wait_for(verify_action_link_text) + world.wait_for(verify_action_link_text) action_link.click() diff --git a/cms/djangoapps/contentstore/features/course-settings.py b/cms/djangoapps/contentstore/features/course-settings.py index a0c25045f2..9eb5b0951d 100644 --- a/cms/djangoapps/contentstore/features/course-settings.py +++ b/cms/djangoapps/contentstore/features/course-settings.py @@ -1,5 +1,7 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + from lettuce import world, step -from common import * from terrain.steps import reload_the_page from selenium.webdriver.common.keys import Keys import time @@ -25,9 +27,9 @@ DEFAULT_TIME = "12:00am" def test_i_select_schedule_and_details(step): expand_icon_css = 'li.nav-course-settings i.icon-expand' if world.browser.is_element_present_by_css(expand_icon_css): - css_click(expand_icon_css) + world.css_click(expand_icon_css) link_css = 'li.nav-course-settings-schedule a' - css_click(link_css) + world.css_click(link_css) @step('I have set course dates$') @@ -97,9 +99,9 @@ def test_i_clear_the_course_start_date(step): @step('I receive a warning about course start date$') def test_i_receive_a_warning_about_course_start_date(step): - assert_css_with_text('.message-error', 'The course must have an assigned start date.') - assert_true('error' in css_find(COURSE_START_DATE_CSS).first._element.get_attribute('class')) - assert_true('error' in css_find(COURSE_START_TIME_CSS).first._element.get_attribute('class')) + assert_true(world.css_has_text('.message-error', 'The course must have an assigned start date.')) + assert_true('error' in world.css_find(COURSE_START_DATE_CSS).first._element.get_attribute('class')) + assert_true('error' in world.css_find(COURSE_START_TIME_CSS).first._element.get_attribute('class')) @step('The previously set start date is shown on refresh$') @@ -124,9 +126,9 @@ def test_i_have_entered_a_new_course_start_date(step): @step('The warning about course start date goes away$') def test_the_warning_about_course_start_date_goes_away(step): - assert_equal(0, len(css_find('.message-error'))) - assert_false('error' in css_find(COURSE_START_DATE_CSS).first._element.get_attribute('class')) - assert_false('error' in css_find(COURSE_START_TIME_CSS).first._element.get_attribute('class')) + assert_equal(0, len(world.css_find('.message-error'))) + assert_false('error' in world.css_find(COURSE_START_DATE_CSS).first._element.get_attribute('class')) + assert_false('error' in world.css_find(COURSE_START_TIME_CSS).first._element.get_attribute('class')) @step('My new course start date is shown on refresh$') @@ -142,8 +144,8 @@ def set_date_or_time(css, date_or_time): """ Sets date or time field. """ - css_fill(css, date_or_time) - e = css_find(css).first + world.css_fill(css, date_or_time) + e = world.css_find(css).first # hit Enter to apply the changes e._element.send_keys(Keys.ENTER) @@ -152,7 +154,7 @@ def verify_date_or_time(css, date_or_time): """ Verifies date or time field. """ - assert_equal(date_or_time, css_find(css).first.value) + assert_equal(date_or_time, world.css_find(css).first.value) def pause(): From 47e47303dca6cf6b81272a5a8882951d88c8d8b8 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Tue, 26 Mar 2013 13:36:27 -0400 Subject: [PATCH 029/128] 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 030/128] 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 031/128] 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 967cf7e6f36e98b1db7c8560e535c25c1327f476 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Tue, 26 Mar 2013 14:43:41 -0400 Subject: [PATCH 032/128] Fix a problem where trying to show image response answers was causing 500 errors. Add test to verify that this won't happen again. --- common/lib/capa/capa/responsetypes.py | 42 ++++++++++++++++--- .../lib/capa/capa/tests/test_responsetypes.py | 11 +++++ 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 8ab716735c..2035c42661 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -1961,9 +1961,10 @@ class ImageResponse(LoncapaResponse): self.ielements = self.inputfields self.answer_ids = [ie.get('id') for ie in self.ielements] + def get_score(self, student_answers): correct_map = CorrectMap() - expectedset = self.get_answers() + expectedset = self.get_mapped_answers() for aid in self.answer_ids: # loop through IDs of # fields in our stanza given = student_answers[ @@ -2018,11 +2019,42 @@ class ImageResponse(LoncapaResponse): break return correct_map - def get_answers(self): - return ( + def get_mapped_answers(self): + ''' + Returns the internal representation of the answers + + Input: + None + Returns: + tuple (dict, dict) - + rectangles (dict) - a map of inputs to the defined rectangle for that input + regions (dict) - a map of inputs to the defined region for that input + ''' + answers = ( dict([(ie.get('id'), ie.get( 'rectangle')) for ie in self.ielements]), dict([(ie.get('id'), ie.get('regions')) for ie in self.ielements])) + return answers + + def get_answers(self): + ''' + Returns the external representation of the answers + + Input: + None + Returns: + dict (str, (str, str)) - a map of inputs to a tuple of their rectange + and their regions + ''' + answers = {} + for ie in self.ielements: + ie_id = ie.get('id') + answers[ie_id] = (ie.get('rectangle'), ie.get('regions')) + + return answers + + + #----------------------------------------------------------------------------- @@ -2087,8 +2119,8 @@ class AnnotationResponse(LoncapaResponse): correct_option = self._find_option_with_choice( inputfield, 'correct') if correct_option is not None: - answer_map[inputfield.get( - 'id')] = correct_option.get('description') + input_id = inputfield.get('id') + answer_map[input_id] = correct_option.get('description') return answer_map def _get_max_points(self): diff --git a/common/lib/capa/capa/tests/test_responsetypes.py b/common/lib/capa/capa/tests/test_responsetypes.py index 0c007f83b2..e009c26aef 100644 --- a/common/lib/capa/capa/tests/test_responsetypes.py +++ b/common/lib/capa/capa/tests/test_responsetypes.py @@ -36,6 +36,10 @@ class ResponseTest(unittest.TestCase): correct_map = problem.grade_answers(input_dict) self.assertEquals(correct_map.get_correctness('1_2_1'), expected_correctness) + def assert_answer_format(self, problem): + answers = problem.get_question_answers() + self.assertTrue(answers['1_2_1'] is not None) + def assert_multiple_grade(self, problem, correct_answers, incorrect_answers): for input_str in correct_answers: result = problem.grade_answers({'1_2_1': input_str}).get_correctness('1_2_1') @@ -166,6 +170,13 @@ class ImageResponseTest(ResponseTest): incorrect_inputs = ["[0,0]", "[600,300]"] self.assert_multiple_grade(problem, correct_inputs, incorrect_inputs) + def test_show_answer(self): + rectangle_str = "(100,100)-(200,200)" + region_str = "[[10,10], [20,10], [20, 30]]" + + problem = self.build_problem(regions=region_str, rectangle=rectangle_str) + self.assert_answer_format(problem) + class SymbolicResponseTest(unittest.TestCase): def test_sr_grade(self): From 84f2cc8af6713b3d41efc545a7fd42a5a80e3b92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Andr=C3=A9s=20Rocha?= Date: Tue, 26 Mar 2013 15:08:22 -0400 Subject: [PATCH 033/128] Display advertised date correctly if it is an ISO date --- common/lib/xmodule/xmodule/course_module.py | 11 ++++++++++- .../xmodule/xmodule/tests/test_course_module.py | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 7999f8d6da..6f3b8e94c9 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -635,8 +635,17 @@ class CourseDescriptor(CourseFields, SequenceDescriptor): @property def start_date_text(self): + def try_parse_iso_8601(text): + try: + result = datetime.strptime(text, "%Y-%m-%dT%H:%M") + result = result.strftime("%b %d, %Y") + except ValueError: + result = text.title() + + return result + if isinstance(self.advertised_start, basestring): - return self.advertised_start + return try_parse_iso_8601(self.advertised_start) elif self.advertised_start is None and self.start is None: return 'TBD' else: diff --git a/common/lib/xmodule/xmodule/tests/test_course_module.py b/common/lib/xmodule/xmodule/tests/test_course_module.py index e1de8a1ed0..eda9cf386c 100644 --- a/common/lib/xmodule/xmodule/tests/test_course_module.py +++ b/common/lib/xmodule/xmodule/tests/test_course_module.py @@ -1,5 +1,6 @@ import unittest from time import strptime + from fs.memoryfs import MemoryFS from mock import Mock, patch @@ -108,7 +109,22 @@ class IsNewCourseTestCase(unittest.TestCase): print "Comparing %s to %s" % (a, b) assertion(a_score, b_score) + @patch('xmodule.course_module.time.gmtime') + def test_start_date_text(self, gmtime_mock): + gmtime_mock.return_value = NOW + settings = [ + # start, advertized, result + ('2012-12-02T12:00', None, 'Dec 02, 2012'), + ('2012-12-02T12:00', '2011-11-01T12:00', 'Nov 01, 2011'), + ('2012-12-02T12:00', 'Spring 2012', 'Spring 2012'), + ('2012-12-02T12:00', 'November, 2011', 'November, 2011'), + ] + + for s in settings: + d = self.get_dummy_course(start=s[0], advertised_start=s[1]) + print "Checking start=%s advertised=%s" % (s[0], s[1]) + self.assertEqual(d.start_date_text, s[2]) @patch('xmodule.course_module.time.gmtime') def test_is_newish(self, gmtime_mock): From 90553a1b1d842600588fe0ecab9402b15c11de3c Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 26 Mar 2013 16:19:34 -0400 Subject: [PATCH 034/128] Use get_many and set_many to cut down on the number of metadata trees to retrieve, and only retrieve them once per call to _load_items --- .../lib/xmodule/xmodule/modulestore/mongo.py | 73 +++++++++++-------- .../xmodule/modulestore/tests/test_mongo.py | 58 ++++++++++++++- 2 files changed, 100 insertions(+), 31 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/mongo.py b/common/lib/xmodule/xmodule/modulestore/mongo.py index b76251bb99..27a5ffbc26 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo.py @@ -9,6 +9,7 @@ from fs.osfs import OSFS from itertools import repeat from path import path from datetime import datetime +from operator import attrgetter from importlib import import_module from xmodule.errortracker import null_error_tracker, exc_info_to_str @@ -107,7 +108,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem): references to metadata_inheritance_tree """ def __init__(self, modulestore, module_data, default_class, resources_fs, - error_tracker, render_template, metadata_inheritance_tree = None): + error_tracker, render_template, metadata_cache = None): """ modulestore: the module store that can be used to retrieve additional modules @@ -132,7 +133,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem): # cdodge: other Systems have a course_id attribute defined. To keep things consistent, let's # define an attribute here as well, even though it's None self.course_id = None - self.metadata_inheritance_tree = metadata_inheritance_tree + self.metadata_cache = metadata_cache def load_item(self, location): location = Location(location) @@ -165,8 +166,8 @@ class CachingDescriptorSystem(MakoDescriptorSystem): model_data = DbModel(kvs, class_, None, MongoUsage(self.course_id, location)) module = class_(self, location, model_data) - if self.metadata_inheritance_tree is not None: - metadata_to_inherit = self.metadata_inheritance_tree.get('parent_metadata', {}).get(location.url(), {}) + if self.metadata_cache is not None: + metadata_to_inherit = self.metadata_cache.get(metadata_cache_key(location), {}).get('parent_metadata', {}).get(location.url(), {}) inherit_metadata(module, metadata_to_inherit) return module except: @@ -206,6 +207,9 @@ def namedtuple_to_son(namedtuple, prefix=''): return son +metadata_cache_key = attrgetter('org', 'course') + + class MongoModuleStore(ModuleStoreBase): """ A Mongodb backed ModuleStore @@ -278,6 +282,7 @@ class MongoModuleStore(ModuleStoreBase): # now traverse the tree and compute down the inherited metadata metadata_to_inherit = {} + def _compute_inherited_metadata(url): my_metadata = {} # check for presence of metadata key. Note that a given module may not yet be fully formed. @@ -293,7 +298,7 @@ class MongoModuleStore(ModuleStoreBase): # go through all the children and recurse, but only if we have # in the result set. Remember results will not contain leaf nodes - for child in results_by_url[url].get('definition',{}).get('children',[]): + for child in results_by_url[url].get('definition', {}).get('children', []): if child in results_by_url: new_child_metadata = copy.deepcopy(my_metadata) new_child_metadata.update(results_by_url[child].get('metadata', {})) @@ -304,42 +309,42 @@ class MongoModuleStore(ModuleStoreBase): # this is likely a leaf node, so let's record what metadata we need to inherit metadata_to_inherit[child] = my_metadata - if root is not None: _compute_inherited_metadata(root) return {'parent_metadata': metadata_to_inherit, - 'timestamp' : datetime.now()} + 'timestamp': datetime.now()} - def get_cached_metadata_inheritance_tree(self, location, force_refresh=False): + def get_cached_metadata_inheritance_trees(self, locations, force_refresh=False): ''' TODO (cdodge) This method can be deleted when the 'split module store' work has been completed ''' - key_name = '{0}/{1}'.format(location.org, location.course) - tree = None - if self.metadata_inheritance_cache is not None: - tree = self.metadata_inheritance_cache.get(key_name) + trees = {} + if locations and self.metadata_inheritance_cache is not None and not force_refresh: + trees = self.metadata_inheritance_cache.get_many(list(set([metadata_cache_key(loc) for loc in locations]))) else: # This is to help guard against an accident prod runtime without a cache logging.warning('Running MongoModuleStore without metadata_inheritance_cache. This should not happen in production!') - if tree is None or force_refresh: - tree = self.get_metadata_inheritance_tree(location) - if self.metadata_inheritance_cache is not None: - self.metadata_inheritance_cache.set(key_name, tree) + to_cache = {} + for loc in locations: + if metadata_cache_key(loc) not in trees: + to_cache[metadata_cache_key(loc)] = trees[metadata_cache_key(loc)] = self.get_metadata_inheritance_tree(loc) - return tree + if to_cache and self.metadata_inheritance_cache is not None: + self.metadata_inheritance_cache.set_many(to_cache) + + return trees def refresh_cached_metadata_inheritance_tree(self, location): pseudo_course_id = '/'.join([location.org, location.course]) if pseudo_course_id not in self.ignore_write_events_on_courses: - self.get_cached_metadata_inheritance_tree(location, force_refresh = True) + self.get_cached_metadata_inheritance_trees([location], force_refresh=True) def clear_cached_metadata_inheritance_tree(self, location): - key_name = '{0}/{1}'.format(location.org, location.course) if self.metadata_inheritance_cache is not None: - self.metadata_inheritance_cache.delete(key_name) + self.metadata_inheritance_cache.delete(metadata_cache_key(location)) def _clean_item_data(self, item): """ @@ -385,7 +390,18 @@ class MongoModuleStore(ModuleStoreBase): return data - def _load_item(self, item, data_cache, should_apply_metadata_inheritence=True): + def _cache_metadata_inheritance(self, items, depth, force_refresh=False): + """ + Retrieves all course metadata inheritance trees needed to load items + """ + + locations = [ + Location(item['location']) for item in items + if not (item['location']['category'] == 'course' and depth == 0) + ] + return self.get_cached_metadata_inheritance_trees(locations, force_refresh=force_refresh) + + def _load_item(self, item, data_cache, metadata_cache): """ Load an XModuleDescriptor from item, using the children stored in data_cache """ @@ -399,9 +415,6 @@ class MongoModuleStore(ModuleStoreBase): metadata_inheritance_tree = None - if should_apply_metadata_inheritence: - metadata_inheritance_tree = self.get_cached_metadata_inheritance_tree(Location(item['location'])) - # TODO (cdodge): When the 'split module store' work has been completed, we should remove # the 'metadata_inheritance_tree' parameter system = CachingDescriptorSystem( @@ -411,7 +424,7 @@ class MongoModuleStore(ModuleStoreBase): resource_fs, self.error_tracker, self.render_template, - metadata_inheritance_tree = metadata_inheritance_tree + metadata_cache, ) return system.load_item(item['location']) @@ -421,11 +434,11 @@ class MongoModuleStore(ModuleStoreBase): to specified depth """ data_cache = self._cache_children(items, depth) + inheritance_cache = self._cache_metadata_inheritance(items, depth) # if we are loading a course object, if we're not prefetching children (depth != 0) then don't - # bother with the metadata inheritence - return [self._load_item(item, data_cache, - should_apply_metadata_inheritence=(item['location']['category'] != 'course' or depth != 0)) for item in items] + # bother with the metadata inheritence + return [self._load_item(item, data_cache, inheritance_cache) for item in items] def get_courses(self): ''' @@ -631,7 +644,7 @@ class MongoModuleStore(ModuleStoreBase): self._update_single_item(location, {'metadata': metadata}) # recompute (and update) the metadata inheritance tree which is cached - self.refresh_cached_metadata_inheritance_tree(loc) + self.refresh_cached_metadata_inheritance_tree(loc) def delete_item(self, location): """ @@ -654,7 +667,7 @@ class MongoModuleStore(ModuleStoreBase): # from overriding our default value set in the init method. safe=self.collection.safe) # recompute (and update) the metadata inheritance tree which is cached - self.refresh_cached_metadata_inheritance_tree(Location(location)) + self.refresh_cached_metadata_inheritance_tree(Location(location)) def get_parent_locations(self, location, course_id): '''Find all locations that are the parents of this location in this diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py b/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py index 6f6f47ba85..3e29c07ea4 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py @@ -1,6 +1,7 @@ import pymongo -from nose.tools import assert_equals, assert_raises, assert_not_equals, with_setup +from mock import Mock +from nose.tools import assert_equals, assert_raises, assert_not_equals, with_setup, assert_false from pprint import pprint from xmodule.modulestore import Location @@ -102,3 +103,58 @@ class TestMongoModuleStore(object): def test_path_to_location(self): '''Make sure that path_to_location works''' check_path_to_location(self.store) + + def test_metadata_inheritance_query_count(self): + ''' + When retrieving items from mongo, we should only query the cache a number of times + equal to the number of courses being retrieved from. + + We should also not query + ''' + self.store.metadata_inheritance_cache = Mock() + get_many = self.store.metadata_inheritance_cache.get_many + set_many = self.store.metadata_inheritance_cache.set_many + get_many.return_value = {('edX', 'toy'): {}} + + self.store.get_item(Location("i4x://edX/toy/course/2012_Fall"), depth=0) + assert_false(get_many.called) + assert_false(set_many.called) + get_many.reset_mock() + + self.store.get_item(Location("i4x://edX/toy/course/2012_Fall"), depth=3) + get_many.assert_called_with([('edX', 'toy')]) + assert_equals(0, set_many.call_count) + get_many.reset_mock() + + self.store.get_items(Location('i4x', 'edX', None, 'course', None), depth=0) + assert_false(get_many.called) + assert_false(set_many.called) + get_many.reset_mock() + + self.store.get_items(Location('i4x', 'edX', None, 'course', None), depth=3) + assert_equals(1, get_many.call_count) + assert_equals([('edX', 'simple'), ('edX', 'toy')], sorted(get_many.call_args[0][0])) + assert_equals(1, set_many.call_count) + assert_equals([('edX', 'simple')], sorted(set_many.call_args[0][0].keys())) + get_many.reset_mock() + + self.store.get_items(Location('i4x', 'edX', None, None, None), depth=0) + assert_equals(1, get_many.call_count) + assert_equals([('edX', 'simple'), ('edX', 'toy')], sorted(get_many.call_args[0][0])) + assert_equals(1, set_many.call_count) + assert_equals([('edX', 'simple')], sorted(set_many.call_args[0][0].keys())) + get_many.reset_mock() + + def test_metadata_inheritance_query_count_forced_refresh(self): + self.store.metadata_inheritance_cache = Mock() + get_many = self.store.metadata_inheritance_cache.get_many + set_many = self.store.metadata_inheritance_cache.set_many + get_many.return_value = {('edX', 'toy'): {}} + + self.store.get_cached_metadata_inheritance_trees( + [Location("i4x://edX/toy/course/2012_Fall"), Location("i4x://edX/simple/course/2012_Fall")], + True + ) + assert_false(get_many.called) + assert_equals(1, set_many.call_count) + assert_equals([('edX', 'simple'), ('edX', 'toy')], sorted(set_many.call_args[0][0].keys())) From 1f11508ac6b4057a137d35a11835db4f6d231588 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 26 Mar 2013 16:30:55 -0400 Subject: [PATCH 035/128] Pylint cleanup --- .../lib/xmodule/xmodule/modulestore/mongo.py | 44 +++++++++++++------ 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/mongo.py b/common/lib/xmodule/xmodule/modulestore/mongo.py index 27a5ffbc26..47049b9fd6 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo.py @@ -97,6 +97,7 @@ class MongoKeyValueStore(KeyValueStore): else: return False + MongoUsage = namedtuple('MongoUsage', 'id, def_id') @@ -108,7 +109,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem): references to metadata_inheritance_tree """ def __init__(self, modulestore, module_data, default_class, resources_fs, - error_tracker, render_template, metadata_cache = None): + error_tracker, render_template, metadata_cache=None): """ modulestore: the module store that can be used to retrieve additional modules @@ -136,6 +137,9 @@ class CachingDescriptorSystem(MakoDescriptorSystem): self.metadata_cache = metadata_cache def load_item(self, location): + """ + Return an XModule instance for the specified location + """ location = Location(location) json_data = self.module_data.get(location) if json_data is None: @@ -197,12 +201,12 @@ def location_to_query(location, wildcard=True): return query -def namedtuple_to_son(namedtuple, prefix=''): +def namedtuple_to_son(ntuple, prefix=''): """ Converts a namedtuple into a SON object with the same key order """ son = SON() - for idx, field_name in enumerate(namedtuple._fields): + for idx, field_name in enumerate(ntuple._fields): son[prefix + field_name] = namedtuple[idx] return son @@ -232,7 +236,6 @@ class MongoModuleStore(ModuleStoreBase): if user is not None and password is not None: self.collection.database.authenticate(user, password) - # Force mongo to report errors, at the expense of performance self.collection.safe = True @@ -262,7 +265,7 @@ class MongoModuleStore(ModuleStoreBase): query = { '_id.org': location.org, '_id.course': location.course, - '_id.category': {'$in': [ 'course', 'chapter', 'sequential', 'vertical']} + '_id.category': {'$in': ['course', 'chapter', 'sequential', 'vertical']} } # we just want the Location, children, and metadata record_filter = {'_id': 1, 'definition.children': 1, 'metadata': 1} @@ -284,6 +287,9 @@ class MongoModuleStore(ModuleStoreBase): metadata_to_inherit = {} def _compute_inherited_metadata(url): + """ + Helper method for computing inherited metadata for a specific location url + """ my_metadata = {} # check for presence of metadata key. Note that a given module may not yet be fully formed. # example: update_item -> update_children -> update_metadata sequence on new item create @@ -325,12 +331,14 @@ class MongoModuleStore(ModuleStoreBase): trees = self.metadata_inheritance_cache.get_many(list(set([metadata_cache_key(loc) for loc in locations]))) else: # This is to help guard against an accident prod runtime without a cache - logging.warning('Running MongoModuleStore without metadata_inheritance_cache. This should not happen in production!') + logging.warning('Running MongoModuleStore without metadata_inheritance_cache. ' + 'This should not happen in production!') to_cache = {} for loc in locations: - if metadata_cache_key(loc) not in trees: - to_cache[metadata_cache_key(loc)] = trees[metadata_cache_key(loc)] = self.get_metadata_inheritance_tree(loc) + cache_key = metadata_cache_key(loc) + if cache_key not in trees: + to_cache[cache_key] = trees[cache_key] = self.get_metadata_inheritance_tree(loc) if to_cache and self.metadata_inheritance_cache is not None: self.metadata_inheritance_cache.set_many(to_cache) @@ -338,11 +346,19 @@ class MongoModuleStore(ModuleStoreBase): return trees def refresh_cached_metadata_inheritance_tree(self, location): + """ + Refresh the cached metadata inheritance tree for the org/course combination + for location + """ pseudo_course_id = '/'.join([location.org, location.course]) if pseudo_course_id not in self.ignore_write_events_on_courses: - self.get_cached_metadata_inheritance_trees([location], force_refresh=True) + self.get_cached_metadata_inheritance_trees([location], force_refresh=True) def clear_cached_metadata_inheritance_tree(self, location): + """ + Delete the cached metadata inheritance tree for the org/course combination + for location + """ if self.metadata_inheritance_cache is not None: self.metadata_inheritance_cache.delete(metadata_cache_key(location)) @@ -372,7 +388,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 @@ -413,8 +429,6 @@ class MongoModuleStore(ModuleStoreBase): resource_fs = OSFS(root) - metadata_inheritance_tree = None - # TODO (cdodge): When the 'split module store' work has been completed, we should remove # the 'metadata_inheritance_tree' parameter system = CachingDescriptorSystem( @@ -572,7 +586,8 @@ class MongoModuleStore(ModuleStoreBase): raise Exception('Could not find course at {0}'.format(course_search_location)) if found_cnt > 1: - raise Exception('Found more than one course at {0}. There should only be one!!! Dump = {1}'.format(course_search_location, courses)) + raise Exception('Found more than one course at {0}. There should only be one!!! ' + 'Dump = {1}'.format(course_search_location, courses)) return courses[0] @@ -688,4 +703,7 @@ class MongoModuleStore(ModuleStoreBase): # DraftModuleStore is first, because it needs to intercept calls to MongoModuleStore class DraftMongoModuleStore(DraftModuleStore, MongoModuleStore): + """ + Version of MongoModuleStore with draft capability mixed in + """ pass From e0343342b0d20f87818ec74535a0f96e8e91c70c Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 26 Mar 2013 16:36:58 -0400 Subject: [PATCH 036/128] Fix typo during pylint fixes --- common/lib/xmodule/xmodule/modulestore/mongo.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/modulestore/mongo.py b/common/lib/xmodule/xmodule/modulestore/mongo.py index 47049b9fd6..fdc34913ee 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo.py @@ -207,7 +207,7 @@ def namedtuple_to_son(ntuple, prefix=''): """ son = SON() for idx, field_name in enumerate(ntuple._fields): - son[prefix + field_name] = namedtuple[idx] + son[prefix + field_name] = ntuple[idx] return son @@ -703,6 +703,9 @@ class MongoModuleStore(ModuleStoreBase): # DraftModuleStore is first, because it needs to intercept calls to MongoModuleStore class DraftMongoModuleStore(DraftModuleStore, MongoModuleStore): + """ + Version of MongoModuleStore with draft capability mixed in + """ """ Version of MongoModuleStore with draft capability mixed in """ From 7a238935578c958f5cbba47554466825049ce8aa Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Tue, 26 Mar 2013 16:40:28 -0400 Subject: [PATCH 037/128] 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 b975d4d90ce1a5e3fc79ab18c6eec96b2052bea3 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 26 Mar 2013 16:43:58 -0400 Subject: [PATCH 038/128] Fix tests --- cms/djangoapps/contentstore/tests/test_contentstore.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index edb20561bc..e6b5933d66 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -211,7 +211,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): new_loc = descriptor.location._replace(org='MITx', course='999') print "Checking {0} should now also be at {1}".format(descriptor.location.url(), new_loc.url()) resp = self.client.get(reverse('edit_unit', kwargs={'location': new_loc.url()})) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 200) def test_delete_course(self): import_from_xml(modulestore(), 'common/test/data/', ['full']) @@ -328,11 +328,11 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertEqual(wrapper.counter, 4) # make sure we pre-fetched a known sequential which should be at depth=2 - self.assertTrue(Location(['i4x', 'edX', 'full', 'sequential', + self.assertTrue(Location(['i4x', 'edX', 'full', 'sequential', 'Administrivia_and_Circuit_Elements', None]) in course.system.module_data) # make sure we don't have a specific vertical which should be at depth=3 - self.assertFalse(Location(['i4x', 'edX', 'full', 'vertical', 'vertical_58', + self.assertFalse(Location(['i4x', 'edX', 'full', 'vertical', 'vertical_58', None]) in course.system.module_data) def test_export_course_with_unknown_metadata(self): @@ -556,7 +556,7 @@ class ContentStoreTest(ModuleStoreTestCase): module_store.update_children(parent.location, parent.children + [new_component_location.url()]) # flush the cache - module_store.get_cached_metadata_inheritance_tree(new_component_location, -1) + module_store.refresh_cached_metadata_inheritance_tree(new_component_location) new_module = module_store.get_item(new_component_location) # check for grace period definition which should be defined at the course level @@ -571,7 +571,7 @@ class ContentStoreTest(ModuleStoreTestCase): module_store.update_metadata(new_module.location, own_metadata(new_module)) # flush the cache and refetch - module_store.get_cached_metadata_inheritance_tree(new_component_location, -1) + module_store.refresh_cached_metadata_inheritance_tree(new_component_location) new_module = module_store.get_item(new_component_location) self.assertEqual(timedelta(1), new_module.lms.graceperiod) From a44ecdfcd6e01ea6f38b82f6c9348a955808c87f Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Tue, 26 Mar 2013 16:45:47 -0400 Subject: [PATCH 039/128] if we parse an invalid location in the content store middleware, then return a 404, not a 500 --- common/djangoapps/contentserver/middleware.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/common/djangoapps/contentserver/middleware.py b/common/djangoapps/contentserver/middleware.py index c5e887801e..e8674a1e9e 100644 --- a/common/djangoapps/contentserver/middleware.py +++ b/common/djangoapps/contentserver/middleware.py @@ -5,6 +5,7 @@ from django.http import HttpResponse, Http404, HttpResponseNotModified from xmodule.contentstore.django import contentstore from xmodule.contentstore.content import StaticContent, XASSET_LOCATION_TAG +from xmodule.modulestore import InvalidLocationError from cache_toolbox.core import get_cached_content, set_cached_content from xmodule.exceptions import NotFoundError @@ -13,7 +14,13 @@ class StaticContentServer(object): def process_request(self, request): # look to see if the request is prefixed with 'c4x' tag if request.path.startswith('/' + XASSET_LOCATION_TAG + '/'): - loc = StaticContent.get_location_from_path(request.path) + try: + loc = StaticContent.get_location_from_path(request.path) + except InvalidLocationError: + response = HttpResponse() + response.status_code = 404 + return response + # first look in our cache so we don't have to round-trip to the DB content = get_cached_content(loc) if content is None: From b0e2c82ad3619bea30674562e347cd76b9856de4 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Tue, 26 Mar 2013 20:02:29 -0400 Subject: [PATCH 040/128] actually.. return a 400 rather than a 404 because the request is malformed. Also add unit test. --- cms/djangoapps/contentstore/tests/test_contentstore.py | 4 ++++ common/djangoapps/contentserver/middleware.py | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index edb20561bc..a8cde71379 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -213,6 +213,10 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): resp = self.client.get(reverse('edit_unit', kwargs={'location': new_loc.url()})) self.assertEqual(resp.status_code, 200) + def test_bad_contentstore_request(self): + resp = self.client.get('http://localhost:8001/c4x/CDX/123123/asset/&images_circuits_Lab7Solution2.png') + self.assertEqual(resp.status_code, 400) + def test_delete_course(self): import_from_xml(modulestore(), 'common/test/data/', ['full']) diff --git a/common/djangoapps/contentserver/middleware.py b/common/djangoapps/contentserver/middleware.py index e8674a1e9e..8e9e70046d 100644 --- a/common/djangoapps/contentserver/middleware.py +++ b/common/djangoapps/contentserver/middleware.py @@ -17,8 +17,9 @@ class StaticContentServer(object): try: loc = StaticContent.get_location_from_path(request.path) except InvalidLocationError: + # return a 'Bad Request' to browser as we have a malformed Location response = HttpResponse() - response.status_code = 404 + response.status_code = 400 return response # first look in our cache so we don't have to round-trip to the DB From 195fd2d1fe3b591e8bd5380707272a170a3b000d Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Tue, 26 Mar 2013 23:48:06 -0400 Subject: [PATCH 041/128] optimize the result-set that gets returned from Mongo on metadata inheritence. We just need the fields which are actually inheritable, so no need to return anything else as it gets filtered out during the computation --- common/lib/xmodule/xmodule/modulestore/mongo.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/mongo.py b/common/lib/xmodule/xmodule/modulestore/mongo.py index fdc34913ee..38b15ab76e 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo.py @@ -267,8 +267,13 @@ class MongoModuleStore(ModuleStoreBase): '_id.course': location.course, '_id.category': {'$in': ['course', 'chapter', 'sequential', 'vertical']} } - # we just want the Location, children, and metadata - record_filter = {'_id': 1, 'definition.children': 1, 'metadata': 1} + # we just want the Location, children, and inheritable metadata + record_filter = {'_id': 1, 'definition.children': 1} + + # just get the inheritable metadata since that is all we need for the computation + # this minimizes both data pushed over the wire + for attr in INHERITABLE_METADATA: + record_filter['metadata.{0}'.format(attr)] = 1 # call out to the DB resultset = self.collection.find(query, record_filter) From 2120481738489d872db41916b75b0336a77a3a9e Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Wed, 27 Mar 2013 01:34:25 -0400 Subject: [PATCH 042/128] studio - corrected JQ selector for smoothscrolling in-page links --- cms/static/js/base.js | 4 ++-- cms/templates/howitworks.html | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cms/static/js/base.js b/cms/static/js/base.js index bd8dc0bae8..7466233331 100644 --- a/cms/static/js/base.js +++ b/cms/static/js/base.js @@ -81,7 +81,7 @@ $(document).ready(function () { }); // general link management - smooth scrolling page links - $('a[rel*="view"]').bind('click', linkSmoothScroll); + $('a[rel*="view"][href^="#"]').bind('click', smoothScrollLink); // toggling overview section details @@ -148,7 +148,7 @@ $(document).ready(function () { }); }); -function linkSmoothScroll(e) { +function smoothScrollLink(e) { (e).preventDefault(); $.smoothScroll({ diff --git a/cms/templates/howitworks.html b/cms/templates/howitworks.html index 1cf9b17710..7a819fceba 100644 --- a/cms/templates/howitworks.html +++ b/cms/templates/howitworks.html @@ -151,7 +151,7 @@
    Simple two-level outline to organize your couse. Drag and drop, and see your course at a glance.
    - + close modal @@ -164,7 +164,7 @@
    Quickly create videos, text snippets, inline discussions, and a variety of problem types.
    - + close modal @@ -177,7 +177,7 @@
    Simply set the date of a section or subsection, and Studio will publish it to your students for you.
    - + close modal From 2c0e5b82ff2535770a5ca605aa1b1bd521c756d4 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Wed, 27 Mar 2013 07:29:22 -0400 Subject: [PATCH 043/128] Return a 403 when an anonymous user attempts to hit modx_dispatch. Fixes https://www.pivotaltracker.com/story/show/46916015 and https://www.pivotaltracker.com/story/show/46916029 --- lms/djangoapps/courseware/module_render.py | 4 +++ .../courseware/tests/test_module_render.py | 31 +++++++++---------- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 973940d784..4747f7b341 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -8,6 +8,7 @@ from functools import partial from django.conf import settings from django.contrib.auth.models import User +from django.core.exceptions import PermissionDenied from django.core.urlresolvers import reverse from django.http import Http404 from django.http import HttpResponse @@ -412,6 +413,9 @@ def modx_dispatch(request, dispatch, location, course_id): if not Location.is_valid(location): raise Http404("Invalid location") + if not request.user.is_authenticated(): + raise PermissionDenied + # Check for submitted files and basic file size checks p = request.POST.copy() if request.FILES: diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py index 3a3a7ac5ea..90ca796a2f 100644 --- a/lms/djangoapps/courseware/tests/test_module_render.py +++ b/lms/djangoapps/courseware/tests/test_module_render.py @@ -1,14 +1,7 @@ -import logging -from mock import MagicMock, patch +from mock import MagicMock import json -import factory -import unittest -from nose.tools import set_trace -from django.http import Http404, HttpResponse, HttpRequest -from django.conf import settings -from django.contrib.auth.models import User -from django.test.client import Client +from django.http import Http404, HttpResponse from django.conf import settings from django.test import TestCase from django.test.client import RequestFactory @@ -16,13 +9,9 @@ from django.core.urlresolvers import reverse from django.test.utils import override_settings from xmodule.modulestore.exceptions import ItemNotFoundError -from xmodule.exceptions import NotFoundError -from xmodule.modulestore import Location import courseware.module_render as render -from xmodule.modulestore.django import modulestore, _MODULESTORES -from xmodule.seq_module import SequenceModule +from xmodule.modulestore.django import modulestore from courseware.tests.tests import PageLoader -from student.models import Registration from courseware.model_data import ModelDataCache from .factories import UserFactory @@ -52,7 +41,6 @@ TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR) class ModuleRenderTestCase(PageLoader): def setUp(self): self.location = ['i4x', 'edX', 'toy', 'chapter', 'Overview'] - self._MODULESTORES = {} self.course_id = 'edX/toy/2012_Fall' self.toy_course = modulestore().get_course(self.course_id) @@ -104,12 +92,23 @@ class ModuleRenderTestCase(PageLoader): self.assertEquals(render.get_score_bucket(11, 10), 'incorrect') self.assertEquals(render.get_score_bucket(-1, 10), 'incorrect') + def test_anonymous_modx_dispatch(self): + dispatch_url = reverse( + 'modx_dispatch', + args=[ + 'edX/toy/2012_Fall', + 'i4x://edX/toy/videosequence/Toy_Videos', + 'goto_position' + ] + ) + response = self.client.post(dispatch_url, {'position': 2}) + self.assertEquals(403, response.status_code) + @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) class TestTOC(TestCase): """Check the Table of Contents for a course""" def setUp(self): - self._MODULESTORES = {} # Toy courses should be loaded self.course_name = 'edX/toy/2012_Fall' From 521843876efb005303e8ff7423442eb9830ab99e Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Wed, 27 Mar 2013 08:10:25 -0400 Subject: [PATCH 044/128] Make the django_comment_client return errors that can't be parsed as JSON just as simple strings when in an ajax context --- .../django_comment_client/middleware.py | 16 +++++++++- .../tests/test_middleware.py | 32 +++++++++---------- 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/lms/djangoapps/django_comment_client/middleware.py b/lms/djangoapps/django_comment_client/middleware.py index abf2d40cab..b9efc1589e 100644 --- a/lms/djangoapps/django_comment_client/middleware.py +++ b/lms/djangoapps/django_comment_client/middleware.py @@ -1,10 +1,24 @@ from comment_client import CommentClientError from django_comment_client.utils import JsonError import json +import logging + +log = logging.getLogger(__name__) class AjaxExceptionMiddleware(object): + """ + Middleware that captures CommentClientErrors during ajax requests + and tranforms them into json responses + """ def process_exception(self, request, exception): + """ + Processes CommentClientErrors in ajax requests. If the request is an ajax request, + returns a http response that encodes the error as json + """ if isinstance(exception, CommentClientError) and request.is_ajax(): - return JsonError(json.loads(exception.message)) + try: + return JsonError(json.loads(exception.message)) + except ValueError: + return JsonError(exception.message) return None diff --git a/lms/djangoapps/django_comment_client/tests/test_middleware.py b/lms/djangoapps/django_comment_client/tests/test_middleware.py index 55e4c72c75..ab9517c160 100644 --- a/lms/djangoapps/django_comment_client/tests/test_middleware.py +++ b/lms/djangoapps/django_comment_client/tests/test_middleware.py @@ -1,7 +1,3 @@ -import string -import random -import collections - from django.test import TestCase import comment_client @@ -13,17 +9,19 @@ class AjaxExceptionTestCase(TestCase): # TODO: check whether the correct error message is produced. # The error message should be the same as the argument to CommentClientError - def setUp(self): - self.a = middleware.AjaxExceptionMiddleware() - self.request1 = django.http.HttpRequest() - self.request0 = django.http.HttpRequest() - self.exception1 = comment_client.CommentClientError('{}') - self.exception0 = ValueError() - self.request1.META['HTTP_X_REQUESTED_WITH'] = "XMLHttpRequest" - self.request0.META['HTTP_X_REQUESTED_WITH'] = "SHADOWFAX" + def setUp(self): + self.a = middleware.AjaxExceptionMiddleware() + self.request1 = django.http.HttpRequest() + self.request0 = django.http.HttpRequest() + self.exception1 = comment_client.CommentClientError('{}') + self.exception2 = comment_client.CommentClientError('Foo!') + self.exception0 = ValueError() + self.request1.META['HTTP_X_REQUESTED_WITH'] = "XMLHttpRequest" + self.request0.META['HTTP_X_REQUESTED_WITH'] = "SHADOWFAX" - def test_process_exception(self): - self.assertIsInstance(self.a.process_exception(self.request1, self.exception1), middleware.JsonError) - self.assertIsNone(self.a.process_exception(self.request1, self.exception0)) - self.assertIsNone(self.a.process_exception(self.request0, self.exception1)) - self.assertIsNone(self.a.process_exception(self.request0, self.exception0)) + def test_process_exception(self): + self.assertIsInstance(self.a.process_exception(self.request1, self.exception1), middleware.JsonError) + self.assertIsInstance(self.a.process_exception(self.request1, self.exception2), middleware.JsonError) + self.assertIsNone(self.a.process_exception(self.request1, self.exception0)) + self.assertIsNone(self.a.process_exception(self.request0, self.exception1)) + self.assertIsNone(self.a.process_exception(self.request0, self.exception0)) From 285e3ee1edfbb5cbb22f821b05b7a752aab25c73 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Wed, 27 Mar 2013 10:49:47 -0400 Subject: [PATCH 045/128] 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 046/128] 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 047/128] 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 048/128] 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 049/128] 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 7101c76016e4c42b18ba5858556b836da6fde66b Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Wed, 27 Mar 2013 12:02:32 -0400 Subject: [PATCH 050/128] comment on rewrite links change --- .../combined_open_ended_modulev1.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 f88fd9ab82..6fe37b9525 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 @@ -365,6 +365,10 @@ class CombinedOpenEndedV1Module(): html = self.current_task.get_html(self.system) return_html = html try: + #Without try except block, get this error: + # File "/home/vik/mitx_all/mitx/common/lib/xmodule/xmodule/x_module.py", line 263, in rewrite_content_links + # if link.startswith(XASSET_SRCREF_PREFIX): + # Placing try except so that if the error is fixed, this code will start working again. return_html = rewrite_links(html, self.rewrite_content_links) except: pass @@ -786,7 +790,7 @@ class CombinedOpenEndedV1Descriptor(): template_dir_name = "combinedopenended" def __init__(self, system): - self.system =system + self.system = system @classmethod def definition_from_xml(cls, xml_object, system): From 15ea32b095abe4d033075640121ad418ced0179d Mon Sep 17 00:00:00 2001 From: Will Daly Date: Wed, 27 Mar 2013 12:53:58 -0400 Subject: [PATCH 051/128] Fixed bug 294, caused by unicode encoding error when creating logging strings. Added unit tests that verify the fix. --- common/djangoapps/student/tests/test_login.py | 107 ++++++++++++++++++ common/djangoapps/student/views.py | 8 +- 2 files changed, 111 insertions(+), 4 deletions(-) create mode 100644 common/djangoapps/student/tests/test_login.py diff --git a/common/djangoapps/student/tests/test_login.py b/common/djangoapps/student/tests/test_login.py new file mode 100644 index 0000000000..dda58a4462 --- /dev/null +++ b/common/djangoapps/student/tests/test_login.py @@ -0,0 +1,107 @@ +from django.test import TestCase +from django.test.client import Client +from django.core.urlresolvers import reverse +from django.contrib.auth.models import User +from student.models import Registration, UserProfile +import json + +class LoginTest(TestCase): + ''' + Test student.views.login_user() view + ''' + + def setUp(self): + + # Create one user and save it to the database + self.user = User.objects.create_user('test', 'test@edx.org', 'test_password') + self.user.is_active = True + self.user.save() + + # Create a registration for the user + Registration().register(self.user) + + # Create a profile for the user + UserProfile(user=self.user).save() + + # Create the test client + self.client = Client() + + # Store the login url + self.url = reverse('login') + + def test_login_success(self): + response = self._login_response('test@edx.org', 'test_password') + self._assert_response(response, success=True) + + def test_login_success_unicode_email(self): + unicode_email = u'test@edx.org' + unichr(40960) + + self.user.email = unicode_email + self.user.save() + + response = self._login_response(unicode_email, 'test_password') + self._assert_response(response, success=True) + + + def test_login_fail_no_user_exists(self): + response = self._login_response('not_a_user@edx.org', 'test_password') + self._assert_response(response, success=False, + value='Email or password is incorrect') + + def test_login_fail_wrong_password(self): + response = self._login_response('test@edx.org', 'wrong_password') + self._assert_response(response, success=False, + value='Email or password is incorrect') + + def test_login_not_activated(self): + + # De-activate the user + self.user.is_active = False + self.user.save() + + # Should now be unable to login + response = self._login_response('test@edx.org', 'test_password') + self._assert_response(response, success=False, + value="This account has not been activated") + + + def test_login_unicode_email(self): + unicode_email = u'test@edx.org' + unichr(40960) + response = self._login_response(unicode_email, 'test_password') + self._assert_response(response, success=False) + + def test_login_unicode_password(self): + unicode_password = u'test_password' + unichr(1972) + response = self._login_response('test@edx.org', unicode_password) + self._assert_response(response, success=False) + + def _login_response(self, email, password): + post_params = {'email': email, 'password': password} + return self.client.post(self.url, post_params) + + def _assert_response(self, response, success=None, value=None): + ''' + Assert that the response had status 200 and returned a valid + JSON-parseable dict. + + If success is provided, assert that the response had that + value for 'success' in the JSON dict. + + If value is provided, assert that the response contained that + value for 'value' in the JSON dict. + ''' + self.assertEqual(response.status_code, 200) + + try: + response_dict = json.loads(response.content) + except ValueError: + self.fail("Could not parse response content as JSON: %s" + % str(response.content)) + + if success is not None: + self.assertEqual(response_dict['success'], success) + + if value is not None: + msg = ("'%s' did not contain '%s'" % + (str(response_dict['value']), str(value))) + self.assertTrue(value in response_dict['value'], msg) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 5dbaf5d2c2..84730421e8 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -369,14 +369,14 @@ def login_user(request, error=""): try: user = User.objects.get(email=email) except User.DoesNotExist: - log.warning("Login failed - Unknown user email: {0}".format(email)) + log.warning(u"Login failed - Unknown user email: {0}".format(email)) return HttpResponse(json.dumps({'success': False, 'value': 'Email or password is incorrect.'})) # TODO: User error message username = user.username user = authenticate(username=username, password=password) if user is None: - log.warning("Login failed - password for {0} is invalid".format(email)) + log.warning(u"Login failed - password for {0} is invalid".format(email)) return HttpResponse(json.dumps({'success': False, 'value': 'Email or password is incorrect.'})) @@ -392,7 +392,7 @@ def login_user(request, error=""): log.critical("Login failed - Could not create session. Is memcached running?") log.exception(e) - log.info("Login success - {0} ({1})".format(username, email)) + log.info(u"Login success - {0} ({1})".format(username, email)) try_change_enrollment(request) @@ -400,7 +400,7 @@ def login_user(request, error=""): return HttpResponse(json.dumps({'success': True})) - log.warning("Login failed - Account not active for user {0}, resending activation".format(username)) + log.warning(u"Login failed - Account not active for user {0}, resending activation".format(username)) reactivation_email_for_user(user) not_activated_msg = "This account has not been activated. We have " + \ From 227a5e8266ddc72e9719eb2b6035a12ee0788c56 Mon Sep 17 00:00:00 2001 From: cahrens Date: Wed, 27 Mar 2013 12:56:06 -0400 Subject: [PATCH 052/128] Delete converters, move unit tests to test_fields, add new additional test cases. --- .../tests/test_course_settings.py | 75 +++-------------- .../models/settings/course_details.py | 28 +++++-- .../models/settings/course_grading.py | 2 - common/djangoapps/util/converters.py | 37 --------- common/lib/xmodule/xmodule/fields.py | 1 - .../lib/xmodule/xmodule/tests/test_fields.py | 80 +++++++++++++++++++ 6 files changed, 113 insertions(+), 110 deletions(-) delete mode 100644 common/djangoapps/util/converters.py create mode 100644 common/lib/xmodule/xmodule/tests/test_fields.py diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index 2e7bc5db83..fe90ad18aa 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -1,8 +1,6 @@ import datetime import json import copy -from util import converters -from util.converters import jsdate_to_time from django.contrib.auth.models import User from django.test.client import Client @@ -15,69 +13,13 @@ from models.settings.course_details import (CourseDetails, from models.settings.course_grading import CourseGradingModel from contentstore.utils import get_modulestore -from django.test import TestCase from .utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory from models.settings.course_metadata import CourseMetadata from xmodule.modulestore.xml_importer import import_from_xml from xmodule.modulestore.django import modulestore -import time - - -# YYYY-MM-DDThh:mm:ss.s+/-HH:MM -class ConvertersTestCase(TestCase): - @staticmethod - def struct_to_datetime(struct_time): - return datetime.datetime(struct_time.tm_year, struct_time.tm_mon, - struct_time.tm_mday, struct_time.tm_hour, - struct_time.tm_min, struct_time.tm_sec, tzinfo=UTC()) - - def compare_dates(self, date1, date2, expected_delta): - dt1 = ConvertersTestCase.struct_to_datetime(date1) - dt2 = ConvertersTestCase.struct_to_datetime(date2) - self.assertEqual(dt1 - dt2, expected_delta, str(date1) + "-" - + str(date2) + "!=" + str(expected_delta)) - - def test_iso_to_struct(self): - '''Test conversion from iso compatible date strings to struct_time''' - self.compare_dates(converters.jsdate_to_time("2013-01-01"), - converters.jsdate_to_time("2012-12-31"), - datetime.timedelta(days=1)) - self.compare_dates(converters.jsdate_to_time("2013-01-01T00"), - converters.jsdate_to_time("2012-12-31T23"), - datetime.timedelta(hours=1)) - self.compare_dates(converters.jsdate_to_time("2013-01-01T00:00"), - converters.jsdate_to_time("2012-12-31T23:59"), - datetime.timedelta(minutes=1)) - self.compare_dates(converters.jsdate_to_time("2013-01-01T00:00:00"), - converters.jsdate_to_time("2012-12-31T23:59:59"), - datetime.timedelta(seconds=1)) - self.compare_dates(converters.jsdate_to_time("2013-01-01T00:00:00Z"), - converters.jsdate_to_time("2012-12-31T23:59:59Z"), - datetime.timedelta(seconds=1)) - self.compare_dates( - converters.jsdate_to_time("2012-12-31T23:00:01-01:00"), - converters.jsdate_to_time("2013-01-01T00:00:00+01:00"), - datetime.timedelta(hours=1, seconds=1)) - - def test_struct_to_iso(self): - ''' - Test converting time reprs to iso dates - ''' - self.assertEqual( - converters.time_to_isodate( - time.strptime("2012-12-31T23:59:59Z", "%Y-%m-%dT%H:%M:%SZ")), - "2012-12-31T23:59:59Z") - self.assertEqual( - converters.time_to_isodate( - jsdate_to_time("2012-12-31T23:59:59Z")), - "2012-12-31T23:59:59Z") - self.assertEqual( - converters.time_to_isodate( - jsdate_to_time("2012-12-31T23:00:01-01:00")), - "2013-01-01T00:00:01Z") - +from xmodule.fields import Date class CourseTestCase(ModuleStoreTestCase): def setUp(self): @@ -206,17 +148,24 @@ class CourseDetailsViewTest(CourseTestCase): self.assertEqual(details['intro_video'], encoded.get('intro_video', None), context + " intro_video not ==") self.assertEqual(details['effort'], encoded['effort'], context + " efforts not ==") + @staticmethod + def struct_to_datetime(struct_time): + return datetime.datetime(struct_time.tm_year, struct_time.tm_mon, + struct_time.tm_mday, struct_time.tm_hour, + struct_time.tm_min, struct_time.tm_sec, tzinfo=UTC()) + def compare_date_fields(self, details, encoded, context, field): if details[field] is not None: + date = Date() if field in encoded and encoded[field] is not None: - encoded_encoded = jsdate_to_time(encoded[field]) - dt1 = ConvertersTestCase.struct_to_datetime(encoded_encoded) + encoded_encoded = date.from_json(encoded[field]) + dt1 = CourseDetailsViewTest.struct_to_datetime(encoded_encoded) if isinstance(details[field], datetime.datetime): dt2 = details[field] else: - details_encoded = jsdate_to_time(details[field]) - dt2 = ConvertersTestCase.struct_to_datetime(details_encoded) + details_encoded = date.from_json(details[field]) + dt2 = CourseDetailsViewTest.struct_to_datetime(details_encoded) expected_delta = datetime.timedelta(0) self.assertEqual(dt1 - dt2, expected_delta, str(dt1) + "!=" + str(dt2) + " at " + context) diff --git a/cms/djangoapps/models/settings/course_details.py b/cms/djangoapps/models/settings/course_details.py index d3cd5fe164..09d57774ab 100644 --- a/cms/djangoapps/models/settings/course_details.py +++ b/cms/djangoapps/models/settings/course_details.py @@ -1,14 +1,14 @@ -from xmodule.modulestore.django import modulestore from xmodule.modulestore import Location from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.inheritance import own_metadata import json from json.encoder import JSONEncoder import time +import calendar from contentstore.utils import get_modulestore -from util.converters import jsdate_to_time, time_to_date from models.settings import course_grading from contentstore.utils import update_item +from xmodule.fields import Date import re import logging @@ -81,8 +81,14 @@ class CourseDetails(object): dirty = False + # In the descriptor's setter, the date is converted to JSON using Date's to_json method. + # Calling to_json on something that is already JSON doesn't work. Since reaching directly + # into the model is nasty, convert the JSON Date to a Python date, which is what the + # setter expects as input. + date = Date() + if 'start_date' in jsondict: - converted = jsdate_to_time(jsondict['start_date']) + converted = date.from_json(jsondict['start_date']) else: converted = None if converted != descriptor.start: @@ -90,7 +96,7 @@ class CourseDetails(object): descriptor.start = converted if 'end_date' in jsondict: - converted = jsdate_to_time(jsondict['end_date']) + converted = date.from_json(jsondict['end_date']) else: converted = None @@ -99,7 +105,7 @@ class CourseDetails(object): descriptor.end = converted if 'enrollment_start' in jsondict: - converted = jsdate_to_time(jsondict['enrollment_start']) + converted = date.from_json(jsondict['enrollment_start']) else: converted = None @@ -108,7 +114,7 @@ class CourseDetails(object): descriptor.enrollment_start = converted if 'enrollment_end' in jsondict: - converted = jsdate_to_time(jsondict['enrollment_end']) + converted = date.from_json(jsondict['enrollment_end']) else: converted = None @@ -172,12 +178,20 @@ class CourseDetails(object): # TODO move to a more general util? Is there a better way to do the isinstance model check? class CourseSettingsEncoder(json.JSONEncoder): + @staticmethod + def time_to_date(time_obj): + """ + Convert a time.time_struct to a true universal time (can pass to js Date + constructor) + """ + return calendar.timegm(time_obj) * 1000 + def default(self, obj): if isinstance(obj, CourseDetails) or isinstance(obj, course_grading.CourseGradingModel): return obj.__dict__ elif isinstance(obj, Location): return obj.dict() elif isinstance(obj, time.struct_time): - return time_to_date(obj) + return CourseSettingsEncoder.time_to_date(obj) else: return JSONEncoder.default(self, obj) diff --git a/cms/djangoapps/models/settings/course_grading.py b/cms/djangoapps/models/settings/course_grading.py index b20fb71f66..ee9b4ac0eb 100644 --- a/cms/djangoapps/models/settings/course_grading.py +++ b/cms/djangoapps/models/settings/course_grading.py @@ -1,7 +1,5 @@ from xmodule.modulestore import Location from contentstore.utils import get_modulestore -import re -from util import converters from datetime import timedelta diff --git a/common/djangoapps/util/converters.py b/common/djangoapps/util/converters.py deleted file mode 100644 index 212cceb77d..0000000000 --- a/common/djangoapps/util/converters.py +++ /dev/null @@ -1,37 +0,0 @@ -import time -import datetime -import calendar -import dateutil.parser - - -def time_to_date(time_obj): - """ - Convert a time.time_struct to a true universal time (can pass to js Date - constructor) - """ - return calendar.timegm(time_obj) * 1000 - - -def time_to_isodate(source): - '''Convert to an iso date''' - if isinstance(source, time.struct_time): - return time.strftime('%Y-%m-%dT%H:%M:%SZ', source) - elif isinstance(source, datetime): - return source.isoformat() + 'Z' - - -def jsdate_to_time(field): - """ - Convert a universal time (iso format) or msec since epoch to a time obj - """ - if field is None: - return field - elif isinstance(field, basestring): - d = dateutil.parser.parse(field) - return d.utctimetuple() - elif isinstance(field, (int, long, float)): - return time.gmtime(field / 1000) - elif isinstance(field, time.struct_time): - return field - else: - raise ValueError("Couldn't convert %r to time" % field) diff --git a/common/lib/xmodule/xmodule/fields.py b/common/lib/xmodule/xmodule/fields.py index 0abe850d68..ea857933fc 100644 --- a/common/lib/xmodule/xmodule/fields.py +++ b/common/lib/xmodule/xmodule/fields.py @@ -14,7 +14,6 @@ class Date(ModelType): ''' Date fields know how to parse and produce json (iso) compatible formats. ''' - # NB: these are copies of util.converters.* def from_json(self, field): """ Parse an optional metadata key containing a time: if present, complain diff --git a/common/lib/xmodule/xmodule/tests/test_fields.py b/common/lib/xmodule/xmodule/tests/test_fields.py new file mode 100644 index 0000000000..7c8872efc1 --- /dev/null +++ b/common/lib/xmodule/xmodule/tests/test_fields.py @@ -0,0 +1,80 @@ +"""Tests for Date class defined in fields.py.""" +import datetime +import unittest +from django.utils.timezone import UTC +from xmodule.fields import Date +import time + +class DateTest(unittest.TestCase): + date = Date() + + @staticmethod + def struct_to_datetime(struct_time): + return datetime.datetime(struct_time.tm_year, struct_time.tm_mon, + struct_time.tm_mday, struct_time.tm_hour, + struct_time.tm_min, struct_time.tm_sec, tzinfo=UTC()) + + def compare_dates(self, date1, date2, expected_delta): + dt1 = DateTest.struct_to_datetime(date1) + dt2 = DateTest.struct_to_datetime(date2) + self.assertEqual(dt1 - dt2, expected_delta, str(date1) + "-" + + str(date2) + "!=" + str(expected_delta)) + + def test_from_json(self): + '''Test conversion from iso compatible date strings to struct_time''' + self.compare_dates( + DateTest.date.from_json("2013-01-01"), + DateTest.date.from_json("2012-12-31"), + datetime.timedelta(days=1)) + self.compare_dates( + DateTest.date.from_json("2013-01-01T00"), + DateTest.date.from_json("2012-12-31T23"), + datetime.timedelta(hours=1)) + self.compare_dates( + DateTest.date.from_json("2013-01-01T00:00"), + DateTest.date.from_json("2012-12-31T23:59"), + datetime.timedelta(minutes=1)) + self.compare_dates( + DateTest.date.from_json("2013-01-01T00:00:00"), + DateTest.date.from_json("2012-12-31T23:59:59"), + datetime.timedelta(seconds=1)) + self.compare_dates( + DateTest.date.from_json("2013-01-01T00:00:00Z"), + DateTest.date.from_json("2012-12-31T23:59:59Z"), + datetime.timedelta(seconds=1)) + self.compare_dates( + DateTest.date.from_json("2012-12-31T23:00:01-01:00"), + DateTest.date.from_json("2013-01-01T00:00:00+01:00"), + datetime.timedelta(hours=1, seconds=1)) + + def test_return_None(self): + self.assertIsNone(DateTest.date.from_json("")) + self.assertIsNone(DateTest.date.from_json(None)) + self.assertIsNone(DateTest.date.from_json(['unknown value'])) + + def test_old_due_date_format(self): + current = datetime.datetime.today() + self.assertEqual( + time.struct_time((current.year, 3, 12, 12, 0, 0, 1, 71, 0)), + DateTest.date.from_json("March 12 12:00")) + self.assertEqual( + time.struct_time((current.year, 12, 4, 16, 30, 0, 2, 338, 0)), + DateTest.date.from_json("December 4 16:30")) + + def test_to_json(self): + ''' + Test converting time reprs to iso dates + ''' + self.assertEqual( + DateTest.date.to_json( + time.strptime("2012-12-31T23:59:59Z", "%Y-%m-%dT%H:%M:%SZ")), + "2012-12-31T23:59:59Z") + self.assertEqual( + DateTest.date.to_json( + DateTest.date.from_json("2012-12-31T23:59:59Z")), + "2012-12-31T23:59:59Z") + self.assertEqual( + DateTest.date.to_json( + DateTest.date.from_json("2012-12-31T23:00:01-01:00")), + "2013-01-01T00:00:01Z") + From cddc868656d784da1db5585879c9518918b6a512 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Wed, 27 Mar 2013 13:01:10 -0400 Subject: [PATCH 053/128] Login URL resolves differently in LMS and CMS, which breaks login_test when loaded by rake test_cms I moved the test into lms/courseware/tests so they run correctly. --- .../student => lms/djangoapps/courseware}/tests/test_login.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {common/djangoapps/student => lms/djangoapps/courseware}/tests/test_login.py (100%) diff --git a/common/djangoapps/student/tests/test_login.py b/lms/djangoapps/courseware/tests/test_login.py similarity index 100% rename from common/djangoapps/student/tests/test_login.py rename to lms/djangoapps/courseware/tests/test_login.py From ac86687fa104d8c0c96ce3e73b7ad29f7baf5a91 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Wed, 27 Mar 2013 14:33:59 -0400 Subject: [PATCH 054/128] 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 055/128] 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 22537ffd3b05269b688972e7e2ad81e118cc1da7 Mon Sep 17 00:00:00 2001 From: cahrens Date: Wed, 27 Mar 2013 14:51:39 -0400 Subject: [PATCH 056/128] Don't need to convert to milliseconds. --- cms/djangoapps/models/settings/course_details.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/cms/djangoapps/models/settings/course_details.py b/cms/djangoapps/models/settings/course_details.py index 09d57774ab..b45f5bd343 100644 --- a/cms/djangoapps/models/settings/course_details.py +++ b/cms/djangoapps/models/settings/course_details.py @@ -178,20 +178,12 @@ class CourseDetails(object): # TODO move to a more general util? Is there a better way to do the isinstance model check? class CourseSettingsEncoder(json.JSONEncoder): - @staticmethod - def time_to_date(time_obj): - """ - Convert a time.time_struct to a true universal time (can pass to js Date - constructor) - """ - return calendar.timegm(time_obj) * 1000 - def default(self, obj): if isinstance(obj, CourseDetails) or isinstance(obj, course_grading.CourseGradingModel): return obj.__dict__ elif isinstance(obj, Location): return obj.dict() elif isinstance(obj, time.struct_time): - return CourseSettingsEncoder.time_to_date(obj) + return Date().to_json(obj) else: return JSONEncoder.default(self, obj) From 5c78218b1360bf9e0eb6bcee41cbc44e1aeb1dac Mon Sep 17 00:00:00 2001 From: cahrens Date: Wed, 27 Mar 2013 14:52:27 -0400 Subject: [PATCH 057/128] Don't need to convert to milliseconds. --- cms/djangoapps/models/settings/course_details.py | 1 - 1 file changed, 1 deletion(-) diff --git a/cms/djangoapps/models/settings/course_details.py b/cms/djangoapps/models/settings/course_details.py index b45f5bd343..876000c7fe 100644 --- a/cms/djangoapps/models/settings/course_details.py +++ b/cms/djangoapps/models/settings/course_details.py @@ -4,7 +4,6 @@ from xmodule.modulestore.inheritance import own_metadata import json from json.encoder import JSONEncoder import time -import calendar from contentstore.utils import get_modulestore from models.settings import course_grading from contentstore.utils import update_item From 122c8567c5d370a6e54e075d4e736c96bcfef646 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Wed, 27 Mar 2013 15:00:08 -0400 Subject: [PATCH 058/128] An integrity error while creating an enrollment just means that our work has already been done. Fixes https://www.pivotaltracker.com/story/show/46915947 --- common/djangoapps/student/views.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 5dbaf5d2c2..d0deffd7b9 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -325,7 +325,12 @@ def change_enrollment(request): "course:{0}".format(course_num), "run:{0}".format(run)]) - enrollment, created = CourseEnrollment.objects.get_or_create(user=user, course_id=course.id) + try: + enrollment, created = CourseEnrollment.objects.get_or_create(user=user, course_id=course.id) + except IntegrityError: + # If we've already created this enrollment in a separate transaction, + # then just continue + pass return {'success': True} elif action == "unenroll": From df1be877390c6869b766870c7d5e40bbfe258913 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Wed, 27 Mar 2013 15:20:40 -0400 Subject: [PATCH 059/128] * 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 6564cc57e6f7a2bddfab8a9dabbcc012687135a1 Mon Sep 17 00:00:00 2001 From: Jay Zoldak Date: Wed, 27 Mar 2013 16:29:55 -0400 Subject: [PATCH 060/128] Fix typo with hyphen in cms lettuce feature files --- .../contentstore/features/advanced-settings.feature | 6 +++--- .../features/studio-overview-togglesection.feature | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cms/djangoapps/contentstore/features/advanced-settings.feature b/cms/djangoapps/contentstore/features/advanced-settings.feature index 66039e19b1..db7294c14c 100644 --- a/cms/djangoapps/contentstore/features/advanced-settings.feature +++ b/cms/djangoapps/contentstore/features/advanced-settings.feature @@ -27,16 +27,16 @@ Feature: Advanced (manual) course policy And I reload the page Then the policy key value is changed - Scenario: Test how multi -line input appears + Scenario: Test how multi-line input appears Given I am on the Advanced Course Settings page in Studio When I create a JSON object as a value Then it is displayed as formatted And I reload the page Then it is displayed as formatted - Scenario: Test automatic quoting of non -JSON values + Scenario: Test automatic quoting of non-JSON values Given I am on the Advanced Course Settings page in Studio - When I create a non -JSON value not in quotes + When I create a non-JSON value not in quotes Then it is displayed as a string And I reload the page Then it is displayed as a string diff --git a/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature b/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature index 88492d55e3..762dea6838 100644 --- a/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature +++ b/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature @@ -21,7 +21,7 @@ Feature: Overview Toggle Section Then I see the "Collapse All Sections" link And all sections are expanded - @skip -phantom + @skip-phantom Scenario: Collapse link is not removed after last section of a course is deleted Given I have a course with 1 section And I navigate to the course overview page From f038237ee9f2d7a5dae9c2ebdb6a2ba57db860c8 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Wed, 27 Mar 2013 16:34:08 -0400 Subject: [PATCH 061/128] 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 062/128] 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 99778b1b5bc7dcf6d9cc29865a85b8d8bc0e2107 Mon Sep 17 00:00:00 2001 From: Brian Wilson Date: Wed, 27 Mar 2013 17:59:56 -0400 Subject: [PATCH 063/128] add fixes for 500 bugs (lighthouse 287 and 293) by requiring login for accessing licenses and inline discussions. Add unit tests for licenses, and rearrange tests based off of (the former) PageLoad. Also cleanups for pylint and pep8. --- lms/djangoapps/course_wiki/tests/tests.py | 11 +- .../courseware/tests/test_module_render.py | 85 +++---- lms/djangoapps/courseware/tests/tests.py | 216 +++++++++--------- .../django_comment_client/forum/views.py | 44 ++-- lms/djangoapps/django_comment_client/tests.py | 71 +----- lms/djangoapps/instructor/tests.py | 26 +-- lms/djangoapps/licenses/tests.py | 136 ++++++++++- lms/djangoapps/licenses/views.py | 22 +- lms/djangoapps/open_ended_grading/tests.py | 30 +-- 9 files changed, 334 insertions(+), 307 deletions(-) diff --git a/lms/djangoapps/course_wiki/tests/tests.py b/lms/djangoapps/course_wiki/tests/tests.py index cecc4f9cf9..620cf104d7 100644 --- a/lms/djangoapps/course_wiki/tests/tests.py +++ b/lms/djangoapps/course_wiki/tests/tests.py @@ -3,13 +3,11 @@ from django.test.utils import override_settings import xmodule.modulestore.django -from courseware.tests.tests import PageLoader, TEST_DATA_XML_MODULESTORE +from courseware.tests.tests import LoginEnrollmentTestCase, TEST_DATA_XML_MODULESTORE from xmodule.modulestore.django import modulestore -from xmodule.modulestore.xml_importer import import_from_xml - @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) -class WikiRedirectTestCase(PageLoader): +class WikiRedirectTestCase(LoginEnrollmentTestCase): def setUp(self): xmodule.modulestore.django._MODULESTORES = {} courses = modulestore().get_courses() @@ -30,8 +28,6 @@ class WikiRedirectTestCase(PageLoader): self.activate_user(self.student) self.activate_user(self.instructor) - - def test_wiki_redirect(self): """ Test that requesting wiki URLs redirect properly to or out of classes. @@ -69,7 +65,6 @@ class WikiRedirectTestCase(PageLoader): self.assertEqual(resp.status_code, 302) self.assertEqual(resp['Location'], 'http://testserver' + destination) - def create_course_page(self, course): """ Test that loading the course wiki page creates the wiki page. @@ -98,7 +93,6 @@ class WikiRedirectTestCase(PageLoader): self.assertTrue("course info" in resp.content.lower()) self.assertTrue("courseware" in resp.content.lower()) - def test_course_navigator(self): """" Test that going from a course page to a wiki page contains the course navigator. @@ -108,7 +102,6 @@ class WikiRedirectTestCase(PageLoader): self.enroll(self.toy) self.create_course_page(self.toy) - course_wiki_page = reverse('wiki:get', kwargs={'path': self.toy.wiki_slug + '/'}) referer = reverse("courseware", kwargs={'course_id': self.toy.id}) diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py index 3a3a7ac5ea..53f775267c 100644 --- a/lms/djangoapps/courseware/tests/test_module_render.py +++ b/lms/djangoapps/courseware/tests/test_module_render.py @@ -1,28 +1,16 @@ -import logging -from mock import MagicMock, patch +from mock import MagicMock import json -import factory -import unittest -from nose.tools import set_trace -from django.http import Http404, HttpResponse, HttpRequest -from django.conf import settings -from django.contrib.auth.models import User -from django.test.client import Client +from django.http import Http404, HttpResponse from django.conf import settings from django.test import TestCase from django.test.client import RequestFactory -from django.core.urlresolvers import reverse from django.test.utils import override_settings from xmodule.modulestore.exceptions import ItemNotFoundError -from xmodule.exceptions import NotFoundError -from xmodule.modulestore import Location import courseware.module_render as render from xmodule.modulestore.django import modulestore, _MODULESTORES -from xmodule.seq_module import SequenceModule -from courseware.tests.tests import PageLoader -from student.models import Registration +from courseware.tests.tests import LoginEnrollmentTestCase from courseware.model_data import ModelDataCache from .factories import UserFactory @@ -49,7 +37,7 @@ TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR) @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) -class ModuleRenderTestCase(PageLoader): +class ModuleRenderTestCase(LoginEnrollmentTestCase): def setUp(self): self.location = ['i4x', 'edX', 'toy', 'chapter', 'Overview'] self._MODULESTORES = {} @@ -66,10 +54,9 @@ class ModuleRenderTestCase(PageLoader): mock_request = MagicMock() mock_request.FILES.keys.return_value = ['file_id'] mock_request.FILES.getlist.return_value = ['file'] * (settings.MAX_FILEUPLOADS_PER_INPUT + 1) - self.assertEquals(render.modx_dispatch(mock_request, 'dummy', self.location, - 'dummy').content, - json.dumps({'success': 'Submission aborted! Maximum %d files may be submitted at once' % - settings.MAX_FILEUPLOADS_PER_INPUT})) + self.assertEquals(render.modx_dispatch(mock_request, 'dummy', self.location, 'dummy').content, + json.dumps({'success': 'Submission aborted! Maximum %d files may be submitted at once' % + settings.MAX_FILEUPLOADS_PER_INPUT})) mock_request_2 = MagicMock() mock_request_2.FILES.keys.return_value = ['file_id'] inputfile = Stub() @@ -80,7 +67,7 @@ class ModuleRenderTestCase(PageLoader): self.assertEquals(render.modx_dispatch(mock_request_2, 'dummy', self.location, 'dummy').content, json.dumps({'success': 'Submission aborted! Your file "%s" is too large (max size: %d MB)' % - (inputfile.name, settings.STUDENT_FILEUPLOAD_MAX_SIZE / (1000 ** 2))})) + (inputfile.name, settings.STUDENT_FILEUPLOAD_MAX_SIZE / (1000 ** 2))})) mock_request_3 = MagicMock() mock_request_3.POST.copy.return_value = {} mock_request_3.FILES = False @@ -91,10 +78,10 @@ class ModuleRenderTestCase(PageLoader): self.assertRaises(ItemNotFoundError, render.modx_dispatch, mock_request_3, 'dummy', self.location, 'toy') self.assertRaises(Http404, render.modx_dispatch, mock_request_3, 'dummy', - self.location, self.course_id) + self.location, self.course_id) mock_request_3.POST.copy.return_value = {'position': 1} self.assertIsInstance(render.modx_dispatch(mock_request_3, 'goto_position', - self.location, self.course_id), HttpResponse) + self.location, self.course_id), HttpResponse) def test_get_score_bucket(self): self.assertEquals(render.get_score_bucket(0, 10), 'incorrect') @@ -125,19 +112,19 @@ class TestTOC(TestCase): self.toy_course.id, self.portal_user, self.toy_course, depth=2) expected = ([{'active': True, 'sections': - [{'url_name': 'Toy_Videos', 'display_name': u'Toy Videos', 'graded': True, - 'format': u'Lecture Sequence', 'due': '', 'active': False}, - {'url_name': 'Welcome', 'display_name': u'Welcome', 'graded': True, - 'format': '', 'due': '', 'active': False}, - {'url_name': 'video_123456789012', 'display_name': 'video 123456789012', 'graded': True, - 'format': '', 'due': '', 'active': False}, - {'url_name': 'video_4f66f493ac8f', 'display_name': 'video 4f66f493ac8f', 'graded': True, - 'format': '', 'due': '', 'active': False}], - 'url_name': 'Overview', 'display_name': u'Overview'}, - {'active': False, 'sections': - [{'url_name': 'toyvideo', 'display_name': 'toyvideo', 'graded': True, - 'format': '', 'due': '', 'active': False}], - 'url_name': 'secret:magic', 'display_name': 'secret:magic'}]) + [{'url_name': 'Toy_Videos', 'display_name': u'Toy Videos', 'graded': True, + 'format': u'Lecture Sequence', 'due': '', 'active': False}, + {'url_name': 'Welcome', 'display_name': u'Welcome', 'graded': True, + 'format': '', 'due': '', 'active': False}, + {'url_name': 'video_123456789012', 'display_name': 'video 123456789012', 'graded': True, + 'format': '', 'due': '', 'active': False}, + {'url_name': 'video_4f66f493ac8f', 'display_name': 'video 4f66f493ac8f', 'graded': True, + 'format': '', 'due': '', 'active': False}], + 'url_name': 'Overview', 'display_name': u'Overview'}, + {'active': False, 'sections': + [{'url_name': 'toyvideo', 'display_name': 'toyvideo', 'graded': True, + 'format': '', 'due': '', 'active': False}], + 'url_name': 'secret:magic', 'display_name': 'secret:magic'}]) actual = render.toc_for_course(self.portal_user, request, self.toy_course, chapter, None, model_data_cache) self.assertEqual(expected, actual) @@ -152,19 +139,19 @@ class TestTOC(TestCase): self.toy_course.id, self.portal_user, self.toy_course, depth=2) expected = ([{'active': True, 'sections': - [{'url_name': 'Toy_Videos', 'display_name': u'Toy Videos', 'graded': True, - 'format': u'Lecture Sequence', 'due': '', 'active': False}, - {'url_name': 'Welcome', 'display_name': u'Welcome', 'graded': True, - 'format': '', 'due': '', 'active': True}, - {'url_name': 'video_123456789012', 'display_name': 'video 123456789012', 'graded': True, - 'format': '', 'due': '', 'active': False}, - {'url_name': 'video_4f66f493ac8f', 'display_name': 'video 4f66f493ac8f', 'graded': True, - 'format': '', 'due': '', 'active': False}], - 'url_name': 'Overview', 'display_name': u'Overview'}, - {'active': False, 'sections': - [{'url_name': 'toyvideo', 'display_name': 'toyvideo', 'graded': True, - 'format': '', 'due': '', 'active': False}], - 'url_name': 'secret:magic', 'display_name': 'secret:magic'}]) + [{'url_name': 'Toy_Videos', 'display_name': u'Toy Videos', 'graded': True, + 'format': u'Lecture Sequence', 'due': '', 'active': False}, + {'url_name': 'Welcome', 'display_name': u'Welcome', 'graded': True, + 'format': '', 'due': '', 'active': True}, + {'url_name': 'video_123456789012', 'display_name': 'video 123456789012', 'graded': True, + 'format': '', 'due': '', 'active': False}, + {'url_name': 'video_4f66f493ac8f', 'display_name': 'video 4f66f493ac8f', 'graded': True, + 'format': '', 'due': '', 'active': False}], + 'url_name': 'Overview', 'display_name': u'Overview'}, + {'active': False, 'sections': + [{'url_name': 'toyvideo', 'display_name': 'toyvideo', 'graded': True, + 'format': '', 'due': '', 'active': False}], + 'url_name': 'secret:magic', 'display_name': 'secret:magic'}]) actual = render.toc_for_course(self.portal_user, request, self.toy_course, chapter, section, model_data_cache) self.assertEqual(expected, actual) diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index cd845b1e44..9845477032 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -1,9 +1,6 @@ import logging -log = logging.getLogger("mitx." + __name__) - import json import time - from urlparse import urlsplit, urlunsplit from django.contrib.auth.models import User, Group @@ -29,29 +26,30 @@ from xmodule.modulestore.django import modulestore from xmodule.modulestore import Location from xmodule.modulestore.xml_importer import import_from_xml from xmodule.modulestore.xml import XMLModuleStore -from xmodule.timeparse import stringify_time +log = logging.getLogger("mitx." + __name__) def parse_json(response): """Parse response, which is assumed to be json""" return json.loads(response.content) -def user(email): +def get_user(email): '''look up a user by email''' return User.objects.get(email=email) -def registration(email): +def get_registration(email): '''look up registration object by email''' return Registration.objects.get(user__email=email) -# A bit of a hack--want mongo modulestore for these tests, until -# jump_to works with the xmlmodulestore or we have an even better solution -# NOTE: this means this test requires mongo to be running. - def mongo_store_config(data_dir): + ''' + Defines default module store using MongoModuleStore + + Use of this config requires mongo to be running + ''' return { 'default': { 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore', @@ -68,6 +66,7 @@ def mongo_store_config(data_dir): def draft_mongo_store_config(data_dir): + '''Defines default module store using DraftMongoModuleStore''' return { 'default': { 'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore', @@ -84,6 +83,7 @@ def draft_mongo_store_config(data_dir): def xml_store_config(data_dir): + '''Defines default module store using XMLModuleStore''' return { 'default': { 'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore', @@ -100,8 +100,8 @@ TEST_DATA_MONGO_MODULESTORE = mongo_store_config(TEST_DATA_DIR) TEST_DATA_DRAFT_MONGO_MODULESTORE = draft_mongo_store_config(TEST_DATA_DIR) -class ActivateLoginTestCase(TestCase): - '''Check that we can activate and log in''' +class LoginEnrollmentTestCase(TestCase): + '''Base TestCase providing support for user creation, activation, login, and course enrollment''' def assertRedirectsNoFollow(self, response, expected_url): """ @@ -117,32 +117,33 @@ class ActivateLoginTestCase(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)) + 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)) - def setUp(self): - email = 'view@test.com' - password = 'foo' - self.create_account('viewtest', email, password) - self.activate_user(email) - self.login(email, password) + 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.activate_user(self.viewtest_email) + self.login(self.viewtest_email, self.viewtest_password) # ============ User creation and login ============== - def _login(self, email, pw): + def _login(self, email, password): '''Login. View should always return 200. The success/fail is in the returned json''' resp = self.client.post(reverse('login'), - {'email': email, 'password': pw}) + {'email': email, 'password': password}) self.assertEqual(resp.status_code, 200) return resp - def login(self, email, pw): + def login(self, email, password): '''Login, check that it worked.''' - resp = self._login(email, pw) + resp = self._login(email, password) data = parse_json(resp) self.assertTrue(data['success']) return resp @@ -154,34 +155,34 @@ class ActivateLoginTestCase(TestCase): self.assertEqual(resp.status_code, 302) return resp - def _create_account(self, username, email, pw): + def _create_account(self, username, email, password): '''Try to create an account. No error checking''' resp = self.client.post('/create_account', { 'username': username, 'email': email, - 'password': pw, + 'password': password, 'name': 'Fred Weasley', 'terms_of_service': 'true', 'honor_code': 'true', }) return resp - def create_account(self, username, email, pw): + def create_account(self, username, email, password): '''Create the account and check that it worked''' - resp = self._create_account(username, email, pw) + resp = self._create_account(username, email, password) self.assertEqual(resp.status_code, 200) data = parse_json(resp) self.assertEqual(data['success'], True) # Check both that the user is created, and inactive - self.assertFalse(user(email).is_active) + self.assertFalse(get_user(email).is_active) return resp def _activate_user(self, email): '''Look up the activation key for the user, then hit the activate view. No error checking''' - activation_key = registration(email).activation_key + activation_key = get_registration(email).activation_key # and now we try to activate resp = self.client.get(reverse('activate', kwargs={'key': activation_key})) @@ -191,19 +192,7 @@ class ActivateLoginTestCase(TestCase): resp = self._activate_user(email) self.assertEqual(resp.status_code, 200) # Now make sure that the user is now actually activated - self.assertTrue(user(email).is_active) - - def test_activate_login(self): - '''The setup function does all the work''' - pass - - def test_logout(self): - '''Setup function does login''' - self.logout() - - -class PageLoader(ActivateLoginTestCase): - ''' Base class that adds a function to load all pages in a modulestore ''' + self.assertTrue(get_user(email).is_active) def _enroll(self, course): """Post to the enrollment view, and return the parsed json response""" @@ -240,8 +229,7 @@ class PageLoader(ActivateLoginTestCase): """ 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 {0} for url '{1}'. Expected code {2}".format(resp.status_code, url, code)) return resp def check_for_post_code(self, code, url, data={}): @@ -251,10 +239,27 @@ class PageLoader(ActivateLoginTestCase): """ 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 {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): + self.setup_viewtest_user() + + def test_activate_login(self): + '''Test login -- the setup function does all the work''' + pass + + def test_logout(self): + '''Test logout -- setup function does login''' + self.logout() + + +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""" # enroll in the course before trying to access pages @@ -264,14 +269,14 @@ class PageLoader(ActivateLoginTestCase): self.enroll(course) course_id = course.id - n = 0 + num = 0 num_bad = 0 all_ok = True for descriptor in module_store.get_items( Location(None, None, None, None, None)): - n += 1 + 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 @@ -332,45 +337,43 @@ class PageLoader(ActivateLoginTestCase): print msg self.assertTrue(all_ok) # fail fast - print "{0}/{1} good".format(n - num_bad, n) - log.info("{0}/{1} good".format(n - num_bad, n)) + print "{0}/{1} good".format(num - num_bad, num) + log.info("{0}/{1} good".format(num - num_bad, num)) self.assertTrue(all_ok) @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) -class TestCoursesLoadTestCase_XmlModulestore(PageLoader): - '''Check that all pages in test courses load properly''' +class TestCoursesLoadTestCase_XmlModulestore(PageLoaderTestCase): + '''Check that all pages in test courses load properly from XML''' def setUp(self): - ActivateLoginTestCase.setUp(self) + self.setup_viewtest_user() xmodule.modulestore.django._MODULESTORES = {} def test_toy_course_loads(self): - module_store = XMLModuleStore( - TEST_DATA_DIR, - default_class='xmodule.hidden_module.HiddenDescriptor', - course_dirs=['toy'], - load_error_modules=True, + module_store = XMLModuleStore(TEST_DATA_DIR, + default_class='xmodule.hidden_module.HiddenDescriptor', + course_dirs=['toy'], + load_error_modules=True, ) self.check_pages_load(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, + module_store = XMLModuleStore(TEST_DATA_DIR, + default_class='xmodule.hidden_module.HiddenDescriptor', + course_dirs=['full'], + load_error_modules=True, ) self.check_pages_load(module_store) @override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) -class TestCoursesLoadTestCase_MongoModulestore(PageLoader): - '''Check that all pages in test courses load properly''' +class TestCoursesLoadTestCase_MongoModulestore(PageLoaderTestCase): + '''Check that all pages in test courses load properly from Mongo''' def setUp(self): - ActivateLoginTestCase.setUp(self) + self.setup_viewtest_user() xmodule.modulestore.django._MODULESTORES = {} modulestore().collection.drop() @@ -386,7 +389,7 @@ class TestCoursesLoadTestCase_MongoModulestore(PageLoader): @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) -class TestNavigation(PageLoader): +class TestNavigation(LoginEnrollmentTestCase): """Check that navigation state is saved properly""" def setUp(self): @@ -447,7 +450,7 @@ class TestDraftModuleStore(TestCase): @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) -class TestViewAuth(PageLoader): +class TestViewAuth(LoginEnrollmentTestCase): """Check that view authentication works properly""" # NOTE: setUpClass() runs before override_settings takes effect, so @@ -492,7 +495,7 @@ class TestViewAuth(PageLoader): 'gradebook', 'grade_summary',)] urls.append(reverse('student_progress', kwargs={'course_id': course.id, - 'student_id': user(self.student).id})) + 'student_id': get_user(self.student).id})) return urls # shouldn't be able to get to the instructor pages @@ -502,8 +505,8 @@ class TestViewAuth(PageLoader): # Make the instructor staff in the toy course group_name = _course_staff_group_name(self.toy.location) - g = Group.objects.create(name=group_name) - g.user_set.add(user(self.instructor)) + group = Group.objects.create(name=group_name) + group.user_set.add(get_user(self.instructor)) self.logout() self.login(self.instructor, self.password) @@ -518,9 +521,9 @@ class TestViewAuth(PageLoader): self.check_for_get_code(404, url) # now also make the instructor staff - u = user(self.instructor) - u.is_staff = True - u.save() + instructor = get_user(self.instructor) + instructor.is_staff = True + instructor.save() # and now should be able to load both for url in instructor_urls(self.toy) + instructor_urls(self.full): @@ -627,7 +630,7 @@ class TestViewAuth(PageLoader): # 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': user(self.student).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) @@ -648,8 +651,8 @@ class TestViewAuth(PageLoader): print '=== Testing course instructor access....' # Make the instructor staff in the toy course group_name = _course_staff_group_name(self.toy.location) - g = Group.objects.create(name=group_name) - g.user_set.add(user(self.instructor)) + group = Group.objects.create(name=group_name) + group.user_set.add(get_user(self.instructor)) self.logout() self.login(self.instructor, self.password) @@ -663,9 +666,9 @@ class TestViewAuth(PageLoader): print '=== Testing staff access....' # now also make the instructor staff - u = user(self.instructor) - u.is_staff = True - u.save() + instructor = get_user(self.instructor) + instructor.is_staff = True + instructor.save() # and now should be able to load both check_staff(self.toy) @@ -698,8 +701,8 @@ class TestViewAuth(PageLoader): print '=== Testing course instructor access....' # Make the instructor staff in the toy course group_name = _course_staff_group_name(self.toy.location) - g = Group.objects.create(name=group_name) - g.user_set.add(user(self.instructor)) + group = Group.objects.create(name=group_name) + group.user_set.add(get_user(self.instructor)) print "logout/login" self.logout() @@ -709,10 +712,10 @@ class TestViewAuth(PageLoader): print '=== Testing staff access....' # now make the instructor global staff, but not in the instructor group - g.user_set.remove(user(self.instructor)) - u = user(self.instructor) - u.is_staff = True - u.save() + group.user_set.remove(get_user(self.instructor)) + instructor = get_user(self.instructor) + instructor.is_staff = True + instructor.save() # unenroll and try again self.unenroll(self.toy) @@ -726,8 +729,8 @@ class TestViewAuth(PageLoader): # Make courses start in the future tomorrow = time.time() + 24 * 3600 - nextday = tomorrow + 24 * 3600 - yesterday = time.time() - 24 * 3600 + # nextday = tomorrow + 24 * 3600 + # yesterday = time.time() - 24 * 3600 # toy course's hasn't started self.toy.lms.start = time.gmtime(tomorrow) @@ -737,20 +740,20 @@ class TestViewAuth(PageLoader): self.toy.lms.days_early_for_beta = 2 # student user shouldn't see it - student_user = user(self.student) + student_user = get_user(self.student) self.assertFalse(has_access(student_user, self.toy, 'load')) # now add the student to the beta test group group_name = course_beta_test_group_name(self.toy.location) - g = Group.objects.create(name=group_name) - g.user_set.add(student_user) + group = Group.objects.create(name=group_name) + group.user_set.add(student_user) # now the student should see it self.assertTrue(has_access(student_user, self.toy, 'load')) @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) -class TestCourseGrader(PageLoader): +class TestCourseGrader(LoginEnrollmentTestCase): """Check that a course gets graded properly""" # NOTE: setUpClass() runs before override_settings takes effect, so @@ -773,35 +776,39 @@ class TestCourseGrader(PageLoader): self.activate_user(self.student) self.enroll(self.graded_course) - self.student_user = user(self.student) + self.student_user = get_user(self.student) self.factory = RequestFactory() def get_grade_summary(self): + '''calls grades.grade for current user and course''' model_data_cache = ModelDataCache.cache_for_descriptor_descendents( 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) def get_homework_scores(self): + '''get scores for homeworks''' return self.get_grade_summary()['totaled_scores']['Homework'] def get_progress_summary(self): + '''return progress summary structure for current user and course''' model_data_cache = ModelDataCache.cache_for_descriptor_descendents( 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) return progress_summary def check_grade_percent(self, percent): + '''assert that percent grade is as expected''' grade_summary = self.get_grade_summary() self.assertEqual(grade_summary['percent'], percent) @@ -816,10 +823,9 @@ class TestCourseGrader(PageLoader): problem_location = "i4x://edX/graded/problem/{0}".format(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-{0}_2_1'.format(problem_url_name): responses[0], @@ -831,16 +837,17 @@ class TestCourseGrader(PageLoader): return resp def problem_location(self, problem_url_name): + '''Get location string for problem, assuming hardcoded course_id''' return "i4x://edX/graded/problem/{0}".format(problem_url_name) def reset_question_answer(self, problem_url_name): + '''resets specified problem for current user''' 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 @@ -855,6 +862,7 @@ class TestCourseGrader(PageLoader): return [s.earned for s in self.get_homework_scores()] def score_for_hw(hw_url_name): + """returns list of scores for a given url""" hw_section = [section for section in self.get_progress_summary()[0]['sections'] if section.get('url_name') == hw_url_name][0] diff --git a/lms/djangoapps/django_comment_client/forum/views.py b/lms/djangoapps/django_comment_client/forum/views.py index 3eee0948da..3a517af26e 100644 --- a/lms/djangoapps/django_comment_client/forum/views.py +++ b/lms/djangoapps/django_comment_client/forum/views.py @@ -1,28 +1,22 @@ import json import logging +import xml.sax.saxutils as saxutils from django.contrib.auth.decorators import login_required -from django.views.decorators.http import require_POST -from django.http import HttpResponse, Http404 -from django.utils import simplejson +from django.http import Http404 from django.core.context_processors import csrf -from django.core.urlresolvers import reverse from django.contrib.auth.models import User from mitxmako.shortcuts import render_to_response, render_to_string from courseware.courses import get_course_with_access -from course_groups.cohorts import is_course_cohorted, get_cohort_id, is_commentable_cohorted, get_cohorted_commentables, get_cohort, get_course_cohorts, get_cohort_by_id +from course_groups.cohorts import (is_course_cohorted, get_cohort_id, is_commentable_cohorted, + get_cohorted_commentables, get_course_cohorts, get_cohort_by_id) from courseware.access import has_access -from urllib import urlencode -from operator import methodcaller -from django_comment_client.permissions import check_permissions_by_view, cached_has_permission -from django_comment_client.utils import (merge_dict, extract, strip_none, - strip_blank, get_courseware_context) - +from django_comment_client.permissions import cached_has_permission +from django_comment_client.utils import (merge_dict, extract, strip_none, get_courseware_context) import django_comment_client.utils as utils import comment_client as cc -import xml.sax.saxutils as saxutils THREADS_PER_PAGE = 20 INLINE_THREADS_PER_PAGE = 20 @@ -31,6 +25,7 @@ escapedict = {'"': '"'} log = logging.getLogger("edx.discussions") +@login_required def get_threads(request, course_id, discussion_id=None, per_page=THREADS_PER_PAGE): """ This may raise cc.utils.CommentClientError or @@ -60,7 +55,6 @@ def get_threads(request, course_id, discussion_id=None, per_page=THREADS_PER_PAG cc_user.default_sort_key = request.GET.get('sort_key') cc_user.save() - #there are 2 dimensions to consider when executing a search with respect to group id #is user a moderator #did the user request a group @@ -91,18 +85,17 @@ def get_threads(request, course_id, discussion_id=None, per_page=THREADS_PER_PAG #now add the group name if the thread has a group id for thread in threads: - + if thread.get('group_id'): thread['group_name'] = get_cohort_by_id(course_id, thread.get('group_id')).name thread['group_string'] = "This post visible only to Group %s." % (thread['group_name']) else: thread['group_name'] = "" thread['group_string'] = "This post visible to everyone." - + #patch for backward compatibility to comments service if not 'pinned' in thread: thread['pinned'] = False - query_params['page'] = page query_params['num_pages'] = num_pages @@ -110,6 +103,7 @@ def get_threads(request, course_id, discussion_id=None, per_page=THREADS_PER_PAG return threads, query_params +@login_required def inline_discussion(request, course_id, discussion_id): """ Renders JSON for DiscussionModules @@ -142,14 +136,14 @@ def inline_discussion(request, course_id, discussion_id): cohorts_list = list() if is_cohorted: - cohorts_list.append({'name':'All Groups','id':None}) + cohorts_list.append({'name': 'All Groups', 'id': None}) #if you're a mod, send all cohorts and let you pick if is_moderator: cohorts = get_course_cohorts(course_id) for c in cohorts: - cohorts_list.append({'name':c.name, 'id':c.id}) + cohorts_list.append({'name': c.name, 'id': c.id}) else: #students don't get to choose @@ -216,9 +210,6 @@ def forum_form_discussion(request, course_id): user_cohort_id = get_cohort_id(request.user, course_id) - - - context = { 'csrf': csrf(request)['csrf_token'], 'course': course, @@ -242,6 +233,7 @@ def forum_form_discussion(request, course_id): return render_to_response('discussion/index.html', context) + @login_required def single_thread(request, course_id, discussion_id, thread_id): course = get_course_with_access(request.user, course_id, 'load') @@ -250,11 +242,11 @@ def single_thread(request, course_id, discussion_id, thread_id): try: thread = cc.Thread.find(thread_id).retrieve(recursive=True, user_id=request.user.id) - + #patch for backward compatibility with comments service if not 'pinned' in thread.attributes: thread['pinned'] = False - + except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError) as err: log.error("Error loading single thread.") raise Http404 @@ -352,7 +344,7 @@ def user_profile(request, course_id, user_id): query_params = { 'page': request.GET.get('page', 1), 'per_page': THREADS_PER_PAGE, # more than threads_per_page to show more activities - } + } threads, page, num_pages = profiled_user.active_threads(query_params) query_params['page'] = page @@ -369,8 +361,6 @@ def user_profile(request, course_id, user_id): 'annotated_content_info': saxutils.escape(json.dumps(annotated_content_info), escapedict), }) else: - - context = { 'course': course, 'user': request.user, @@ -426,5 +416,5 @@ def followed_threads(request, course_id, user_id): } return render_to_response('discussion/user_profile.html', context) - except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError) as err: + except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError): raise Http404 diff --git a/lms/djangoapps/django_comment_client/tests.py b/lms/djangoapps/django_comment_client/tests.py index 1d925cdb8e..a35df54cd9 100644 --- a/lms/djangoapps/django_comment_client/tests.py +++ b/lms/djangoapps/django_comment_client/tests.py @@ -1,73 +1,12 @@ -from django.contrib.auth.models import User, Group -from django.core.urlresolvers import reverse -from django.test import TestCase -from django.test.client import RequestFactory -from django.conf import settings - -from mock import Mock - -from django.test.utils import override_settings - -import xmodule.modulestore.django - -from student.models import CourseEnrollment - -from django.db.models.signals import m2m_changed, pre_delete, pre_save, post_delete, post_save -from django.dispatch.dispatcher import _make_id import string import random -from .permissions import has_permission -from .models import Role, Permission -from xmodule.modulestore.django import modulestore -from xmodule.modulestore import Location -from xmodule.modulestore.xml_importer import import_from_xml -from xmodule.modulestore.xml import XMLModuleStore - -import comment_client - -from courseware.tests.tests import PageLoader, TEST_DATA_XML_MODULESTORE - -#@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) -#class TestCohorting(PageLoader): -# """Check that cohorting works properly""" -# -# def setUp(self): -# xmodule.modulestore.django._MODULESTORES = {} -# -# # Assume courses are there -# self.toy = modulestore().get_course("edX/toy/2012_Fall") -# -# # Create two accounts -# self.student = 'view@test.com' -# self.student2 = 'view2@test.com' -# self.password = 'foo' -# self.create_account('u1', self.student, self.password) -# self.create_account('u2', self.student2, self.password) -# self.activate_user(self.student) -# self.activate_user(self.student2) -# -# def test_create_thread(self): -# my_save = Mock() -# comment_client.perform_request = my_save -# -# resp = self.client.post( -# reverse('django_comment_client.base.views.create_thread', -# kwargs={'course_id': 'edX/toy/2012_Fall', -# 'commentable_id': 'General'}), -# {'some': "some", -# 'data': 'data'}) -# self.assertTrue(my_save.called) -# -# #self.assertEqual(resp.status_code, 200) -# #self.assertEqual(my_save.something, "expected", "complaint if not true") -# -# self.toy.cohort_config = {"cohorted": True} -# -# # call the view again ... -# -# # assert that different things happened +from django.contrib.auth.models import User +from django.test import TestCase +from student.models import CourseEnrollment +from django_comment_client.permissions import has_permission +from django_comment_client.models import Role class PermissionsTestCase(TestCase): diff --git a/lms/djangoapps/instructor/tests.py b/lms/djangoapps/instructor/tests.py index b775aa158a..512e81e302 100644 --- a/lms/djangoapps/instructor/tests.py +++ b/lms/djangoapps/instructor/tests.py @@ -8,13 +8,6 @@ Notes for running by hand: django-admin.py test --settings=lms.envs.test --pythonpath=. lms/djangoapps/instructor """ -import courseware.tests.tests as ct - -import json - -from nose import SkipTest -from mock import patch, Mock - from django.test.utils import override_settings # Need access to internal func to put users in the right group @@ -26,13 +19,13 @@ from django_comment_client.models import Role, FORUM_ROLE_ADMINISTRATOR, \ from django_comment_client.utils import has_forum_access from courseware.access import _course_staff_group_name -import courseware.tests.tests as ct +from courseware.tests.tests import LoginEnrollmentTestCase, TEST_DATA_XML_MODULESTORE, get_user from xmodule.modulestore.django import modulestore import xmodule.modulestore.django -@override_settings(MODULESTORE=ct.TEST_DATA_XML_MODULESTORE) -class TestInstructorDashboardGradeDownloadCSV(ct.PageLoader): +@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) +class TestInstructorDashboardGradeDownloadCSV(LoginEnrollmentTestCase): ''' Check for download of csv ''' @@ -55,7 +48,7 @@ class TestInstructorDashboardGradeDownloadCSV(ct.PageLoader): def make_instructor(course): group_name = _course_staff_group_name(course.location) g = Group.objects.create(name=group_name) - g.user_set.add(ct.user(self.instructor)) + g.user_set.add(get_user(self.instructor)) make_instructor(self.toy) @@ -63,7 +56,6 @@ class TestInstructorDashboardGradeDownloadCSV(ct.PageLoader): self.login(self.instructor, self.password) self.enroll(self.toy) - def test_download_grades_csv(self): course = self.toy url = reverse('instructor_dashboard', kwargs={'course_id': course.id}) @@ -101,9 +93,8 @@ def action_name(operation, rolename): return '{0} forum {1}'.format(operation, FORUM_ADMIN_ACTION_SUFFIX[rolename]) - -@override_settings(MODULESTORE=ct.TEST_DATA_XML_MODULESTORE) -class TestInstructorDashboardForumAdmin(ct.PageLoader): +@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) +class TestInstructorDashboardForumAdmin(LoginEnrollmentTestCase): ''' Check for change in forum admin role memberships ''' @@ -112,7 +103,6 @@ class TestInstructorDashboardForumAdmin(ct.PageLoader): xmodule.modulestore.django._MODULESTORES = {} courses = modulestore().get_courses() - self.course_id = "edX/toy/2012_Fall" self.toy = modulestore().get_course(self.course_id) @@ -127,14 +117,12 @@ class TestInstructorDashboardForumAdmin(ct.PageLoader): group_name = _course_staff_group_name(self.toy.location) g = Group.objects.create(name=group_name) - g.user_set.add(ct.user(self.instructor)) + g.user_set.add(get_user(self.instructor)) self.logout() self.login(self.instructor, self.password) self.enroll(self.toy) - - def initialize_roles(self, course_id): self.admin_role = Role.objects.get_or_create(name=FORUM_ROLE_ADMINISTRATOR, course_id=course_id)[0] self.moderator_role = Role.objects.get_or_create(name=FORUM_ROLE_MODERATOR, course_id=course_id)[0] diff --git a/lms/djangoapps/licenses/tests.py b/lms/djangoapps/licenses/tests.py index 232c853b62..5289c31bc6 100644 --- a/lms/djangoapps/licenses/tests.py +++ b/lms/djangoapps/licenses/tests.py @@ -1,22 +1,138 @@ +"""Tests for License package""" import logging +import json + from uuid import uuid4 from random import shuffle from tempfile import NamedTemporaryFile +from factory import Factory, SubFactory from django.test import TestCase from django.core.management import call_command - -from .models import CourseSoftware, UserLicense +from django.core.urlresolvers import reverse +from licenses.models import CourseSoftware, UserLicense +from courseware.tests.tests import LoginEnrollmentTestCase, get_user COURSE_1 = 'edX/toy/2012_Fall' SOFTWARE_1 = 'matlab' SOFTWARE_2 = 'stata' +SERIAL_1 = '123456abcde' + log = logging.getLogger(__name__) +class CourseSoftwareFactory(Factory): + '''Factory for generating CourseSoftware objects in database''' + FACTORY_FOR = CourseSoftware + + name = SOFTWARE_1 + full_name = SOFTWARE_1 + url = SOFTWARE_1 + course_id = COURSE_1 + + +class UserLicenseFactory(Factory): + ''' + Factory for generating UserLicense objects in database + + By default, the user assigned is null, indicating that the + serial number has not yet been assigned. + ''' + FACTORY_FOR = UserLicense + + software = SubFactory(CourseSoftwareFactory) + serial = SERIAL_1 + + +class LicenseTestCase(LoginEnrollmentTestCase): + '''Tests for licenses.views''' + def setUp(self): + '''creates a user and logs in''' + self.setup_viewtest_user() + self.software = CourseSoftwareFactory() + + def test_get_license(self): + UserLicenseFactory(user=get_user(self.viewtest_email), software=self.software) + response = self.client.post(reverse('user_software_license'), + {'software': SOFTWARE_1, 'generate': 'false'}, + HTTP_X_REQUESTED_WITH='XMLHttpRequest', + HTTP_REFERER='/courses/{0}/some_page'.format(COURSE_1)) + self.assertEqual(200, response.status_code) + json_returned = json.loads(response.content) + self.assertFalse('error' in json_returned) + self.assertTrue('serial' in json_returned) + self.assertEquals(json_returned['serial'], SERIAL_1) + + def test_get_nonexistent_license(self): + response = self.client.post(reverse('user_software_license'), + {'software': SOFTWARE_1, 'generate': 'false'}, + HTTP_X_REQUESTED_WITH='XMLHttpRequest', + HTTP_REFERER='/courses/{0}/some_page'.format(COURSE_1)) + self.assertEqual(200, response.status_code) + json_returned = json.loads(response.content) + self.assertFalse('serial' in json_returned) + self.assertTrue('error' in json_returned) + + def test_create_nonexistent_license(self): + '''Should not assign a license to an unlicensed user when none are available''' + response = self.client.post(reverse('user_software_license'), + {'software': SOFTWARE_1, 'generate': 'true'}, + HTTP_X_REQUESTED_WITH='XMLHttpRequest', + HTTP_REFERER='/courses/{0}/some_page'.format(COURSE_1)) + self.assertEqual(200, response.status_code) + json_returned = json.loads(response.content) + self.assertFalse('serial' in json_returned) + self.assertTrue('error' in json_returned) + + def test_create_license(self): + '''Should assign a license to an unlicensed user if one is unassigned''' + # create an unassigned license + UserLicenseFactory(software=self.software) + response = self.client.post(reverse('user_software_license'), + {'software': SOFTWARE_1, 'generate': 'true'}, + HTTP_X_REQUESTED_WITH='XMLHttpRequest', + HTTP_REFERER='/courses/{0}/some_page'.format(COURSE_1)) + self.assertEqual(200, response.status_code) + json_returned = json.loads(response.content) + self.assertFalse('error' in json_returned) + self.assertTrue('serial' in json_returned) + self.assertEquals(json_returned['serial'], SERIAL_1) + + def test_get_license_from_wrong_course(self): + response = self.client.post(reverse('user_software_license'), + {'software': SOFTWARE_1, 'generate': 'false'}, + HTTP_X_REQUESTED_WITH='XMLHttpRequest', + HTTP_REFERER='/courses/{0}/some_page'.format('some/other/course')) + self.assertEqual(404, response.status_code) + + def test_get_license_from_non_ajax(self): + response = self.client.post(reverse('user_software_license'), + {'software': SOFTWARE_1, 'generate': 'false'}, + HTTP_REFERER='/courses/{0}/some_page'.format(COURSE_1)) + self.assertEqual(404, response.status_code) + + def test_get_license_without_software(self): + response = self.client.post(reverse('user_software_license'), + {'generate': 'false'}, + HTTP_X_REQUESTED_WITH='XMLHttpRequest', + HTTP_REFERER='/courses/{0}/some_page'.format(COURSE_1)) + self.assertEqual(404, response.status_code) + + def test_get_license_without_login(self): + self.logout() + response = self.client.post(reverse('user_software_license'), + {'software': SOFTWARE_1, 'generate': 'false'}, + HTTP_X_REQUESTED_WITH='XMLHttpRequest', + HTTP_REFERER='/courses/{0}/some_page'.format(COURSE_1)) + # if we're not logged in, we should be referred to the login page + self.assertEqual(302, response.status_code) + + class CommandTest(TestCase): + '''Test management command for importing serial numbers''' + def test_import_serial_numbers(self): size = 20 @@ -51,31 +167,33 @@ class CommandTest(TestCase): licenses_count = UserLicense.objects.all().count() self.assertEqual(3 * size, licenses_count) - cs = CourseSoftware.objects.get(pk=1) + software = CourseSoftware.objects.get(pk=1) - lics = UserLicense.objects.filter(software=cs)[:size] + lics = UserLicense.objects.filter(software=software)[:size] known_serials = list(l.serial for l in lics) known_serials.extend(generate_serials(10)) shuffle(known_serials) log.debug('Adding some new and old serials to {0}'.format(SOFTWARE_1)) - with NamedTemporaryFile() as f: - f.write('\n'.join(known_serials)) - f.flush() - args = [COURSE_1, SOFTWARE_1, f.name] + with NamedTemporaryFile() as tmpfile: + tmpfile.write('\n'.join(known_serials)) + tmpfile.flush() + args = [COURSE_1, SOFTWARE_1, tmpfile.name] call_command('import_serial_numbers', *args) log.debug('Check if we added only the new ones') - licenses_count = UserLicense.objects.filter(software=cs).count() + licenses_count = UserLicense.objects.filter(software=software).count() self.assertEqual((2 * size) + 10, licenses_count) def generate_serials(size=20): + '''generate a list of serial numbers''' return [str(uuid4()) for _ in range(size)] def generate_serials_file(size=20): + '''output list of generated serial numbers to a temp file''' serials = generate_serials(size) temp_file = NamedTemporaryFile() diff --git a/lms/djangoapps/licenses/views.py b/lms/djangoapps/licenses/views.py index 20966427ba..1c1a80ed31 100644 --- a/lms/djangoapps/licenses/views.py +++ b/lms/djangoapps/licenses/views.py @@ -7,12 +7,13 @@ from collections import namedtuple, defaultdict from mitxmako.shortcuts import render_to_string +from django.contrib.auth.decorators import login_required from django.contrib.auth.models import User from django.http import HttpResponse, Http404 -from django.views.decorators.csrf import requires_csrf_token, csrf_protect +from django.views.decorators.csrf import requires_csrf_token -from .models import CourseSoftware -from .models import get_courses_licenses, get_or_create_license, get_license +from licenses.models import CourseSoftware +from licenses.models import get_courses_licenses, get_or_create_license, get_license log = logging.getLogger("mitx.licenses") @@ -44,6 +45,7 @@ def get_licenses_by_course(user, courses): return data_by_course +@login_required @requires_csrf_token def user_software_license(request): if request.method != 'POST' or not request.is_ajax(): @@ -65,19 +67,21 @@ def user_software_license(request): try: software = CourseSoftware.objects.get(name=software_name, course_id=course_id) - print software except CourseSoftware.DoesNotExist: raise Http404 - user = User.objects.get(id=user_id) + try: + user = User.objects.get(id=user_id) + except User.DoesNotExist: + raise Http404 if generate: - license = get_or_create_license(user, software) + software_license = get_or_create_license(user, software) else: - license = get_license(user, software) + software_license = get_license(user, software) - if license: - response = {'serial': license.serial} + if software_license: + response = {'serial': software_license.serial} else: response = {'error': 'No serial number found'} diff --git a/lms/djangoapps/open_ended_grading/tests.py b/lms/djangoapps/open_ended_grading/tests.py index 1fd871d0cd..e554fdf0e1 100644 --- a/lms/djangoapps/open_ended_grading/tests.py +++ b/lms/djangoapps/open_ended_grading/tests.py @@ -4,22 +4,22 @@ Tests for open ended grading interfaces django-admin.py test --settings=lms.envs.test --pythonpath=. lms/djangoapps/open_ended_grading """ -from django.test import TestCase -from open_ended_grading import staff_grading_service -from xmodule.open_ended_grading_classes import peer_grading_service -from xmodule import peer_grading_module +import json +from mock import MagicMock + from django.core.urlresolvers import reverse from django.contrib.auth.models import Group +from mitxmako.shortcuts import render_to_string -from courseware.access import _course_staff_group_name -import courseware.tests.tests as ct +from xmodule.open_ended_grading_classes import peer_grading_service +from xmodule import peer_grading_module from xmodule.modulestore.django import modulestore import xmodule.modulestore.django -from nose import SkipTest -from mock import patch, Mock, MagicMock -import json from xmodule.x_module import ModuleSystem -from mitxmako.shortcuts import render_to_string + +from open_ended_grading import staff_grading_service +from courseware.access import _course_staff_group_name +from courseware.tests.tests import LoginEnrollmentTestCase, TEST_DATA_XML_MODULESTORE, get_user import logging @@ -30,8 +30,8 @@ from django.http import QueryDict from xmodule.tests import test_util_open_ended -@override_settings(MODULESTORE=ct.TEST_DATA_XML_MODULESTORE) -class TestStaffGradingService(ct.PageLoader): +@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) +class TestStaffGradingService(LoginEnrollmentTestCase): ''' Check that staff grading service proxy works. Basically just checking the access control and error handling logic -- all the actual work is on the @@ -56,7 +56,7 @@ class TestStaffGradingService(ct.PageLoader): def make_instructor(course): group_name = _course_staff_group_name(course.location) g = Group.objects.create(name=group_name) - g.user_set.add(ct.user(self.instructor)) + g.user_set.add(get_user(self.instructor)) make_instructor(self.toy) @@ -126,8 +126,8 @@ class TestStaffGradingService(ct.PageLoader): self.assertIsNotNone(d['problem_list']) -@override_settings(MODULESTORE=ct.TEST_DATA_XML_MODULESTORE) -class TestPeerGradingService(ct.PageLoader): +@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) +class TestPeerGradingService(LoginEnrollmentTestCase): ''' Check that staff grading service proxy works. Basically just checking the access control and error handling logic -- all the actual work is on the From 4443afecaf2450f16fb36d7c79d99cd5e91c19d3 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Wed, 27 Mar 2013 18:05:00 -0400 Subject: [PATCH 064/128] 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 c639799a07730eb2244904838e6591eb760ecc6f Mon Sep 17 00:00:00 2001 From: Brian Wilson Date: Wed, 27 Mar 2013 18:12:48 -0400 Subject: [PATCH 065/128] fix missing import --- lms/djangoapps/courseware/tests/test_module_render.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py index fdba969ab5..85a65b7772 100644 --- a/lms/djangoapps/courseware/tests/test_module_render.py +++ b/lms/djangoapps/courseware/tests/test_module_render.py @@ -2,6 +2,7 @@ from mock import MagicMock import json from django.http import Http404, HttpResponse +from django.core.urlresolvers import reverse from django.conf import settings from django.test import TestCase from django.test.client import RequestFactory From df6d8fd2a3ed19d941810fb25e2043c8dd1948e3 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Wed, 27 Mar 2013 18:20:04 -0400 Subject: [PATCH 066/128] 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 067/128] 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 03f9bb5d38a3855ee56087b9132f8ebbe13be747 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Wed, 27 Mar 2013 22:37:07 -0400 Subject: [PATCH 068/128] use a request-scoped cache to keep the metadata inheritence tree around for the whole request. This means we should only do one trip to Memcached/Mongo per course per request. This is expected to keep memory utilization down --- common/djangoapps/request_cache/__init__.py | 0 common/djangoapps/request_cache/middleware.py | 20 +++++++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 common/djangoapps/request_cache/__init__.py create mode 100644 common/djangoapps/request_cache/middleware.py diff --git a/common/djangoapps/request_cache/__init__.py b/common/djangoapps/request_cache/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/request_cache/middleware.py b/common/djangoapps/request_cache/middleware.py new file mode 100644 index 0000000000..9d3dffdf27 --- /dev/null +++ b/common/djangoapps/request_cache/middleware.py @@ -0,0 +1,20 @@ +import threading + +_request_cache_threadlocal = threading.local() +_request_cache_threadlocal.data = {} + +class RequestCache(object): + @classmethod + def get_request_cache(cls): + return _request_cache_threadlocal + + def clear_request_cache(self): + _request_cache_threadlocal.data = {} + + def process_request(self, request): + self.clear_request_cache() + return None + + def process_response(self, request, response): + self.clear_request_cache() + return response \ No newline at end of file From b609a96902e6be8c1be4fd423db2c9d2dbe018ca Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Wed, 27 Mar 2013 22:51:52 -0400 Subject: [PATCH 069/128] ummm. forgot to commit stuff --- cms/envs/common.py | 1 + cms/one_time_startup.py | 4 +- .../lib/xmodule/xmodule/modulestore/mongo.py | 105 ++++++++++-------- .../xmodule/modulestore/tests/test_mongo.py | 55 --------- lms/envs/common.py | 1 + lms/one_time_startup.py | 4 +- 6 files changed, 64 insertions(+), 106 deletions(-) diff --git a/cms/envs/common.py b/cms/envs/common.py index a83f61d8f9..12fa09947a 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -113,6 +113,7 @@ TEMPLATE_LOADERS = ( MIDDLEWARE_CLASSES = ( 'contentserver.middleware.StaticContentServer', + 'request_cache.middleware.RequestCache', 'django.middleware.cache.UpdateCacheMiddleware', 'django.middleware.common.CommonMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', diff --git a/cms/one_time_startup.py b/cms/one_time_startup.py index 38a2fef847..6e88fed439 100644 --- a/cms/one_time_startup.py +++ b/cms/one_time_startup.py @@ -1,13 +1,15 @@ from dogapi import dog_http_api, dog_stats_api from django.conf import settings from xmodule.modulestore.django import modulestore +from request_cache.middleware import RequestCache from django.core.cache import get_cache, InvalidCacheBackendError cache = get_cache('mongo_metadata_inheritance') for store_name in settings.MODULESTORE: store = modulestore(store_name) - store.metadata_inheritance_cache = cache + store.metadata_inheritance_cache_subsystem = cache + store.request_cache = RequestCache.get_request_cache() if hasattr(settings, 'DATADOG_API'): dog_http_api.api_key = settings.DATADOG_API diff --git a/common/lib/xmodule/xmodule/modulestore/mongo.py b/common/lib/xmodule/xmodule/modulestore/mongo.py index 38b15ab76e..b93a95c965 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo.py @@ -27,6 +27,9 @@ from .inheritance import own_metadata, INHERITABLE_METADATA, inherit_metadata log = logging.getLogger(__name__) +import threading +_mongo_metadata_request_cache_threadlocal = threading.local() + # TODO (cpennington): This code currently operates under the assumption that # there is only one revision for each item. Once we start versioning inside the CMS, # that assumption will have to change @@ -109,7 +112,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem): references to metadata_inheritance_tree """ def __init__(self, modulestore, module_data, default_class, resources_fs, - error_tracker, render_template, metadata_cache=None): + error_tracker, render_template, cached_metadata=None): """ modulestore: the module store that can be used to retrieve additional modules @@ -134,7 +137,8 @@ class CachingDescriptorSystem(MakoDescriptorSystem): # cdodge: other Systems have a course_id attribute defined. To keep things consistent, let's # define an attribute here as well, even though it's None self.course_id = None - self.metadata_cache = metadata_cache + self.cached_metadata = cached_metadata + def load_item(self, location): """ @@ -170,8 +174,8 @@ class CachingDescriptorSystem(MakoDescriptorSystem): model_data = DbModel(kvs, class_, None, MongoUsage(self.course_id, location)) module = class_(self, location, model_data) - if self.metadata_cache is not None: - metadata_to_inherit = self.metadata_cache.get(metadata_cache_key(location), {}).get('parent_metadata', {}).get(location.url(), {}) + if self.cached_metadata is not None: + metadata_to_inherit = self.cached_metadata.get(location.url(), {}) inherit_metadata(module, metadata_to_inherit) return module except: @@ -223,7 +227,8 @@ class MongoModuleStore(ModuleStoreBase): def __init__(self, host, db, collection, fs_root, render_template, port=27017, default_class=None, error_tracker=null_error_tracker, - user=None, password=None, **kwargs): + user=None, password=None, request_cache=None, + metadata_inheritance_cache_subsystem=None, **kwargs): ModuleStoreBase.__init__(self) @@ -254,8 +259,10 @@ class MongoModuleStore(ModuleStoreBase): self.error_tracker = error_tracker self.render_template = render_template self.ignore_write_events_on_courses = [] + self.request_cache = request_cache + self.metadata_inheritance_cache_subsystem = metadata_inheritance_cache_subsystem - def get_metadata_inheritance_tree(self, location): + def compute_metadata_inheritance_tree(self, location): ''' TODO (cdodge) This method can be deleted when the 'split module store' work has been completed ''' @@ -323,32 +330,47 @@ class MongoModuleStore(ModuleStoreBase): if root is not None: _compute_inherited_metadata(root) - return {'parent_metadata': metadata_to_inherit, - 'timestamp': datetime.now()} + return metadata_to_inherit - def get_cached_metadata_inheritance_trees(self, locations, force_refresh=False): + def get_cached_metadata_inheritance_tree(self, location, force_refresh=False): ''' TODO (cdodge) This method can be deleted when the 'split module store' work has been completed ''' + key = metadata_cache_key(location) + tree = {} + + if not force_refresh: + # see if we are first in the request cache (if present) + if self.request_cache is not None and key in self.request_cache.data.get('metadata_inheritance', {}): + logging.debug('***** HIT IN REQUEST CACHE') + return self.request_cache.data['metadata_inheritance'][key] - trees = {} - if locations and self.metadata_inheritance_cache is not None and not force_refresh: - trees = self.metadata_inheritance_cache.get_many(list(set([metadata_cache_key(loc) for loc in locations]))) - else: - # This is to help guard against an accident prod runtime without a cache - logging.warning('Running MongoModuleStore without metadata_inheritance_cache. ' - 'This should not happen in production!') + # then look in any caching subsystem (e.g. memcached) + if self.metadata_inheritance_cache_subsystem is not None: + tree = self.metadata_inheritance_cache_subsystem.get(key, {}) + if tree: + logging.debug('***** HIT IN MEMCACHED') + else: + logging.warning('Running MongoModuleStore without a metadata_inheritance_cache_subsystem. This is OK in localdev and testing environment. Not OK in production.') - to_cache = {} - for loc in locations: - cache_key = metadata_cache_key(loc) - if cache_key not in trees: - to_cache[cache_key] = trees[cache_key] = self.get_metadata_inheritance_tree(loc) + if not tree: + # if not in subsystem, or we are on force refresh, then we have to compute + logging.debug('***** COMPUTING METADATA') + tree = self.compute_metadata_inheritance_tree(location) - if to_cache and self.metadata_inheritance_cache is not None: - self.metadata_inheritance_cache.set_many(to_cache) + # now populate a request_cache, if available + if self.request_cache is not None: + # we can't assume the 'metadatat_inheritance' part of the request cache dict has been + # defined + if 'metadata_inheritance' not in self.request_cache.data: + self.request_cache.data['metadata_inheritance'] = {} + self.request_cache.data['metadata_inheritance'][key] = tree - return trees + # now write to caching subsystem (e.g. memcached), if available + if self.metadata_inheritance_cache_subsystem is not None: + self.metadata_inheritance_cache_subsystem.set(key, tree) + + return tree def refresh_cached_metadata_inheritance_tree(self, location): """ @@ -357,15 +379,7 @@ class MongoModuleStore(ModuleStoreBase): """ pseudo_course_id = '/'.join([location.org, location.course]) if pseudo_course_id not in self.ignore_write_events_on_courses: - self.get_cached_metadata_inheritance_trees([location], force_refresh=True) - - def clear_cached_metadata_inheritance_tree(self, location): - """ - Delete the cached metadata inheritance tree for the org/course combination - for location - """ - if self.metadata_inheritance_cache is not None: - self.metadata_inheritance_cache.delete(metadata_cache_key(location)) + self.get_cached_metadata_inheritance_tree(location, force_refresh=True) def _clean_item_data(self, item): """ @@ -411,18 +425,7 @@ class MongoModuleStore(ModuleStoreBase): return data - def _cache_metadata_inheritance(self, items, depth, force_refresh=False): - """ - Retrieves all course metadata inheritance trees needed to load items - """ - - locations = [ - Location(item['location']) for item in items - if not (item['location']['category'] == 'course' and depth == 0) - ] - return self.get_cached_metadata_inheritance_trees(locations, force_refresh=force_refresh) - - def _load_item(self, item, data_cache, metadata_cache): + def _load_item(self, item, data_cache, apply_cached_metadata=True): """ Load an XModuleDescriptor from item, using the children stored in data_cache """ @@ -434,6 +437,10 @@ class MongoModuleStore(ModuleStoreBase): resource_fs = OSFS(root) + cached_metadata = {} + if apply_cached_metadata: + cached_metadata = self.get_cached_metadata_inheritance_tree(Location(item['location'])) + # TODO (cdodge): When the 'split module store' work has been completed, we should remove # the 'metadata_inheritance_tree' parameter system = CachingDescriptorSystem( @@ -443,7 +450,7 @@ class MongoModuleStore(ModuleStoreBase): resource_fs, self.error_tracker, self.render_template, - metadata_cache, + cached_metadata, ) return system.load_item(item['location']) @@ -453,11 +460,11 @@ class MongoModuleStore(ModuleStoreBase): to specified depth """ data_cache = self._cache_children(items, depth) - inheritance_cache = self._cache_metadata_inheritance(items, depth) # if we are loading a course object, if we're not prefetching children (depth != 0) then don't - # bother with the metadata inheritence - return [self._load_item(item, data_cache, inheritance_cache) for item in items] + # bother with the metadata inheritance + return [self._load_item(item, data_cache, + apply_cached_metadata=(item['location']['category']!='course' or depth !=0)) for item in items] def get_courses(self): ''' diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py b/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py index 3e29c07ea4..061d70d09f 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py @@ -103,58 +103,3 @@ class TestMongoModuleStore(object): def test_path_to_location(self): '''Make sure that path_to_location works''' check_path_to_location(self.store) - - def test_metadata_inheritance_query_count(self): - ''' - When retrieving items from mongo, we should only query the cache a number of times - equal to the number of courses being retrieved from. - - We should also not query - ''' - self.store.metadata_inheritance_cache = Mock() - get_many = self.store.metadata_inheritance_cache.get_many - set_many = self.store.metadata_inheritance_cache.set_many - get_many.return_value = {('edX', 'toy'): {}} - - self.store.get_item(Location("i4x://edX/toy/course/2012_Fall"), depth=0) - assert_false(get_many.called) - assert_false(set_many.called) - get_many.reset_mock() - - self.store.get_item(Location("i4x://edX/toy/course/2012_Fall"), depth=3) - get_many.assert_called_with([('edX', 'toy')]) - assert_equals(0, set_many.call_count) - get_many.reset_mock() - - self.store.get_items(Location('i4x', 'edX', None, 'course', None), depth=0) - assert_false(get_many.called) - assert_false(set_many.called) - get_many.reset_mock() - - self.store.get_items(Location('i4x', 'edX', None, 'course', None), depth=3) - assert_equals(1, get_many.call_count) - assert_equals([('edX', 'simple'), ('edX', 'toy')], sorted(get_many.call_args[0][0])) - assert_equals(1, set_many.call_count) - assert_equals([('edX', 'simple')], sorted(set_many.call_args[0][0].keys())) - get_many.reset_mock() - - self.store.get_items(Location('i4x', 'edX', None, None, None), depth=0) - assert_equals(1, get_many.call_count) - assert_equals([('edX', 'simple'), ('edX', 'toy')], sorted(get_many.call_args[0][0])) - assert_equals(1, set_many.call_count) - assert_equals([('edX', 'simple')], sorted(set_many.call_args[0][0].keys())) - get_many.reset_mock() - - def test_metadata_inheritance_query_count_forced_refresh(self): - self.store.metadata_inheritance_cache = Mock() - get_many = self.store.metadata_inheritance_cache.get_many - set_many = self.store.metadata_inheritance_cache.set_many - get_many.return_value = {('edX', 'toy'): {}} - - self.store.get_cached_metadata_inheritance_trees( - [Location("i4x://edX/toy/course/2012_Fall"), Location("i4x://edX/simple/course/2012_Fall")], - True - ) - assert_false(get_many.called) - assert_equals(1, set_many.call_count) - assert_equals([('edX', 'simple'), ('edX', 'toy')], sorted(set_many.call_args[0][0].keys())) diff --git a/lms/envs/common.py b/lms/envs/common.py index cfd6fc34de..8654b5ebf5 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -364,6 +364,7 @@ TEMPLATE_LOADERS = ( MIDDLEWARE_CLASSES = ( 'contentserver.middleware.StaticContentServer', + 'request_cache.middleware.RequestCache', 'django_comment_client.middleware.AjaxExceptionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', diff --git a/lms/one_time_startup.py b/lms/one_time_startup.py index 6b3c45d60f..e1b1f79444 100644 --- a/lms/one_time_startup.py +++ b/lms/one_time_startup.py @@ -2,13 +2,15 @@ import logging from dogapi import dog_http_api, dog_stats_api from django.conf import settings from xmodule.modulestore.django import modulestore +from request_cache.middleware import RequestCache from django.core.cache import get_cache, InvalidCacheBackendError cache = get_cache('mongo_metadata_inheritance') for store_name in settings.MODULESTORE: store = modulestore(store_name) - store.metadata_inheritance_cache = cache + store.metadata_inheritance_cache_subsystem = cache + store.request_cache = RequestCache.get_request_cache() if hasattr(settings, 'DATADOG_API'): dog_http_api.api_key = settings.DATADOG_API From 446397b23bbcf625a82374c46773720e5e059e28 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Wed, 27 Mar 2013 23:12:00 -0400 Subject: [PATCH 070/128] remove unused thread.local() --- common/lib/xmodule/xmodule/modulestore/mongo.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/mongo.py b/common/lib/xmodule/xmodule/modulestore/mongo.py index b93a95c965..b388f81f7c 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo.py @@ -27,9 +27,6 @@ from .inheritance import own_metadata, INHERITABLE_METADATA, inherit_metadata log = logging.getLogger(__name__) -import threading -_mongo_metadata_request_cache_threadlocal = threading.local() - # TODO (cpennington): This code currently operates under the assumption that # there is only one revision for each item. Once we start versioning inside the CMS, # that assumption will have to change From d448aa1365b7aca300bfc67ba3684e162b09e4aa Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Wed, 27 Mar 2013 23:13:33 -0400 Subject: [PATCH 071/128] remove debug log messages --- common/lib/xmodule/xmodule/modulestore/mongo.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/mongo.py b/common/lib/xmodule/xmodule/modulestore/mongo.py index b388f81f7c..7bd61924fa 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo.py @@ -339,20 +339,16 @@ class MongoModuleStore(ModuleStoreBase): if not force_refresh: # see if we are first in the request cache (if present) if self.request_cache is not None and key in self.request_cache.data.get('metadata_inheritance', {}): - logging.debug('***** HIT IN REQUEST CACHE') return self.request_cache.data['metadata_inheritance'][key] # then look in any caching subsystem (e.g. memcached) if self.metadata_inheritance_cache_subsystem is not None: tree = self.metadata_inheritance_cache_subsystem.get(key, {}) - if tree: - logging.debug('***** HIT IN MEMCACHED') else: logging.warning('Running MongoModuleStore without a metadata_inheritance_cache_subsystem. This is OK in localdev and testing environment. Not OK in production.') if not tree: # if not in subsystem, or we are on force refresh, then we have to compute - logging.debug('***** COMPUTING METADATA') tree = self.compute_metadata_inheritance_tree(location) # now populate a request_cache, if available From 3f52261b5b44a7d76dd6cc2c71e7ab95ca10e3e5 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Wed, 27 Mar 2013 23:18:38 -0400 Subject: [PATCH 072/128] hmmm. actually, we should only write out to memcache if we've recomputed. Otherwise, a memcache hit will end up writing back to memcache... --- common/lib/xmodule/xmodule/modulestore/mongo.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/mongo.py b/common/lib/xmodule/xmodule/modulestore/mongo.py index 7bd61924fa..47e35cda93 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo.py @@ -350,8 +350,14 @@ class MongoModuleStore(ModuleStoreBase): if not tree: # if not in subsystem, or we are on force refresh, then we have to compute tree = self.compute_metadata_inheritance_tree(location) + + # now write out computed tree to caching subsystem (e.g. memcached), if available + if self.metadata_inheritance_cache_subsystem is not None: + self.metadata_inheritance_cache_subsystem.set(key, tree) - # now populate a request_cache, if available + # now populate a request_cache, if available. NOTE, we are outside of the + # scope of the above if: statement so that after a memcache hit, it'll get + # put into the request_cache if self.request_cache is not None: # we can't assume the 'metadatat_inheritance' part of the request cache dict has been # defined @@ -359,10 +365,6 @@ class MongoModuleStore(ModuleStoreBase): self.request_cache.data['metadata_inheritance'] = {} self.request_cache.data['metadata_inheritance'][key] = tree - # now write to caching subsystem (e.g. memcached), if available - if self.metadata_inheritance_cache_subsystem is not None: - self.metadata_inheritance_cache_subsystem.set(key, tree) - return tree def refresh_cached_metadata_inheritance_tree(self, location): From 7978c581dbb079e2097c95be69cb4e27f463c605 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Wed, 27 Mar 2013 17:45:36 -0400 Subject: [PATCH 073/128] 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 074/128] 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 075/128] 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 076/128] 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 077/128] 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 078/128] 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 079/128] 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 080/128] 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 7279f9c4601f7ee769ce689b1bcab4c63b94e377 Mon Sep 17 00:00:00 2001 From: cahrens Date: Thu, 28 Mar 2013 10:54:10 -0400 Subject: [PATCH 081/128] Bug fix for grading type not showing on course outline (#258). --- .../contentstore/features/subsection.feature | 8 ++++++++ cms/djangoapps/contentstore/features/subsection.py | 13 ++++++++++++- cms/templates/overview.html | 2 +- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/cms/djangoapps/contentstore/features/subsection.feature b/cms/djangoapps/contentstore/features/subsection.feature index 1be5f4aeb9..e913c6a4bf 100644 --- a/cms/djangoapps/contentstore/features/subsection.feature +++ b/cms/djangoapps/contentstore/features/subsection.feature @@ -17,6 +17,14 @@ Feature: Create Subsection And I click to edit the subsection name Then I see the complete subsection name with a quote in the editor + Scenario: Assign grading type to a subsection and verify it is still shown after refresh (bug #258) + Given I have opened a new course section in Studio + And I have added a new subsection + And I mark it as Homework + Then I see it marked as Homework + And I reload the page + Then I see it marked as Homework + @skip-phantom Scenario: Delete a subsection Given I have opened a new course section in Studio diff --git a/cms/djangoapps/contentstore/features/subsection.py b/cms/djangoapps/contentstore/features/subsection.py index 54f49f2fa6..4ab27fcb49 100644 --- a/cms/djangoapps/contentstore/features/subsection.py +++ b/cms/djangoapps/contentstore/features/subsection.py @@ -40,7 +40,7 @@ def i_click_to_edit_subsection_name(step): def i_see_complete_subsection_name_with_quote_in_editor(step): css = '.subsection-display-name-input' assert world.is_css_present(css) - assert_equal(world.browser.find_by_css(css).value, 'Subsection With "Quote"') + assert_equal(world.css_find(css).value, 'Subsection With "Quote"') @step('I have added a new subsection$') @@ -48,6 +48,17 @@ def i_have_added_a_new_subsection(step): add_subsection() +@step('I mark it as Homework$') +def i_mark_it_as_homework(step): + world.css_click('a.menu-toggle') + world.browser.click_link_by_text('Homework') + + +@step('I see it marked as Homework$') +def i_see_it_marked__as_homework(step): + assert_equal(world.css_find(".status-label").value, 'Homework') + + ############ ASSERTIONS ################### diff --git a/cms/templates/overview.html b/cms/templates/overview.html index 904f654717..d45a90093e 100644 --- a/cms/templates/overview.html +++ b/cms/templates/overview.html @@ -200,7 +200,7 @@ -
    +
    From 197f52539f791ec4b70e707864792ee5f0cc7eaa Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Thu, 28 Mar 2013 11:37:53 -0400 Subject: [PATCH 082/128] 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 083/128] 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 084/128] 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 085/128] 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 086/128] 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 087/128] 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 088/128] 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 089/128] 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 090/128] 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 091/128] 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 092/128] 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 39d666cd1306aab99735be13847765ecbf5f7fc0 Mon Sep 17 00:00:00 2001 From: Brian Wilson Date: Fri, 29 Mar 2013 01:27:36 -0400 Subject: [PATCH 093/128] modify AssignmentFormatGrader to act like a SingleSectionGrader on single sections --- common/lib/xmodule/xmodule/graders.py | 104 ++++++---- .../lib/xmodule/xmodule/tests/test_graders.py | 192 ++++++++++-------- 2 files changed, 173 insertions(+), 123 deletions(-) diff --git a/common/lib/xmodule/xmodule/graders.py b/common/lib/xmodule/xmodule/graders.py index 35318f4f1e..862da791c0 100644 --- a/common/lib/xmodule/xmodule/graders.py +++ b/common/lib/xmodule/xmodule/graders.py @@ -45,8 +45,9 @@ def invalid_args(func, argdict): Given a function and a dictionary of arguments, returns a set of arguments from argdict that aren't accepted by func """ - args, varargs, keywords, defaults = inspect.getargspec(func) - if keywords: return set() # All accepted + args, _, keywords, _ = inspect.getargspec(func) + if keywords: + return set() # All accepted return set(argdict) - set(args) @@ -119,7 +120,7 @@ class CourseGrader(object): that has the matching section format. The grader outputs a dictionary with the following keys: - - percent: Contaisn a float value, which is the final percentage score for the student. + - percent: Contains a float value, which is the final percentage score for the student. - section_breakdown: This is a list of dictionaries which provide details on sections that were graded. These are used for display in a graph or chart. The format for a section_breakdown dictionary is explained below. @@ -150,6 +151,7 @@ class CourseGrader(object): @abc.abstractmethod def grade(self, grade_sheet, generate_random_scores=False): + '''Given a grade sheet, return a dict containing grading information''' raise NotImplementedError @@ -158,7 +160,10 @@ class WeightedSubsectionsGrader(CourseGrader): This grader takes a list of tuples containing (grader, category_name, weight) and computes a final grade by totalling the contribution of each sub grader and multiplying it by the given weight. For example, the sections may be - [ (homeworkGrader, "Homework", 0.15), (labGrader, "Labs", 0.15), (midtermGrader, "Midterm", 0.30), (finalGrader, "Final", 0.40) ] + + [ (homeworkGrader, "Homework", 0.15), (labGrader, "Labs", 0.15), (midtermGrader, "Midterm", 0.30), + (finalGrader, "Final", 0.40) ] + All items in section_breakdown for each subgrader will be combined. A grade_breakdown will be composed using the score from each grader. @@ -177,12 +182,12 @@ class WeightedSubsectionsGrader(CourseGrader): for subgrader, category, weight in self.sections: subgrade_result = subgrader.grade(grade_sheet, generate_random_scores) - weightedPercent = subgrade_result['percent'] * weight - section_detail = "{0} = {1:.1%} of a possible {2:.0%}".format(category, weightedPercent, weight) + weighted_percent = subgrade_result['percent'] * weight + section_detail = "{0} = {1:.1%} of a possible {2:.0%}".format(category, weighted_percent, weight) - total_percent += weightedPercent + total_percent += weighted_percent section_breakdown += subgrade_result['section_breakdown'] - grade_breakdown.append({'percent': weightedPercent, 'detail': section_detail, 'category': category}) + grade_breakdown.append({'percent': weighted_percent, 'detail': section_detail, 'category': category}) return {'percent': total_percent, 'section_breakdown': section_breakdown, @@ -203,32 +208,33 @@ class SingleSectionGrader(CourseGrader): self.category = category or name def grade(self, grade_sheet, generate_random_scores=False): - foundScore = None + found_score = None if self.type in grade_sheet: for score in grade_sheet[self.type]: if score.section == self.name: - foundScore = score + found_score = score break - if foundScore or generate_random_scores: + if found_score or generate_random_scores: if generate_random_scores: # for debugging! earned = random.randint(2, 15) possible = random.randint(earned, 15) else: # We found the score - earned = foundScore.earned - possible = foundScore.possible + earned = found_score.earned + possible = found_score.possible percent = earned / float(possible) detail = "{name} - {percent:.0%} ({earned:.3n}/{possible:.3n})".format(name=self.name, - percent=percent, - earned=float(earned), - possible=float(possible)) + percent=percent, + earned=float(earned), + possible=float(possible)) else: percent = 0.0 detail = "{name} - 0% (?/?)".format(name=self.name) - breakdown = [{'percent': percent, 'label': self.short_label, 'detail': detail, 'category': self.category, 'prominent': True}] + breakdown = [{'percent': percent, 'label': self.short_label, + 'detail': detail, 'category': self.category, 'prominent': True}] return {'percent': percent, 'section_breakdown': breakdown, @@ -250,6 +256,13 @@ class AssignmentFormatGrader(CourseGrader): show_only_average is to suppress the display of each assignment in this grader and instead only show the total score of this grader in the breakdown. + hide_average is to suppress the display of the total score in this grader and instead + only show each assignment in this grader in the breakdown. + + If there is only a single assignment in this grader, then it acts like a SingleSectionGrader + and returns only one entry for the grader. Since the assignment and the total are the same, + the total is returned but is not labeled as an average. + category should be presentable to the user, but may not appear. When the grade breakdown is displayed, scores from the same category will be similar (for example, by color). @@ -263,7 +276,8 @@ class AssignmentFormatGrader(CourseGrader): min_count = 2 would produce the labels "Assignment 3", "Assignment 4" """ - def __init__(self, type, min_count, drop_count, category=None, section_type=None, short_label=None, show_only_average=False, hide_average=False, starting_index=1): + def __init__(self, type, min_count, drop_count, category=None, section_type=None, short_label=None, + show_only_average=False, hide_average=False, starting_index=1): self.type = type self.min_count = min_count self.drop_count = drop_count @@ -275,7 +289,8 @@ class AssignmentFormatGrader(CourseGrader): self.hide_average = hide_average def grade(self, grade_sheet, generate_random_scores=False): - def totalWithDrops(breakdown, drop_count): + def total_with_drops(breakdown, drop_count): + '''calculates total score for a section while dropping lowest scores''' #create an array of tuples with (index, mark), sorted by mark['percent'] descending sorted_breakdown = sorted(enumerate(breakdown), key=lambda x: -x[1]['percent']) # A list of the indices of the dropped scores @@ -308,33 +323,50 @@ class AssignmentFormatGrader(CourseGrader): section_name = scores[i].section percentage = earned / float(possible) - summary = "{section_type} {index} - {name} - {percent:.0%} ({earned:.3n}/{possible:.3n})".format(index=i + self.starting_index, - section_type=self.section_type, - name=section_name, - percent=percentage, - earned=float(earned), - possible=float(possible)) + summary_format = "{section_type} {index} - {name} - {percent:.0%} ({earned:.3n}/{possible:.3n})" + summary = summary_format.format(index=i + self.starting_index, + section_type=self.section_type, + name=section_name, + percent=percentage, + earned=float(earned), + possible=float(possible)) else: percentage = 0 - summary = "{section_type} {index} Unreleased - 0% (?/?)".format(index=i + self.starting_index, section_type=self.section_type) + summary = "{section_type} {index} Unreleased - 0% (?/?)".format(index=i + self.starting_index, + section_type=self.section_type) - short_label = "{short_label} {index:02d}".format(index=i + self.starting_index, short_label=self.short_label) + short_label = "{short_label} {index:02d}".format(index=i + self.starting_index, + short_label=self.short_label) - breakdown.append({'percent': percentage, 'label': short_label, 'detail': summary, 'category': self.category}) + breakdown.append({'percent': percentage, 'label': short_label, + 'detail': summary, 'category': self.category}) - total_percent, dropped_indices = totalWithDrops(breakdown, self.drop_count) + total_percent, dropped_indices = total_with_drops(breakdown, self.drop_count) for dropped_index in dropped_indices: - breakdown[dropped_index]['mark'] = {'detail': "The lowest {drop_count} {section_type} scores are dropped.".format(drop_count=self.drop_count, section_type=self.section_type)} + breakdown[dropped_index]['mark'] = {'detail': "The lowest {drop_count} {section_type} scores are dropped." + .format(drop_count=self.drop_count, section_type=self.section_type)} - total_detail = "{section_type} Average = {percent:.0%}".format(percent=total_percent, section_type=self.section_type) - total_label = "{short_label} Avg".format(short_label=self.short_label) + if len(breakdown) == 1: + # if there is only one entry in a section, suppress the existing individual entry and the average, + # and just display a single entry for the section. That way it acts automatically like a + # SingleSectionGrader. + total_detail = "{section_type} = {percent:.0%}".format(percent=total_percent, + section_type=self.section_type) + total_label = "{short_label}".format(short_label=self.short_label) + breakdown = [{'percent': total_percent, 'label': total_label, + 'detail': total_detail, 'category': self.category, 'prominent': True}, ] + else: + total_detail = "{section_type} Average = {percent:.0%}".format(percent=total_percent, + section_type=self.section_type) + total_label = "{short_label} Avg".format(short_label=self.short_label) - if self.show_only_average: - breakdown = [] + if self.show_only_average: + breakdown = [] - if not self.hide_average: - breakdown.append({'percent': total_percent, 'label': total_label, 'detail': total_detail, 'category': self.category, 'prominent': True}) + if not self.hide_average: + breakdown.append({'percent': total_percent, 'label': total_label, + 'detail': total_detail, 'category': self.category, 'prominent': True}) return {'percent': total_percent, 'section_breakdown': breakdown, diff --git a/common/lib/xmodule/xmodule/tests/test_graders.py b/common/lib/xmodule/xmodule/tests/test_graders.py index 27416b1d5c..1a9ba50dc4 100644 --- a/common/lib/xmodule/xmodule/tests/test_graders.py +++ b/common/lib/xmodule/xmodule/tests/test_graders.py @@ -6,32 +6,34 @@ from xmodule.graders import Score, aggregate_scores class GradesheetTest(unittest.TestCase): + '''Tests the aggregate_scores method''' def test_weighted_grading(self): scores = [] Score.__sub__ = lambda me, other: (me.earned - other.earned) + (me.possible - other.possible) - all, graded = aggregate_scores(scores) - self.assertEqual(all, Score(earned=0, possible=0, graded=False, section="summary")) - self.assertEqual(graded, Score(earned=0, possible=0, graded=True, section="summary")) + all_total, graded_total = aggregate_scores(scores) + self.assertEqual(all_total, Score(earned=0, possible=0, graded=False, section="summary")) + self.assertEqual(graded_total, Score(earned=0, possible=0, graded=True, section="summary")) scores.append(Score(earned=0, possible=5, graded=False, section="summary")) - all, graded = aggregate_scores(scores) - self.assertEqual(all, Score(earned=0, possible=5, graded=False, section="summary")) - self.assertEqual(graded, Score(earned=0, possible=0, graded=True, section="summary")) + all_total, graded_total = aggregate_scores(scores) + self.assertEqual(all_total, Score(earned=0, possible=5, graded=False, section="summary")) + self.assertEqual(graded_total, Score(earned=0, possible=0, graded=True, section="summary")) scores.append(Score(earned=3, possible=5, graded=True, section="summary")) - all, graded = aggregate_scores(scores) - self.assertAlmostEqual(all, Score(earned=3, possible=10, graded=False, section="summary")) - self.assertAlmostEqual(graded, Score(earned=3, possible=5, graded=True, section="summary")) + all_total, graded_total = aggregate_scores(scores) + self.assertAlmostEqual(all_total, Score(earned=3, possible=10, graded=False, section="summary")) + self.assertAlmostEqual(graded_total, Score(earned=3, possible=5, graded=True, section="summary")) scores.append(Score(earned=2, possible=5, graded=True, section="summary")) - all, graded = aggregate_scores(scores) - self.assertAlmostEqual(all, Score(earned=5, possible=15, graded=False, section="summary")) - self.assertAlmostEqual(graded, Score(earned=5, possible=10, graded=True, section="summary")) + all_total, graded_total = aggregate_scores(scores) + self.assertAlmostEqual(all_total, Score(earned=5, possible=15, graded=False, section="summary")) + self.assertAlmostEqual(graded_total, Score(earned=5, possible=10, graded=True, section="summary")) class GraderTest(unittest.TestCase): + '''Tests grader implementations''' empty_gradesheet = { } @@ -44,136 +46,152 @@ class GraderTest(unittest.TestCase): test_gradesheet = { 'Homework': [Score(earned=2, possible=20.0, graded=True, section='hw1'), - Score(earned=16, possible=16.0, graded=True, section='hw2')], - #The dropped scores should be from the assignments that don't exist yet + Score(earned=16, possible=16.0, graded=True, section='hw2')], + # The dropped scores should be from the assignments that don't exist yet 'Lab': [Score(earned=1, possible=2.0, graded=True, section='lab1'), # Dropped - Score(earned=1, possible=1.0, graded=True, section='lab2'), - Score(earned=1, possible=1.0, graded=True, section='lab3'), - Score(earned=5, possible=25.0, graded=True, section='lab4'), # Dropped - Score(earned=3, possible=4.0, graded=True, section='lab5'), # Dropped - Score(earned=6, possible=7.0, graded=True, section='lab6'), - Score(earned=5, possible=6.0, graded=True, section='lab7')], + Score(earned=1, possible=1.0, graded=True, section='lab2'), + Score(earned=1, possible=1.0, graded=True, section='lab3'), + Score(earned=5, possible=25.0, graded=True, section='lab4'), # Dropped + Score(earned=3, possible=4.0, graded=True, section='lab5'), # Dropped + Score(earned=6, possible=7.0, graded=True, section='lab6'), + Score(earned=5, possible=6.0, graded=True, section='lab7')], 'Midterm': [Score(earned=50.5, possible=100, graded=True, section="Midterm Exam"), ], } - def test_SingleSectionGrader(self): - midtermGrader = graders.SingleSectionGrader("Midterm", "Midterm Exam") - lab4Grader = graders.SingleSectionGrader("Lab", "lab4") - badLabGrader = graders.SingleSectionGrader("Lab", "lab42") + def test_single_section_grader(self): + midterm_grader = graders.SingleSectionGrader("Midterm", "Midterm Exam") + lab4_grader = graders.SingleSectionGrader("Lab", "lab4") + bad_lab_grader = graders.SingleSectionGrader("Lab", "lab42") - for graded in [midtermGrader.grade(self.empty_gradesheet), - midtermGrader.grade(self.incomplete_gradesheet), - badLabGrader.grade(self.test_gradesheet)]: + for graded in [midterm_grader.grade(self.empty_gradesheet), + midterm_grader.grade(self.incomplete_gradesheet), + bad_lab_grader.grade(self.test_gradesheet)]: self.assertEqual(len(graded['section_breakdown']), 1) self.assertEqual(graded['percent'], 0.0) - graded = midtermGrader.grade(self.test_gradesheet) + graded = midterm_grader.grade(self.test_gradesheet) self.assertAlmostEqual(graded['percent'], 0.505) self.assertEqual(len(graded['section_breakdown']), 1) - graded = lab4Grader.grade(self.test_gradesheet) + graded = lab4_grader.grade(self.test_gradesheet) self.assertAlmostEqual(graded['percent'], 0.2) self.assertEqual(len(graded['section_breakdown']), 1) - def test_AssignmentFormatGrader(self): - homeworkGrader = graders.AssignmentFormatGrader("Homework", 12, 2) - noDropGrader = graders.AssignmentFormatGrader("Homework", 12, 0) - #Even though the minimum number is 3, this should grade correctly when 7 assignments are found - overflowGrader = graders.AssignmentFormatGrader("Lab", 3, 2) - labGrader = graders.AssignmentFormatGrader("Lab", 7, 3) + def test_assignment_format_grader(self): + homework_grader = graders.AssignmentFormatGrader("Homework", 12, 2) + no_drop_grader = graders.AssignmentFormatGrader("Homework", 12, 0) + # Even though the minimum number is 3, this should grade correctly when 7 assignments are found + overflow_grader = graders.AssignmentFormatGrader("Lab", 3, 2) + lab_grader = graders.AssignmentFormatGrader("Lab", 7, 3) - #Test the grading of an empty gradesheet - for graded in [homeworkGrader.grade(self.empty_gradesheet), - noDropGrader.grade(self.empty_gradesheet), - homeworkGrader.grade(self.incomplete_gradesheet), - noDropGrader.grade(self.incomplete_gradesheet)]: + # Test the grading of an empty gradesheet + for graded in [homework_grader.grade(self.empty_gradesheet), + no_drop_grader.grade(self.empty_gradesheet), + homework_grader.grade(self.incomplete_gradesheet), + no_drop_grader.grade(self.incomplete_gradesheet)]: self.assertAlmostEqual(graded['percent'], 0.0) - #Make sure the breakdown includes 12 sections, plus one summary + # Make sure the breakdown includes 12 sections, plus one summary self.assertEqual(len(graded['section_breakdown']), 12 + 1) - graded = homeworkGrader.grade(self.test_gradesheet) + graded = homework_grader.grade(self.test_gradesheet) self.assertAlmostEqual(graded['percent'], 0.11) # 100% + 10% / 10 assignments self.assertEqual(len(graded['section_breakdown']), 12 + 1) - graded = noDropGrader.grade(self.test_gradesheet) + graded = no_drop_grader.grade(self.test_gradesheet) self.assertAlmostEqual(graded['percent'], 0.0916666666666666) # 100% + 10% / 12 assignments self.assertEqual(len(graded['section_breakdown']), 12 + 1) - graded = overflowGrader.grade(self.test_gradesheet) + graded = overflow_grader.grade(self.test_gradesheet) self.assertAlmostEqual(graded['percent'], 0.8880952380952382) # 100% + 10% / 5 assignments self.assertEqual(len(graded['section_breakdown']), 7 + 1) - graded = labGrader.grade(self.test_gradesheet) + graded = lab_grader.grade(self.test_gradesheet) self.assertAlmostEqual(graded['percent'], 0.9226190476190477) self.assertEqual(len(graded['section_breakdown']), 7 + 1) - def test_WeightedSubsectionsGrader(self): - #First, a few sub graders - homeworkGrader = graders.AssignmentFormatGrader("Homework", 12, 2) - labGrader = graders.AssignmentFormatGrader("Lab", 7, 3) - midtermGrader = graders.SingleSectionGrader("Midterm", "Midterm Exam") + def test_assignment_format_grader_on_single_section_entry(self): + midterm_grader = graders.AssignmentFormatGrader("Midterm", 1, 0) + # Test the grading on a section with one item: + for graded in [midterm_grader.grade(self.empty_gradesheet), + midterm_grader.grade(self.incomplete_gradesheet)]: + self.assertAlmostEqual(graded['percent'], 0.0) + # Make sure the breakdown includes just the one summary + self.assertEqual(len(graded['section_breakdown']), 0 + 1) + self.assertEqual(graded['section_breakdown'][0]['label'], 'Midterm') - weightedGrader = graders.WeightedSubsectionsGrader([(homeworkGrader, homeworkGrader.category, 0.25), - (labGrader, labGrader.category, 0.25), - (midtermGrader, midtermGrader.category, 0.5)]) + graded = midterm_grader.grade(self.test_gradesheet) + self.assertAlmostEqual(graded['percent'], 0.505) + self.assertEqual(len(graded['section_breakdown']), 0 + 1) - overOneWeightsGrader = graders.WeightedSubsectionsGrader([(homeworkGrader, homeworkGrader.category, 0.5), - (labGrader, labGrader.category, 0.5), - (midtermGrader, midtermGrader.category, 0.5)]) + def test_weighted_subsections_grader(self): + # First, a few sub graders + homework_grader = graders.AssignmentFormatGrader("Homework", 12, 2) + lab_grader = graders.AssignmentFormatGrader("Lab", 7, 3) + # phasing out the use of SingleSectionGraders, and instead using AssignmentFormatGraders that + # will act like SingleSectionGraders on single sections. + midterm_grader = graders.AssignmentFormatGrader("Midterm", 1, 0) - #The midterm should have all weight on this one - zeroWeightsGrader = graders.WeightedSubsectionsGrader([(homeworkGrader, homeworkGrader.category, 0.0), - (labGrader, labGrader.category, 0.0), - (midtermGrader, midtermGrader.category, 0.5)]) + weighted_grader = graders.WeightedSubsectionsGrader([(homework_grader, homework_grader.category, 0.25), + (lab_grader, lab_grader.category, 0.25), + (midterm_grader, midterm_grader.category, 0.5)]) - #This should always have a final percent of zero - allZeroWeightsGrader = graders.WeightedSubsectionsGrader([(homeworkGrader, homeworkGrader.category, 0.0), - (labGrader, labGrader.category, 0.0), - (midtermGrader, midtermGrader.category, 0.0)]) + over_one_weights_grader = graders.WeightedSubsectionsGrader([(homework_grader, homework_grader.category, 0.5), + (lab_grader, lab_grader.category, 0.5), + (midterm_grader, midterm_grader.category, 0.5)]) - emptyGrader = graders.WeightedSubsectionsGrader([]) + # The midterm should have all weight on this one + zero_weights_grader = graders.WeightedSubsectionsGrader([(homework_grader, homework_grader.category, 0.0), + (lab_grader, lab_grader.category, 0.0), + (midterm_grader, midterm_grader.category, 0.5)]) - graded = weightedGrader.grade(self.test_gradesheet) + # This should always have a final percent of zero + all_zero_weights_grader = graders.WeightedSubsectionsGrader([(homework_grader, homework_grader.category, 0.0), + (lab_grader, lab_grader.category, 0.0), + (midterm_grader, midterm_grader.category, 0.0)]) + + empty_grader = graders.WeightedSubsectionsGrader([]) + + graded = weighted_grader.grade(self.test_gradesheet) self.assertAlmostEqual(graded['percent'], 0.5106547619047619) self.assertEqual(len(graded['section_breakdown']), (12 + 1) + (7 + 1) + 1) self.assertEqual(len(graded['grade_breakdown']), 3) - graded = overOneWeightsGrader.grade(self.test_gradesheet) + graded = over_one_weights_grader.grade(self.test_gradesheet) self.assertAlmostEqual(graded['percent'], 0.7688095238095238) self.assertEqual(len(graded['section_breakdown']), (12 + 1) + (7 + 1) + 1) self.assertEqual(len(graded['grade_breakdown']), 3) - graded = zeroWeightsGrader.grade(self.test_gradesheet) + graded = zero_weights_grader.grade(self.test_gradesheet) self.assertAlmostEqual(graded['percent'], 0.2525) self.assertEqual(len(graded['section_breakdown']), (12 + 1) + (7 + 1) + 1) self.assertEqual(len(graded['grade_breakdown']), 3) - graded = allZeroWeightsGrader.grade(self.test_gradesheet) + graded = all_zero_weights_grader.grade(self.test_gradesheet) self.assertAlmostEqual(graded['percent'], 0.0) self.assertEqual(len(graded['section_breakdown']), (12 + 1) + (7 + 1) + 1) self.assertEqual(len(graded['grade_breakdown']), 3) - for graded in [weightedGrader.grade(self.empty_gradesheet), - weightedGrader.grade(self.incomplete_gradesheet), - zeroWeightsGrader.grade(self.empty_gradesheet), - allZeroWeightsGrader.grade(self.empty_gradesheet)]: + for graded in [weighted_grader.grade(self.empty_gradesheet), + weighted_grader.grade(self.incomplete_gradesheet), + zero_weights_grader.grade(self.empty_gradesheet), + all_zero_weights_grader.grade(self.empty_gradesheet)]: self.assertAlmostEqual(graded['percent'], 0.0) self.assertEqual(len(graded['section_breakdown']), (12 + 1) + (7 + 1) + 1) self.assertEqual(len(graded['grade_breakdown']), 3) - graded = emptyGrader.grade(self.test_gradesheet) + graded = empty_grader.grade(self.test_gradesheet) self.assertAlmostEqual(graded['percent'], 0.0) self.assertEqual(len(graded['section_breakdown']), 0) self.assertEqual(len(graded['grade_breakdown']), 0) - def test_graderFromConf(self): + def test_grader_from_conf(self): - #Confs always produce a graders.WeightedSubsectionsGrader, so we test this by repeating the test - #in test_graders.WeightedSubsectionsGrader, but generate the graders with confs. + # Confs always produce a graders.WeightedSubsectionsGrader, so we test this by repeating the test + # in test_graders.WeightedSubsectionsGrader, but generate the graders with confs. - weightedGrader = graders.grader_from_conf([ + weighted_grader = graders.grader_from_conf([ { 'type': "Homework", 'min_count': 12, @@ -196,25 +214,25 @@ class GraderTest(unittest.TestCase): }, ]) - emptyGrader = graders.grader_from_conf([]) + empty_grader = graders.grader_from_conf([]) - graded = weightedGrader.grade(self.test_gradesheet) + graded = weighted_grader.grade(self.test_gradesheet) self.assertAlmostEqual(graded['percent'], 0.5106547619047619) self.assertEqual(len(graded['section_breakdown']), (12 + 1) + (7 + 1) + 1) self.assertEqual(len(graded['grade_breakdown']), 3) - graded = emptyGrader.grade(self.test_gradesheet) + graded = empty_grader.grade(self.test_gradesheet) self.assertAlmostEqual(graded['percent'], 0.0) self.assertEqual(len(graded['section_breakdown']), 0) self.assertEqual(len(graded['grade_breakdown']), 0) - #Test that graders can also be used instead of lists of dictionaries - homeworkGrader = graders.AssignmentFormatGrader("Homework", 12, 2) - homeworkGrader2 = graders.grader_from_conf(homeworkGrader) + # Test that graders can also be used instead of lists of dictionaries + homework_grader = graders.AssignmentFormatGrader("Homework", 12, 2) + homework_grader2 = graders.grader_from_conf(homework_grader) - graded = homeworkGrader2.grade(self.test_gradesheet) + graded = homework_grader2.grade(self.test_gradesheet) self.assertAlmostEqual(graded['percent'], 0.11) self.assertEqual(len(graded['section_breakdown']), 12 + 1) - #TODO: How do we test failure cases? The parser only logs an error when - #it can't parse something. Maybe it should throw exceptions? + # TODO: How do we test failure cases? The parser only logs an error when + # it can't parse something. Maybe it should throw exceptions? From bbb53a17f8186df5ef2a8c6e7ed559d3147354f8 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Fri, 29 Mar 2013 09:58:03 -0400 Subject: [PATCH 094/128] 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 095/128] 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 096/128] 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 097/128] 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 098/128] 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 4f9d18df8ccd00135298a6d728d4f2ade3725424 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Fri, 29 Mar 2013 11:59:08 -0400 Subject: [PATCH 099/128] Wrote unit tests to verify bug fix in https://github.com/MITx/mitx/pull/1764 --- .../xmodule/xmodule/tests/test_capa_module.py | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py index d2458cb3d0..1b923c13f8 100644 --- a/common/lib/xmodule/xmodule/tests/test_capa_module.py +++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py @@ -859,3 +859,62 @@ class CapaModuleTest(unittest.TestCase): # Expect that the module has created a new dummy problem with the error self.assertNotEqual(original_problem, module.lcp) + + + def test_random_seed_no_change(self): + rerandomize_options = ['never', 'per_student', 'always', 'onreset'] + + for rerandomize in rerandomize_options: + module = CapaFactory.create(rerandomize=rerandomize) + + # Get the seed + # module.seed isn't set until the problem is checked/saved, + # so we access the capa problem seed directly + seed = module.lcp.seed + self.assertTrue(seed is not None) + + if rerandomize == 'never': + self.assertEqual(seed, 1) + + # Check the problem + get_request_dict = { CapaFactory.input_key(): '3.14'} + module.check_problem(get_request_dict) + + # Expect that the seed is the same + self.assertEqual(seed, module.seed) + + # Save the problem + module.save_problem(get_request_dict) + + # Expect that the seed is the same + self.assertEqual(seed, module.seed) + + def test_random_seed_with_reset(self): + rerandomize_options = ['never', 'per_student', 'always', 'onreset'] + + for rerandomize in rerandomize_options: + module = CapaFactory.create(rerandomize=rerandomize) + + # Get the seed + seed = module.lcp.seed + + # Reset the problem + module.reset_problem({}) + + # We do NOT want the seed to reset if rerandomize + # is set to 'never' -- it should still be 1 + # The seed also stays the same if we're randomizing + # 'per_student': the same student should see the same problem + if rerandomize in ['never', 'per_student']: + self.assertEqual(seed, module.seed) + + # Otherwise, we expect the seed to change + # to another valid seed + else: + + # After we save, the seed is stored in the module + get_request_dict = { CapaFactory.input_key(): '3.14'} + module.save_problem(get_request_dict) + + self.assertEqual(seed, module.seed) + self.assertTrue(module.seed is not None) From 50fd7ee7a49ac9870139b62e242e8962ec90f70d Mon Sep 17 00:00:00 2001 From: Brian Wilson Date: Fri, 29 Mar 2013 12:01:10 -0400 Subject: [PATCH 100/128] fix test --- lms/djangoapps/instructor/tests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lms/djangoapps/instructor/tests.py b/lms/djangoapps/instructor/tests.py index 512e81e302..fd8e652997 100644 --- a/lms/djangoapps/instructor/tests.py +++ b/lms/djangoapps/instructor/tests.py @@ -74,8 +74,8 @@ class TestInstructorDashboardGradeDownloadCSV(LoginEnrollmentTestCase): # All the not-actually-in-the-course hw and labs come from the # default grading policy string in graders.py - expected_body = '''"ID","Username","Full Name","edX email","External email","HW 01","HW 02","HW 03","HW 04","HW 05","HW 06","HW 07","HW 08","HW 09","HW 10","HW 11","HW 12","HW Avg","Lab 01","Lab 02","Lab 03","Lab 04","Lab 05","Lab 06","Lab 07","Lab 08","Lab 09","Lab 10","Lab 11","Lab 12","Lab Avg","Midterm 01","Midterm Avg","Final 01","Final Avg" -"2","u2","Fred Weasley","view2@test.com","","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0" + expected_body = '''"ID","Username","Full Name","edX email","External email","HW 01","HW 02","HW 03","HW 04","HW 05","HW 06","HW 07","HW 08","HW 09","HW 10","HW 11","HW 12","HW Avg","Lab 01","Lab 02","Lab 03","Lab 04","Lab 05","Lab 06","Lab 07","Lab 08","Lab 09","Lab 10","Lab 11","Lab 12","Lab Avg","Midterm","Final" +"2","u2","Fred Weasley","view2@test.com","","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0" ''' self.assertEqual(body, expected_body, msg) From 17adc986bd89d1612a06dc43bfd1f6dcbe206b8b Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Fri, 29 Mar 2013 12:16:27 -0400 Subject: [PATCH 101/128] 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 102/128] 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 103/128] 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 104/128] 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 105/128] 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 106/128] 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 107/128] 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 108/128] 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 109/128] 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 110/128] 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 111/128] 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 29efa842050ef26529e59cf006bf38f12ef28c4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Andr=C3=A9s=20Rocha?= Date: Fri, 29 Mar 2013 18:34:02 -0400 Subject: [PATCH 112/128] Enable dev env support for sass source maps Change the parameters of the sass compiler to output source maps. Google Chrome uses the maps in the debugger to show the sass or scss file that originated the style for a particular element. More information here: http://fonicmonkey.net/2013/03/25/native-sass-scss-source-map-support-in-chrome-and-rails/ --- cms/envs/dev.py | 4 ++++ lms/envs/dev.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/cms/envs/dev.py b/cms/envs/dev.py index b8d4d14b9e..c4465a0e06 100644 --- a/cms/envs/dev.py +++ b/cms/envs/dev.py @@ -112,6 +112,10 @@ CACHE_TIMEOUT = 0 # Dummy secret key for dev SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd' +################################ PIPELINE ################################# + +PIPELINE_SASS_ARGUMENTS = '--debug-info --require {proj_dir}/static/sass/bourbon/lib/bourbon.rb'.format(proj_dir=PROJECT_ROOT) + ################################ DEBUG TOOLBAR ################################# INSTALLED_APPS += ('debug_toolbar', 'debug_toolbar_mongo') MIDDLEWARE_CLASSES += ('django_comment_client.utils.QueryCountDebugMiddleware', diff --git a/lms/envs/dev.py b/lms/envs/dev.py index 24bad58459..8363f744a0 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -221,7 +221,7 @@ FILE_UPLOAD_HANDLERS = ( ########################### PIPELINE ################################# -PIPELINE_SASS_ARGUMENTS = '-r {proj_dir}/static/sass/bourbon/lib/bourbon.rb'.format(proj_dir=PROJECT_ROOT) +PIPELINE_SASS_ARGUMENTS = '--debug-info --require {proj_dir}/static/sass/bourbon/lib/bourbon.rb'.format(proj_dir=PROJECT_ROOT) ########################## PEARSON TESTING ########################### MITX_FEATURES['ENABLE_PEARSON_HACK_TEST'] = True From 23d96b25333fe1047151e7d64db30099d5d90284 Mon Sep 17 00:00:00 2001 From: Brian Wilson Date: Fri, 29 Mar 2013 21:16:20 -0400 Subject: [PATCH 113/128] 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 114/128] 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: