From 66657db0bee2e26a3966e6ca04b2483f88918754 Mon Sep 17 00:00:00 2001 From: Peter Baratta Date: Mon, 4 Mar 2013 09:49:41 -0700 Subject: [PATCH 001/264] 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/264] 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 4d136f8d3bc1d247941b68f0af8b2caac4e947b0 Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Thu, 7 Mar 2013 11:51:45 -0500 Subject: [PATCH 003/264] fixed annotation tooltip styling issue in studio --- common/lib/xmodule/xmodule/css/annotatable/display.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/common/lib/xmodule/xmodule/css/annotatable/display.scss b/common/lib/xmodule/xmodule/css/annotatable/display.scss index 308b379ec1..c462d4806e 100644 --- a/common/lib/xmodule/xmodule/css/annotatable/display.scss +++ b/common/lib/xmodule/xmodule/css/annotatable/display.scss @@ -127,6 +127,7 @@ $body-font-size: em(14); font-weight: 400; padding: 0 10px 10px 10px; background-color: transparent; + border-color: transparent; } p { color: inherit; From f5c3775b5dcbb8b16e6a0fcd27fd8b835516a56e Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Thu, 7 Mar 2013 12:21:47 -0500 Subject: [PATCH 004/264] fixed the annotation tooltip line height so it is the same in studio and the lms. --- common/lib/xmodule/xmodule/css/annotatable/display.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/common/lib/xmodule/xmodule/css/annotatable/display.scss b/common/lib/xmodule/xmodule/css/annotatable/display.scss index c462d4806e..b5739b28fc 100644 --- a/common/lib/xmodule/xmodule/css/annotatable/display.scss +++ b/common/lib/xmodule/xmodule/css/annotatable/display.scss @@ -144,6 +144,7 @@ $body-font-size: em(14); margin: 0px 0px 10px 0; max-height: 225px; overflow: auto; + line-height: normal; } .annotatable-reply { display: block; From 60b060263c15bb90fc658349224c50158609e31d Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Thu, 7 Mar 2013 16:02:22 -0500 Subject: [PATCH 005/264] refactor highlight css to prevent issues with cascade --- common/lib/xmodule/xmodule/css/annotatable/display.scss | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/common/lib/xmodule/xmodule/css/annotatable/display.scss b/common/lib/xmodule/xmodule/css/annotatable/display.scss index b5739b28fc..f8ae779b8c 100644 --- a/common/lib/xmodule/xmodule/css/annotatable/display.scss +++ b/common/lib/xmodule/xmodule/css/annotatable/display.scss @@ -55,6 +55,7 @@ $body-font-size: em(14); display: inline; cursor: pointer; + $highlight_index: 0; @each $highlight in ( (yellow rgba(255,255,10,0.3) rgba(255,255,10,0.9)), (red rgba(178,19,16,0.3) rgba(178,19,16,0.9)), @@ -62,12 +63,13 @@ $body-font-size: em(14); (green rgba(25,255,132,0.3) rgba(25,255,132,0.9)), (blue rgba(35,163,255,0.3) rgba(35,163,255,0.9)), (purple rgba(115,9,178,0.3) rgba(115,9,178,0.9))) { - + + $highlight_index: $highlight_index + 1; $marker: nth($highlight,1); $color: nth($highlight,2); $selected_color: nth($highlight,3); - @if $marker == yellow { + @if $highlight_index == 1 { &.highlight { background-color: $color; &.selected { background-color: $selected_color; } @@ -167,5 +169,3 @@ $body-font-size: em(14); border-top-color: rgba(0, 0, 0, .85); } } - - From 49f85211fa5c5550897d25aceb786ac82d1259ee Mon Sep 17 00:00:00 2001 From: Peter Baratta Date: Fri, 8 Mar 2013 03:39:34 -0700 Subject: [PATCH 006/264] 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 094458dd6f0e4437a71dcbcd990d31286725dc16 Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Mon, 11 Mar 2013 16:19:36 -0400 Subject: [PATCH 007/264] Modified tooltip positioning on non-overlapping annotation spans. --- .../xmodule/js/src/annotatable/display.coffee | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee index 2ad49ae6d7..523b0e99cf 100644 --- a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee @@ -75,6 +75,7 @@ class @Annotatable classes: 'ui-tooltip-annotatable' events: show: @onShowTip + move: @onMoveTip onClickToggleAnnotations: (e) => @toggleAnnotations() @@ -87,6 +88,40 @@ class @Annotatable onShowTip: (event, api) => event.preventDefault() if @annotationsHidden + onMoveTip: (event, api, position) => + ### + This method handles an edge case in which a tooltip is displayed above + a non-overlapping span like this: + + (( TOOLTIP )) + \/ + text text text ... text text text ...... + + + The problem is that the tooltip looks disconnected from both spans, so + we should re-position the tooltip to appear above the span. + ### + + tip = api.elements.tooltip + adjust_y = api.options.position?.adjust?.y || 0 + target = api.elements.target + rects = $(target).get(0).getClientRects() + is_non_overlapping = (rects?.length == 2 and rects[0].left > rects[1].right) + + if is_non_overlapping + focus_rect = rects[0] + rect_center = focus_rect.left + (focus_rect.width / 2) + rect_top = focus_rect.top + tip_width = $(tip).width() + tip_height = $(tip).height() + tip_left = rect_center - (tip_width / 2) + tip_top = window.pageYOffset + rect_top - tip_height + adjust_y + win_width = $(window).width() + if tip_left + tip_width > win_width + tip_left = win_width - tip_width + position.left = tip_left + position.top = tip_top + getSpanForProblemReturn: (el) -> problem_id = $(@problemReturnSelector).index(el) @$(@spanSelector).filter("[data-problem-id='#{problem_id}']") From fcf82ba2bc44cb701c020d0494e2537139635f27 Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Mon, 11 Mar 2013 18:02:22 -0400 Subject: [PATCH 008/264] fixed pep8 violations for annotation module --- common/lib/xmodule/xmodule/annotatable_module.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/common/lib/xmodule/xmodule/annotatable_module.py b/common/lib/xmodule/xmodule/annotatable_module.py index f093b76f52..1385296ddf 100644 --- a/common/lib/xmodule/xmodule/annotatable_module.py +++ b/common/lib/xmodule/xmodule/annotatable_module.py @@ -11,13 +11,13 @@ from xmodule.contentstore.content import StaticContent log = logging.getLogger(__name__) + class AnnotatableModule(XModule): js = {'coffee': [resource_string(__name__, 'js/src/javascript_loader.coffee'), resource_string(__name__, 'js/src/collapsible.coffee'), resource_string(__name__, 'js/src/html/display.coffee'), resource_string(__name__, 'js/src/annotatable/display.coffee')], - 'js': [] - } + 'js': []} js_module_name = "Annotatable" css = {'scss': [resource_string(__name__, 'css/annotatable/display.scss')]} icon_class = 'annotatable' @@ -34,11 +34,11 @@ class AnnotatableModule(XModule): if color is not None: if color in self.highlight_colors: - cls.append('highlight-'+color) + cls.append('highlight-' + color) attr['_delete'] = highlight_key attr['value'] = ' '.join(cls) - return { 'class' : attr } + return {'class': attr} def _get_annotation_data_attr(self, index, el): """ Returns a dict in which the keys are the HTML data attributes @@ -58,7 +58,7 @@ class AnnotatableModule(XModule): if xml_key in el.attrib: value = el.get(xml_key, '') html_key = attrs_map[xml_key] - data_attrs[html_key] = { 'value': value, '_delete': xml_key } + data_attrs[html_key] = {'value': value, '_delete': xml_key} return data_attrs @@ -76,7 +76,6 @@ class AnnotatableModule(XModule): delete_key = attr[key]['_delete'] del el.attrib[delete_key] - def _render_content(self): """ Renders annotatable content with annotation spans and returns HTML. """ xmltree = etree.fromstring(self.content) @@ -123,9 +122,9 @@ class AnnotatableModule(XModule): self.element_id = self.location.html_id() self.highlight_colors = ['yellow', 'orange', 'purple', 'blue', 'green'] + class AnnotatableDescriptor(RawDescriptor): module_class = AnnotatableModule stores_state = True template_dir_name = "annotatable" mako_template = "widgets/raw-edit.html" - From d860b167d6838443d05b5b67805047a7e032f6a3 Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Tue, 12 Mar 2013 14:09:56 -0400 Subject: [PATCH 009/264] fixed tooltip positioning for non-overlapping spans in studio --- .../xmodule/css/annotatable/display.scss | 4 +++ .../xmodule/js/src/annotatable/display.coffee | 34 ++++++++++++++----- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/common/lib/xmodule/xmodule/css/annotatable/display.scss b/common/lib/xmodule/xmodule/css/annotatable/display.scss index f8ae779b8c..6e1a38ee31 100644 --- a/common/lib/xmodule/xmodule/css/annotatable/display.scss +++ b/common/lib/xmodule/xmodule/css/annotatable/display.scss @@ -1,6 +1,10 @@ $border-color: #C8C8C8; $body-font-size: em(14); +.annotatable-wrapper { + position: relative; +} + .annotatable-header { margin-bottom: .5em; .annotatable-title { diff --git a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee index 523b0e99cf..e38e48eeda 100644 --- a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee @@ -1,7 +1,8 @@ class @Annotatable _debug: false - # selectors for the annotatable xmodule + # selectors for the annotatable xmodule + wrapperSelector: '.annotatable-wrapper' toggleAnnotationsSelector: '.annotatable-toggle-annotations' toggleInstructionsSelector: '.annotatable-toggle-instructions' instructionsSelector: '.annotatable-instructions' @@ -61,7 +62,7 @@ class @Annotatable my: 'bottom center' # of tooltip at: 'top center' # of target target: $(el) # where the tooltip was triggered (i.e. the annotation span) - container: @$el + container: @$(@wrapperSelector) adjust: y: -5 show: @@ -104,23 +105,38 @@ class @Annotatable tip = api.elements.tooltip adjust_y = api.options.position?.adjust?.y || 0 + container = api.options.position?.container || $('body') target = api.elements.target + rects = $(target).get(0).getClientRects() is_non_overlapping = (rects?.length == 2 and rects[0].left > rects[1].right) if is_non_overlapping - focus_rect = rects[0] + # we want to choose the largest of the two non-overlapping spans and display + # the tooltip above the center of it (see api.options.position settings) + focus_rect = (if rects[0].width > rects[1].width then rects[0] else rects[1]) rect_center = focus_rect.left + (focus_rect.width / 2) rect_top = focus_rect.top tip_width = $(tip).width() tip_height = $(tip).height() - tip_left = rect_center - (tip_width / 2) - tip_top = window.pageYOffset + rect_top - tip_height + adjust_y + + # tooltip is positioned relative to its container, so we need to factor in offsets + container_offset = $(container).offset() + offset_left = -container_offset.left + offset_top = $('body').scrollTop() - container_offset.top + + tip_left = offset_left + rect_center - (tip_width / 2) + tip_top = offset_top + rect_top - tip_height + adjust_y + + # make sure the new tip position doesn't clip the edges of the screen win_width = $(window).width() - if tip_left + tip_width > win_width - tip_left = win_width - tip_width - position.left = tip_left - position.top = tip_top + if tip_left < offset_left + tip_left = offset_left + else if tip_left + tip_width > win_width + offset_left + tip_left = win_width + offset_left - tip_width + + # final step: update the position object (used by qtip2 to show the tip after the move event) + $.extend position, 'left': tip_left, 'top': tip_top getSpanForProblemReturn: (el) -> problem_id = $(@problemReturnSelector).index(el) From bf6ca1b0e759252795ca89ad905828d30ceada28 Mon Sep 17 00:00:00 2001 From: Arthur Barrett Date: Tue, 12 Mar 2013 17:32:00 -0400 Subject: [PATCH 010/264] use document to get scrollTop for firefox --- common/lib/xmodule/xmodule/js/src/annotatable/display.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee index e38e48eeda..8a32c8f51e 100644 --- a/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/annotatable/display.coffee @@ -123,7 +123,7 @@ class @Annotatable # tooltip is positioned relative to its container, so we need to factor in offsets container_offset = $(container).offset() offset_left = -container_offset.left - offset_top = $('body').scrollTop() - container_offset.top + offset_top = $(document).scrollTop() - container_offset.top tip_left = offset_left + rect_center - (tip_width / 2) tip_top = offset_top + rect_top - tip_height + adjust_y From 1b5f0400212cbd7f1d975cd4da3c523685395aad Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Tue, 12 Mar 2013 21:57:40 -0400 Subject: [PATCH 011/264] studio - checklists: roughed out initial static design, HTML, and most CSS for checklists v0 UI --- cms/djangoapps/contentstore/views.py | 3 + cms/static/sass/_base.scss | 10 +- cms/static/sass/_variables.scss | 3 + cms/static/sass/base-style.scss | 1 + cms/templates/checklists.html | 43 +++++ cms/templates/ux-checklists.html | 246 +++++++++++++++++++++++++++ cms/templates/widgets/header.html | 1 + cms/urls.py | 1 + 8 files changed, 305 insertions(+), 3 deletions(-) create mode 100644 cms/templates/checklists.html create mode 100644 cms/templates/ux-checklists.html diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index c2c80106fa..7394edb4f7 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -113,6 +113,9 @@ def howitworks(request): else: return render_to_response('howitworks.html', {}) +def ux_checklists(request): + return render_to_response('ux-checklists.html', {}) + # ==== Views for any logged-in user ================================== diff --git a/cms/static/sass/_base.scss b/cms/static/sass/_base.scss index 5d4bc7c773..23ff2b93e8 100644 --- a/cms/static/sass/_base.scss +++ b/cms/static/sass/_base.scss @@ -214,7 +214,7 @@ h1 { color: $gray-l2; } - .title, .title-1 { + .title-1 { @include font-size(32); margin: 0; padding: 0; @@ -283,8 +283,8 @@ h1 { .title-3 { @include font-size(16); - margin: 0 0 ($baseline/4) 0; - font-weight: 500; + margin: 0 0 ($baseline/2) 0; + font-weight: 600; } .title-4 { @@ -772,6 +772,10 @@ hr.divide { word-wrap: break-word; } +hr.divider { + @extend .sr; +} + // ==================== // js dependant diff --git a/cms/static/sass/_variables.scss b/cms/static/sass/_variables.scss index 4d8e26b2f9..e94ebcb7bc 100644 --- a/cms/static/sass/_variables.scss +++ b/cms/static/sass/_variables.scss @@ -77,6 +77,9 @@ $shadow: rgba(0,0,0,0.2); $shadow-l1: rgba(0,0,0,0.1); $shadow-d1: rgba(0,0,0,0.4); +// misc. +$elem-height-imaginary: 1000000px; + // colors - inherited $baseFontColor: #3c3c3c; $offBlack: #3c3c3c; diff --git a/cms/static/sass/base-style.scss b/cms/static/sass/base-style.scss index dceac4233d..e37ea22aad 100644 --- a/cms/static/sass/base-style.scss +++ b/cms/static/sass/base-style.scss @@ -31,6 +31,7 @@ @import "login"; @import "account"; @import "index"; +@import "checklists"; @import 'jquery-ui-calendar'; @import 'content-types'; diff --git a/cms/templates/checklists.html b/cms/templates/checklists.html new file mode 100644 index 0000000000..21f5c5007d --- /dev/null +++ b/cms/templates/checklists.html @@ -0,0 +1,43 @@ +<%inherit file="base.html" /> +<%block name="title">Course Checklists +<%block name="bodyclass">is-signedin course checklists + +<%block name="jsextra"> + + + +<%block name="content"> +
+
+
+ UX Design +

Alerts & Notifications

+
+
+
+ +
+
+
+
+
+

Alerts

+ persistant, static messages to the user +
+ +

In Studio, alerts are 1) general warnings/notes (e.g. drafts, published content, next steps) about the current view a user is interacting with or 2) notes about the status (e.g. saved confirmations, errors, next system steps) of any previous state that need to communicated to the user when arriving at the current view.

+
+
+
+
+ + +<%block name="view_alerts"> + + \ No newline at end of file diff --git a/cms/templates/ux-checklists.html b/cms/templates/ux-checklists.html new file mode 100644 index 0000000000..ef63561ce2 --- /dev/null +++ b/cms/templates/ux-checklists.html @@ -0,0 +1,246 @@ +<%inherit file="base.html" /> +<%block name="title">Course Checklists +<%block name="bodyclass">is-signedin course uxdesign checklists + +<%block name="content"> +
+
+
+ Tools +

Course Tasks & Checklists

+
+
+
+ +
+
+
+
+

Current Checklists

+ +
+
+

Getting Started with Studio

+ Tasks Completed: 2/5 +
+ +
    +
  • +
    +
    + + +
    +

    Grant your collaborators permission to edit your course so you can work together.

    +
    + + +
  • +
  • +
    +
    + + +
    +

    Establish a course start and end date, course enrollment start and end dates, content release and due dates, and other important dates.

    +
    + + +
  • +
  • +
    +
    + + +
    +

    Grant your collaborators permission to edit your course so you can work together.

    +
    + + +
  • +
  • +
    +
    + + +
    +

    Establish a course start and end date, course enrollment start and end dates, content release and due dates, and other important dates.

    +
    + + +
  • +
  • +
    +
    + + +
    +

    Grant your collaborators permission to edit your course so you can work together.

    +
    + + +
  • +
  • +
    +
    + + +
    +

    Establish a course start and end date, course enrollment start and end dates, content release and due dates, and other important dates.

    +
    + + +
  • +
+ + +
+ +
+ +

Completed Checklists

+ +
+
+

Getting Started with Studio

+ Tasks Completed: 5/5 +
+ +
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+ + +
+ +
+
+
+ + +
+
+ + +<%block name="view_alerts"> + +
+
+ + +
+

Your policy changes have been saved.

+

Please note that validation of your policy key and value pairs is not currently in place yet. If you are having difficulties, please review your policy pairs.

+
+ + + + close alert + +
+
+ + +<%block name="jsextra"> + + \ No newline at end of file diff --git a/cms/templates/widgets/header.html b/cms/templates/widgets/header.html index a063e4b526..7648f8b6f2 100644 --- a/cms/templates/widgets/header.html +++ b/cms/templates/widgets/header.html @@ -58,6 +58,7 @@ diff --git a/cms/urls.py b/cms/urls.py index d43b9bc44c..cba19310fe 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -83,6 +83,7 @@ urlpatterns = ('', # User creation and updating views urlpatterns += ( + url(r'^ux-checklists$', 'contentstore.views.ux_checklists', name='checklists'), url(r'^howitworks$', 'contentstore.views.howitworks', name='howitworks'), url(r'^signup$', 'contentstore.views.signup', name='signup'), From 66a8735a58908b3ce1e72f5fca0e90cdc12327f9 Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Wed, 13 Mar 2013 10:50:34 -0400 Subject: [PATCH 012/264] studio - Checklists: initial design and front end proofing/firming up - WIP --- cms/static/sass/_base.scss | 3 +- cms/static/sass/_checklists.scss | 273 ++++++++++++++++++++++++++++ cms/static/sass/_variables.scss | 1 + cms/templates/ux-checklists.html | 303 ++++++++++++++++++++++--------- 4 files changed, 497 insertions(+), 83 deletions(-) create mode 100644 cms/static/sass/_checklists.scss diff --git a/cms/static/sass/_base.scss b/cms/static/sass/_base.scss index 23ff2b93e8..995a0bbe9f 100644 --- a/cms/static/sass/_base.scss +++ b/cms/static/sass/_base.scss @@ -327,7 +327,8 @@ h1 { } } - .nav-related { + // navigation + .nav-related, .nav-page { .nav-item { margin-bottom: ($baseline/4); diff --git a/cms/static/sass/_checklists.scss b/cms/static/sass/_checklists.scss new file mode 100644 index 0000000000..66601fcf2a --- /dev/null +++ b/cms/static/sass/_checklists.scss @@ -0,0 +1,273 @@ +// Studio - Course Settings +// ==================== +body.course.checklists { + + .content-primary, .content-supplementary { + @include box-sizing(border-box); + float: left; + } + + .content-primary { + width: flex-grid(9, 12); + margin-right: flex-gutter(); + } + + // checklists - general + .course-checklist { + @extend .window; + margin: 0 0 ($baseline*2) 0; + + &:last-child { + margin-bottom: 0; + } + + header { + @include clearfix(); + margin-bottom: 0; + padding: $baseline ($baseline*1.5); + + .checklist-title { + @include transition(color .15s .25s ease-in-out); + width: flex-grid(7, 9); + margin: 0 flex-gutter() 0 0; + float: left; + + &.is-selectable { + cursor: pointer; + + &:hover { + color: $blue; + } + } + } + + .checklist-status { + @include font-size(13); + width: flex-grid(2, 9); + float: right; + margin-top: ($baseline/2); + text-align: right; + color: $gray-l2; + + .status-count { + @include font-size(16); + margin-left: ($baseline/4); + margin-right: ($baseline/4); + color: $gray-d3; + font-weight: 600; + } + + .status-amount { + @include font-size(16); + margin-left: ($baseline/4); + color: $gray-d3; + font-weight: 600; + } + } + } + + // checklist actions + .course-checklist-actions { + @include clearfix(); + @include box-shadow(inset 0 1px 1px $shadow-l1); + @include transition(border .15s ease-in-out .25s); + border-top: 1px solid $gray-l2; + padding: $baseline ($baseline*1.5); + background: $gray-l4; + + .action-primary { + @include green-button(); + float: left; + + .icon-add { + @include font-size(12); + display: inline-block; + vertical-align: middle; + margin-right: ($baseline/4); + } + } + + .action-secondary { + @include font-size(14); + @include grey-button(); + font-weight: 400; + float: right; + + .icon-delete { + @include font-size(12); + display: inline-block; + vertical-align: middle; + margin-right: ($baseline/4); + } + } + } + + // state - collapsed + &.is-collapsed { + + .list-tasks { + height: 0; + } + } + + // state - completed + &.is-completed { + + header { + + .checklist-title { + color: $gray-l1; + } + + .checklist-status { + + .status-count, .status-amount, .icon-confirm { + color: $green; + } + } + + .checklist-status .icon-confirm { + @include font-size(12); + display: inline-block; + vertical-align: middle; + margin-right: ($baseline/4); + } + } + } + + // state - not available + .is-not-available { + + } + } + + // list of tasks + .list-tasks { + height: auto; + overflow: hidden; + + .task { + @include transition(background .15s ease-in-out .25s, border .15s ease-in-out .25s); + @include clearfix(); + position: relative; + border-top: 1px solid $white; + border-bottom: 1px solid $gray-l5; + padding: $baseline ($baseline*1.5); + background: $white; + opacity: 1.0; + + + &:last-child { + margin-bottom: 0; + border-bottom: none; + } + + label { + float: left; + width: flex-grid(7,9); + font-weight: 500; + + .task-input { + display: inline-block; + vertical-align: middle; + margin-right: flex-gutter(); + } + + .task-details { + display: inline-block; + vertical-align: middle; + width: flex-grid(6,7); + + .task-name { + @include transition(color .15s .25s ease-in-out); + vertical-align: baseline; + cursor: pointer; + margin-bottom: 0; + } + + .task-description { + @include transition(color .15s .25s ease-in-out); + @include font-size(14); + color: $gray-l2; + } + + .task-support { + @include transition(opacity .15s .25s ease-in-out); + @include font-size(12); + opacity: 0; + pointer-events: none; + } + } + } + + .task-actions { + @include transition(opacity .15s .25s ease-in-out); + @include clearfix(); + display: inline-block; + vertical-align: middle; + float: left; + width: flex-grid(2,9); + margin: ($baseline/2) 0 0 flex-gutter(); + opacity: 0; + pointer-events: none; + text-align: right; + + .action-primary { + @include blue-button; + @include transition(all .15s); + @include font-size(12); + font-weight: 600; + text-align: center; + } + + .action-secondary { + @include font-size(13); + margin-top: ($baseline/2); + } + } + + // state - hover + &:hover { + background: $blue-l5; + border-bottom-color: $blue-l4; + border-top-color: $blue-l4; + opacity: 1.0; + + .task-details { + .task-support { + opacity: 1.0; + pointer-events: auto; + } + } + + .task-actions { + opacity: 1.0; + pointer-events: auto; + } + } + + + // state - completed + &.is-completed { + background: $gray-l6; + border-top-color: $gray-l5; + border-bottom-color: $gray-l5; + + .task-details { + opacity: 0.50; + } + + &:hover { + + .task-details { + opacity: 1.0; + } + } + } + } + } + + .content-supplementary { + width: flex-grid(3, 12); + } +} \ No newline at end of file diff --git a/cms/static/sass/_variables.scss b/cms/static/sass/_variables.scss index e94ebcb7bc..9e383af99d 100644 --- a/cms/static/sass/_variables.scss +++ b/cms/static/sass/_variables.scss @@ -24,6 +24,7 @@ $gray-l2: tint($gray,40%); $gray-l3: tint($gray,60%); $gray-l4: tint($gray,80%); $gray-l5: tint($gray,90%); +$gray-l6: tint($gray,95%); $gray-d1: shade($gray,20%); $gray-d2: shade($gray,40%); $gray-d3: shade($gray,60%); diff --git a/cms/templates/ux-checklists.html b/cms/templates/ux-checklists.html index ef63561ce2..d0a5ab8fa4 100644 --- a/cms/templates/ux-checklists.html +++ b/cms/templates/ux-checklists.html @@ -26,13 +26,14 @@
  • -
    -
    - - +
    +
    • @@ -40,29 +41,16 @@
  • +
  • -
    -
    - - -
    -

    Establish a course start and end date, course enrollment start and end dates, content release and due dates, and other important dates.

    -
    +
  • -
  • -
    -
    - - +
    +

    Add Course Team Members

    +

    Grant your collaborators permission to edit your course so you can work together.

    -

    Grant your collaborators permission to edit your course so you can work together.

    -
    +
    • @@ -70,48 +58,128 @@
  • -
  • -
    -
    - - -
    -

    Establish a course start and end date, course enrollment start and end dates, content release and due dates, and other important dates.

    -
    - -
  • -
  • -
    -
    - - -
    -

    Grant your collaborators permission to edit your course so you can work together.

    -
    - - -
  • -
    -
    - - +
    + +
  • + +
  • + + + +
  • +
+ + + + +
+
+

Getting Started with Studio

+ Tasks Completed: 2/5 +
+ +
    +
  • + + + +
  • + +
  • + + + +
  • + +
  • + + + +
  • + +
  • + + +
  • @@ -138,29 +206,68 @@ Tasks Completed: 5/5 -
      -
    • - -
    • +
      • + + +
      • -
      • +
      • + + + +
      • + +
      • + + +
      - +
    • + + Add a Task to This Checklist +
    • +
    • + Delete This Checklist +
    • +

@@ -233,12 +340,44 @@ From 16f6744aefcb53f8b1b59cdc82251288020540c6 Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Wed, 13 Mar 2013 19:13:26 -0400 Subject: [PATCH 014/264] studio - checklists: revised task completion styles, added in checklist visual progress UI and demo/PoC JS and cleaned up some content/status states --- cms/static/sass/_checklists.scss | 49 ++++++++++++++++++++++++++++++-- cms/templates/ux-checklists.html | 25 ++++++++++++++-- 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/cms/static/sass/_checklists.scss b/cms/static/sass/_checklists.scss index ca5424967e..7a55e07101 100644 --- a/cms/static/sass/_checklists.scss +++ b/cms/static/sass/_checklists.scss @@ -21,6 +21,32 @@ body.course.checklists { margin-bottom: 0; } + // visual status + .viz-checklist-status { + @include text-hide(); + @include size(100%,($baseline/4)); + position: relative; + display: block; + margin: 0; + background: $gray-l4; + + .viz-checklist-status-value { + @include transition(width 2s ease-in-out .25s); + position: absolute; + top: 0; + left: 0; + width: 50%; + height: ($baseline/4); + background: $green; + + .int { + @include text-sr(); + } + } + } + // 0% of checklist completed + + // header/title header { @include clearfix(); @include box-shadow(inset 0 -1px 1px $shadow-l1); @@ -127,6 +153,13 @@ body.course.checklists { // state - completed &.is-completed { + .viz-checklist-status { + + .viz-checklist-status-value { + width: 100%; + } + } + header { .checklist-title, .icon-confirm { @@ -273,10 +306,20 @@ body.course.checklists { color: $gray-l2; } + .task-actions { + + .action-primary { + @include grey-button; + @include font-size(12); + font-weight: 600; + text-align: center; + } + } + &:hover { - background: $blue-l5; - border-bottom-color: $blue-l4; - border-top-color: $blue-l4; + background: $gray-l5; + border-bottom-color: $gray-l4; + border-top-color: $gray-l4; .task-details { opacity:1.0; diff --git a/cms/templates/ux-checklists.html b/cms/templates/ux-checklists.html index 3f7a947111..833bbe000f 100644 --- a/cms/templates/ux-checklists.html +++ b/cms/templates/ux-checklists.html @@ -14,11 +14,17 @@
+
+

Praesent commodo cursus magna, vel scelerisque nisl consectetur et. Duis mollis, est non commodo luctus, nisi erat porttitor ligula, eget lacinia odio sem nec elit. Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor.Test Checklist Progress Visualization

+
+

Current Checklists

+ 0% of checklist completed +

Getting Started with Studio

Tasks Completed: 0/4 @@ -100,6 +106,8 @@
+ 0% of checklist completed +

Draft a Rough Course Outline

Tasks Completed: 0/7 @@ -238,6 +246,8 @@
+ 0% of checklist completed +

Explore edX's Support Tools

Tasks Completed: 0/4 @@ -319,9 +329,10 @@
+ 0% of checklist completed +

Draft your Course Introduction

- Tasks Completed: 0/4
    @@ -330,7 +341,7 @@
    -

    Drafting a Course Description/h4> +

    Drafting a Course Description

    Courses on edX each have their own introduction page, including a course video, description, and more. Draft the introduction students will read before deciding to enroll in your course.

    @@ -342,7 +353,7 @@
-
  • +
  • + + +
    <%block name="view_alerts"> - + +
    +
    + + +
    +

    Your policy changes have been saved.

    +

    Please note that validation of your policy key and value pairs is not currently in place yet. If you are having difficulties, please review your policy pairs.

    +
    + + + + close alert + +
    +
    + + +<%block name="jsextra"> + \ No newline at end of file diff --git a/cms/templates/widgets/header.html b/cms/templates/widgets/header.html index 7648f8b6f2..45ff66df85 100644 --- a/cms/templates/widgets/header.html +++ b/cms/templates/widgets/header.html @@ -58,7 +58,7 @@ diff --git a/cms/urls.py b/cms/urls.py index cba19310fe..18da7c7b71 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -83,7 +83,8 @@ urlpatterns = ('', # User creation and updating views urlpatterns += ( - url(r'^ux-checklists$', 'contentstore.views.ux_checklists', name='checklists'), + url(r'^(?P[^/]+)/(?P[^/]+)/checklists/(?P[^/]+)$', 'contentstore.views.get_checklists', name='checklists'), +# url(r'^ux-checklists$', 'contentstore.views.ux_checklists', name='checklists'), url(r'^howitworks$', 'contentstore.views.howitworks', name='howitworks'), url(r'^signup$', 'contentstore.views.signup', name='signup'), diff --git a/common/lib/xmodule/xmodule/templates/course/empty.yaml b/common/lib/xmodule/xmodule/templates/course/empty.yaml index cb2f3bcec6..61cc204254 100644 --- a/common/lib/xmodule/xmodule/templates/course/empty.yaml +++ b/common/lib/xmodule/xmodule/templates/course/empty.yaml @@ -2,5 +2,31 @@ metadata: display_name: Empty start: 2020-10-10T10:00 + checklists: [ + {"short_description" : "Getting Started With Studio", + "items" : [{"short_description": "Add Course Team Members", + "long_description": "Grant your collaborators permission to edit your course so you can work together.", + "is_checked": false, + "action_url": "/manage_users/", + "action_text": "Edit Course Team"}, + {"short_description": "Drink Whiskey", + "long_description": "Team-building activity.", + "is_checked": false, + "action_url": "/drink_alcohol", + "action_text": "Drink"}], + "completed" : false}, + {"short_description" : "Launching Your Course", + "items" : [{"short_description": "Add Content", + "long_description": "Create videos and problems.", + "is_checked": false, + "action_url": "", + "action_text": ""}, + {"short_description": "View Course as a Student", + "long_description": "Create a student account and view your course.", + "is_checked": false, + "action_url": "", + "action_text": ""}], + "completed" : false} + ] data: { 'textbooks' : [ ], 'wiki_slug' : null } children: [] From 065e85044923c2b14c46246aa92d83e4696396cc Mon Sep 17 00:00:00 2001 From: cahrens Date: Thu, 14 Mar 2013 09:32:54 -0400 Subject: [PATCH 018/264] Merge with Brian's changes. --- cms/templates/checklists.html | 28 +- cms/templates/ux-checklists.html | 618 ------------------------------- 2 files changed, 24 insertions(+), 622 deletions(-) delete mode 100644 cms/templates/ux-checklists.html diff --git a/cms/templates/checklists.html b/cms/templates/checklists.html index 109ac36df5..455dbe6b04 100644 --- a/cms/templates/checklists.html +++ b/cms/templates/checklists.html @@ -14,6 +14,10 @@
    +
    +

    Praesent commodo cursus magna, vel scelerisque nisl consectetur et. Duis mollis, est non commodo luctus, nisi erat porttitor ligula, eget lacinia odio sem nec elit. Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor.Test Checklist Progress Visualization

    +
    +

    Current Checklists

    @@ -21,6 +25,7 @@ % for checklist in checklists:
    + 0% of checklist completed

    ${checklist['short_description']}

    Tasks Completed: 0/4 @@ -53,6 +58,8 @@ % endfor
    + 0% of checklist completed +

    Getting Started with Studio

    Tasks Completed: 0/4 @@ -134,6 +141,8 @@
    + 0% of checklist completed +

    Draft a Rough Course Outline

    Tasks Completed: 0/7 @@ -272,6 +281,8 @@
    + 0% of checklist completed +

    Explore edX's Support Tools

    Tasks Completed: 0/4 @@ -353,9 +364,10 @@
    + 0% of checklist completed +

    Draft your Course Introduction

    - Tasks Completed: 0/4
      @@ -364,8 +376,8 @@
      -

      Drafting a Course Description/h4> -

      Courses on edX each have their own introduction page, including a course video, description, and more. Draft the introduction students will read before deciding to enroll in your course.

      +

      Drafting a Course Description

      +

      Courses on edX each have their own introduction page, including a course video, description, and more. Draft the introduction students will read before deciding to enroll in your course.

      @@ -376,7 +388,7 @@
    -
  • +
  • <%block name="view_alerts">
    -
    - +
    + -
    -

    Your policy changes have been saved.

    -

    Please note that validation of your policy key and value pairs is not currently in place yet. If you are having difficulties, please review your policy pairs.

    -
    - - - - close alert - +
    +

    Your policy changes have been saved.

    +

    Please note that validation of your policy key and value pairs is not currently in place yet. If you are having difficulties, please review your policy pairs.

    + + + + close alert + +
    @@ -598,56 +113,55 @@ // checklists - prototype/basic js $(document).ready(function() { - $('.course-checklist .checklist-title').each(function(e){ - $(this).addClass('is-selectable').attr('title','Collapse/Expand this Checklist').bind('click', toggleChecklist); - }); + $('.course-checklist .checklist-title').each(function(e){ + $(this).addClass('is-selectable').bind('click', toggleChecklist); + }); - $('.course-checklist .task label').each(function(e){ - $(this).bind('click', toggleTask); - }); + $('.course-checklist .task label').each(function(e){ + $(this).bind('click', toggleTask); + }); - // demo/proof of concept for visual progress - $('.demo-checklistviz').click(function(e){ - (e).preventDefault(); - $('#course-checklist1 .viz-checklist-status .viz-checklist-status-value').css('width','25%'); - }); + // demo/proof of concept for visual progress + $('.demo-checklistviz').click(function(e){ + (e).preventDefault(); + $('#course-checklist1 .viz-checklist-status .viz-checklist-status-value').css('width','25%'); + }); - function toggleChecklist(e) { - (e).preventDefault(); - $(this).closest('.course-checklist').toggleClass('is-collapsed'); - } + function toggleChecklist(e) { + (e).preventDefault(); + $(this).closest('.course-checklist').toggleClass('is-collapsed'); + } - function toggleTask(e) { - (e).preventDefault(); + function toggleTask(e) { + (e).preventDefault(); - var $taskInput = $(this).find('.task-input'); + var $taskInput = $(this).find('.task-input'); - if ($taskInput.attr('checked')) { - $taskInput.removeAttr('checked'); - console.log('removing check'); - } - else { - $taskInput.attr('checked', 'checked'); - console.log('adding check'); - } + if ($taskInput.attr('checked')) { + $taskInput.removeAttr('checked'); + console.log('removing check'); + } + else { + $taskInput.attr('checked', 'checked'); + console.log('adding check'); + } - $(this).closest('.task').toggleClass('is-completed'); - } + $(this).closest('.task').toggleClass('is-completed'); + } - // in-progress update checklist progress (based on checkbox check/uncheck events) - function updateChecklistProgress() { - var $statusCount = $(this).closest('.course-checklist').find('.status-count'); - var $statusAmount = $(this).closest('.course-checklist').find('.status-amount'); + // in-progress update checklist progress (based on checkbox check/uncheck events) + function updateChecklistProgress() { + var $statusCount = $(this).closest('.course-checklist').find('.status-count'); + var $statusAmount = $(this).closest('.course-checklist').find('.status-amount'); - if ($(this).attr('checked')) { - console.log('adding'); - } - - else { - console.log('subtracting'); - } - } + if ($(this).attr('checked')) { + console.log('adding'); + } + else { + console.log('subtracting'); + } + } }); \ No newline at end of file diff --git a/common/lib/xmodule/xmodule/templates/course/empty.yaml b/common/lib/xmodule/xmodule/templates/course/empty.yaml index 61cc204254..153d7859ee 100644 --- a/common/lib/xmodule/xmodule/templates/course/empty.yaml +++ b/common/lib/xmodule/xmodule/templates/course/empty.yaml @@ -3,30 +3,104 @@ metadata: display_name: Empty start: 2020-10-10T10:00 checklists: [ - {"short_description" : "Getting Started With Studio", - "items" : [{"short_description": "Add Course Team Members", - "long_description": "Grant your collaborators permission to edit your course so you can work together.", - "is_checked": false, - "action_url": "/manage_users/", - "action_text": "Edit Course Team"}, - {"short_description": "Drink Whiskey", - "long_description": "Team-building activity.", - "is_checked": false, - "action_url": "/drink_alcohol", - "action_text": "Drink"}], - "completed" : false}, - {"short_description" : "Launching Your Course", - "items" : [{"short_description": "Add Content", - "long_description": "Create videos and problems.", - "is_checked": false, - "action_url": "", - "action_text": ""}, - {"short_description": "View Course as a Student", - "long_description": "Create a student account and view your course.", - "is_checked": false, - "action_url": "", - "action_text": ""}], - "completed" : false} + {"short_description" : "Getting Started With Studio", + "items" : [{"short_description": "Add Course Team Members", + "long_description": "Grant your collaborators permission to edit your course so you can work together.", + "is_checked": false, + "action_url": "/manage_users/", + "action_text": "Edit Course Team"}, + {"short_description": "Set Important Dates for Your Course", + "long_description": "Establish your course's student enrollment and launch dates on the Schedule and Details Settings page.", + "is_checked": false, + "action_url": "/settings-details/", + "action_text": "Edit Course Details & Schedule"}, + {"short_description": "Draft Your Course's Grading Policy", + "long_description": "Regardless of whether you have all your course assignments written, you can immediately get started setting up assignment types and a grade computation scheme.", + "is_checked": false, + "action_url": "/settings-grading/", + "action_text": "Edit Grading Settings"}, + {"short_description": "Explore the other Studio Checklists", + "long_description": "They'll help you learn the other course authoring tools available to you, and will also help you find help when you need it.", + "is_checked": false, + "action_url": "", + "action_text": ""}], + "completed" : false}, + {"short_description" : "Draft a Rough Course Outline", + "items" : [{"short_description": "Create your first Section and Subsection", + "long_description": "Walk through the mechanics of building your course's first section and subsection through your course outline to start.", + "is_checked": false, + "action_url": "/course/", + "action_text": "Edit in Course Outline"}, + {"short_description": "Set your first Section's Release Date", + "long_description": "Sections are released sequentially to students, and you have complete control over they are released to students.", + "is_checked": false, + "action_url": "/course/", + "action_text": "Edit in Course Outline"}, + {"short_description": "Designate a Subsection as Graded", + "long_description": "Assignment types are defined in your grading settings but can be quickly associated with sections using your course outline.", + "is_checked": false, + "action_url": "/course/", + "action_text": "Edit in Course Outline"}, + {"short_description": "Reordering Course Content", + "long_description": "From the Course Outline, you can easily reorder your course content based on the progression you'd like students to walk through.", + "is_checked": false, + "action_url": "/course/", + "action_text": "Edit in Course Outline"}, + {"short_description": "Renaming Course Sections", + "long_description": "Learn how to rename Sections by clicking on its name from the Course Outline; this should open the editing mode.", + "is_checked": false, + "action_url": "/course/", + "action_text": "Edit in Course Outline"}, + {"short_description": "Deleting Course Content", + "long_description": "Try out deleting on a section, subsection, or unit you don't need anymore. Be careful though, anything inside the course content you delete is also removed.", + "is_checked": false, + "action_url": "/course/", + "action_text": "Edit in Course Outline"}, + {"short_description": "Add an Instructor-Only Section to Your Outline", + "long_description": "Some course authors find creating a section for unsorted, in-progress work useful. To do this, create a section and set the release date to the distant future.", + "is_checked": false, + "action_url": "/course/", + "action_text": "Edit in Course Outline"}], + "completed" : false}, + {"short_description" : "Explore edX's Support Tools", + "items" : [{"short_description": "Explore the Studio Help Forum", + "long_description": "Access the Studio Help forum from the menu that appears when you click your user name in the top right corner of Studio.", + "is_checked": false, + "action_url": "http://help.edge.edx.org/", + "action_text": "Visit Studio Help"}, + {"short_description": "Enroll in edX101", + "long_description": "Register for edX101, edX's primer for course creation.", + "is_checked": false, + "action_url": "/", + "action_text": "Register for edX 101"}, + {"short_description": "Download the Studio Documentation", + "long_description": "View the searchable Studio documentation to find answers to your questions or information about how to do specific tasks. Once you download the PDF, you can view it offline.", + "is_checked": false, + "action_url": "/", + "action_text": "Download Documentation"}], + "completed" : false}, + {"short_description" : "Draft your Course Introduction", + "items" : [{"short_description": "Drafting a Course Description", + "long_description": "Courses on edX each have their own introduction page, including a course video, description, and more. Draft the introduction students will read before deciding to enroll in your course.", + "is_checked": false, + "action_url": "/settings-details/", + "action_text": "Edit Course Details & Schedule"}, + {"short_description": "Adding Staff Bios", + "long_description": "Showing prospective students who will be their instructor is helpful, so we recommend including staff bios in the Course introduction page.", + "is_checked": false, + "action_url": "/settings-details/", + "action_text": "Edit Course Details & Schedule"}, + {"short_description": "Add Course FAQs", + "long_description": "Students often have questions about courses, and including a short list of frequently asked questions up front often reduces the number of students who are confused.", + "is_checked": false, + "action_url": "/settings-details/", + "action_text": "Edit Course Details & Schedule"}, + {"short_description": "Add Course Prerequisites", + "long_description": "Before a student jumps into a course without the necessary preparation, we'd like them to understand the prerequisites that will make them more likely to succeed.", + "is_checked": false, + "action_url": "/settings-details/", + "action_text": "Edit Course Details & Schedule"}], + "completed" : false} ] data: { 'textbooks' : [ ], 'wiki_slug' : null } children: [] From 4c8a45f85ecfb6422bd10de3b79f3a5ef51c70f9 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 14 Mar 2013 13:29:26 -0400 Subject: [PATCH 021/264] 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 022/264] 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 017fd06bfc1ff28635bb54f427c5256ac64d89ba Mon Sep 17 00:00:00 2001 From: cahrens Date: Thu, 14 Mar 2013 13:37:22 -0400 Subject: [PATCH 023/264] Pre-merge. --- cms/static/js/models/checklists.js | 1 + cms/static/js/views/checklists_view.js | 57 ++++++++++--- cms/templates/checklists.html | 113 ++++++------------------- 3 files changed, 70 insertions(+), 101 deletions(-) create mode 100644 cms/static/js/models/checklists.js diff --git a/cms/static/js/models/checklists.js b/cms/static/js/models/checklists.js new file mode 100644 index 0000000000..368db944ed --- /dev/null +++ b/cms/static/js/models/checklists.js @@ -0,0 +1 @@ +if (!CMS.Models['Checklists']) CMS.Models.Checklists = new Object(); \ No newline at end of file diff --git a/cms/static/js/views/checklists_view.js b/cms/static/js/views/checklists_view.js index b33a5d66f4..0905b392e4 100644 --- a/cms/static/js/views/checklists_view.js +++ b/cms/static/js/views/checklists_view.js @@ -1,18 +1,49 @@ +if (!CMS.Views['Checklists']) CMS.Views.Checklists = {}; + CMS.Views.Checklists = Backbone.View.extend({ - // takes CMS.Models.CourseInfo as model - tagName: 'div', + // takes CMS.Models.Checklists as model - render: function() { - // instantiate the ClassInfoUpdateView and delegate the proper dom to it - new CMS.Views.ClassInfoUpdateView({ - el: $('body.updates'), - collection: this.model.get('updates') - }); + events : { + 'click .course-checklist .checklist-title' : "toggleChecklist", + 'click .course-checklist .task label' : "toggleTask", + 'click .demo-checklistviz' : "demoUpdateProgress" + }, - new CMS.Views.ClassInfoHandoutsView({ - el: this.$('#course-handouts-view'), - model: this.model.get('handouts') - }); - return this; + initialize : function() { + // adding class and title needs to happen in HTML +// $('.course-checklist .checklist-title').each(function(e){ +// $(this).addClass('is-selectable').attr('title','Collapse/Expand this Checklist').bind('click', this.toggleChecklist); +// }); + }, + + toggleChecklist : function(e) { + (e).preventDefault(); + $(e.target).closest('.course-checklist').toggleClass('is-collapsed'); + }, + + toggleTask : function (e) { + (e).preventDefault(); + $(e.target).closest('.task').toggleClass('is-completed'); + }, + + // TODO: remove + demoUpdateProgress : function(e) { + (e).preventDefault(); + $('#course-checklist0 .viz-checklist-status .viz-checklist-status-value').css('width','25%'); + }, + + // TODO: not used. In-progress update checklist progress (based on checkbox check/uncheck events) + updateChecklistProgress : function(e) { + var $statusCount = this.$el.closest('.course-checklist').find('.status-count'); + var $statusAmount = this.$el.closest('.course-checklist').find('.status-amount'); + + if (this.$el.attr('checked')) { + console.log('adding'); + } + + else { + console.log('subtracting'); + } } + }); \ No newline at end of file diff --git a/cms/templates/checklists.html b/cms/templates/checklists.html index 455dbe6b04..a9d7255d55 100644 --- a/cms/templates/checklists.html +++ b/cms/templates/checklists.html @@ -2,6 +2,27 @@ <%block name="title">Course Checklists <%block name="bodyclass">is-signedin course uxdesign checklists +<%namespace name='static' file='static_content.html'/> +<%block name="jsextra"> + + + + + + + <%block name="content"> @@ -564,7 +578,7 @@ @@ -574,80 +588,3 @@
    -<%block name="view_alerts"> - -
    -
    - - -
    -

    Your policy changes have been saved.

    -

    Please note that validation of your policy key and value pairs is not currently in place yet. If you are having difficulties, please review your policy pairs.

    -
    - - - - close alert - -
    -
    - - -<%block name="jsextra"> - - \ No newline at end of file From 10eb7e45ea58a776113087515c1a00748f954320 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 14 Mar 2013 13:42:41 -0400 Subject: [PATCH 024/264] 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 ac25b02ce6842babfc1ed1107f49778e8d876f6b Mon Sep 17 00:00:00 2001 From: cahrens Date: Thu, 14 Mar 2013 13:48:17 -0400 Subject: [PATCH 025/264] Minor updates. --- cms/djangoapps/contentstore/views.py | 5 ++++- cms/static/js/views/checklists_view.js | 1 - 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index 62c8f8da97..89360c1d42 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -1322,7 +1322,10 @@ def get_checklists(request, org, course, name): return render_to_response('checklists.html', - {'checklists' : course_module.metadata[key]}) + { + 'context_course': course_module, + 'checklists' : course_module.metadata[key] + }) @login_required diff --git a/cms/static/js/views/checklists_view.js b/cms/static/js/views/checklists_view.js index 0905b392e4..1ed79c8fac 100644 --- a/cms/static/js/views/checklists_view.js +++ b/cms/static/js/views/checklists_view.js @@ -22,7 +22,6 @@ CMS.Views.Checklists = Backbone.View.extend({ }, toggleTask : function (e) { - (e).preventDefault(); $(e.target).closest('.task').toggleClass('is-completed'); }, From 13c38fa173cd988b44bc5a2a32f9abda3102a1a4 Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Thu, 14 Mar 2013 16:17:47 -0400 Subject: [PATCH 026/264] studio - checklists: revised input/label HTML and adjusted styling for each --- cms/static/sass/_checklists.scss | 87 ++++++++++++++------------------ cms/templates/checklists.html | 26 +++++----- 2 files changed, 51 insertions(+), 62 deletions(-) diff --git a/cms/static/sass/_checklists.scss b/cms/static/sass/_checklists.scss index 7a5b5213ea..b49c75f9e6 100644 --- a/cms/static/sass/_checklists.scss +++ b/cms/static/sass/_checklists.scss @@ -56,7 +56,7 @@ body.course.checklists { .checklist-title { @include transition(color .15s .25s ease-in-out); - width: flex-grid(7, 9); + width: flex-grid(6, 9); margin: 0 flex-gutter() 0 0; float: left; @@ -64,14 +64,6 @@ body.course.checklists { @include font-size(14); display: inline-block; vertical-align: middle; - margin-right: $baseline; - color: $gray-l4; - } - - .icon-confirm { - @include font-size(20); - display: inline-block; - vertical-align: middle; margin-right: ($baseline/2); color: $gray-l4; } @@ -91,11 +83,20 @@ body.course.checklists { .checklist-status { @include font-size(13); - width: flex-grid(2, 9); + width: flex-grid(3, 9); float: right; margin-top: ($baseline/2); text-align: right; color: $gray-l2; + + + .icon-confirm { + @include font-size(20); + display: inline-block; + vertical-align: middle; + margin-left: ($baseline/2); + color: $gray-l4; + } .status-count { @include font-size(16); @@ -192,13 +193,6 @@ body.course.checklists { color: $green; } } - - .checklist-status .icon-confirm { - @include font-size(12); - display: inline-block; - vertical-align: middle; - margin-right: ($baseline/4); - } } } @@ -230,41 +224,38 @@ body.course.checklists { border-bottom: none; } - label { + .task-input { + display: inline-block; + vertical-align: text-top; float: left; - width: flex-grid(7,9); + margin: ($baseline/2) flex-gutter() 0 0; + } + + .task-details { + display: inline-block; + vertical-align: text-top; + float: left; + width: flex-grid(6,9); font-weight: 500; - .task-input { - display: inline-block; - vertical-align: text-top; - margin: ($baseline/2) flex-gutter() 0 0; + .task-name { + @include transition(color .15s .25s ease-in-out); + vertical-align: baseline; + cursor: pointer; + margin-bottom: 0; + } + + .task-description { + @include transition(color .15s .25s ease-in-out); + @include font-size(14); + color: $gray-l2; } - .task-details { - display: inline-block; - vertical-align: text-top; - width: flex-grid(6,7); - - .task-name { - @include transition(color .15s .25s ease-in-out); - vertical-align: baseline; - cursor: pointer; - margin-bottom: 0; - } - - .task-description { - @include transition(color .15s .25s ease-in-out); - @include font-size(14); - color: $gray-l2; - } - - .task-support { - @include transition(opacity .15s .25s ease-in-out); - @include font-size(12); - opacity: 0; - pointer-events: none; - } + .task-support { + @include transition(opacity .15s .25s ease-in-out); + @include font-size(12); + opacity: 0; + pointer-events: none; } } @@ -273,7 +264,7 @@ body.course.checklists { @include clearfix(); display: inline-block; vertical-align: middle; - float: left; + float: right; width: flex-grid(2,9); margin: ($baseline/2) 0 0 flex-gutter(); opacity: 0; diff --git a/cms/templates/checklists.html b/cms/templates/checklists.html index 0e9be39fed..8415338466 100644 --- a/cms/templates/checklists.html +++ b/cms/templates/checklists.html @@ -48,27 +48,25 @@
    0% of checklist completed
    -

    +

    - - ${checklist['short_description']}

    - - Tasks Completed: 0/4 + ${checklist['short_description']} + + Tasks Completed: 0/4 + +
      % for item in checklist['items']:
    • -
    diff --git a/common/lib/capa/capa/tests/__init__.py b/common/lib/capa/capa/tests/__init__.py index 7b1bffce62..72d82c683b 100644 --- a/common/lib/capa/capa/tests/__init__.py +++ b/common/lib/capa/capa/tests/__init__.py @@ -20,7 +20,7 @@ def calledback_url(dispatch = 'score_update'): return dispatch xqueue_interface = MagicMock() -xqueue_interface.send_to_queue.return_value = (1, 'Success!') +xqueue_interface.send_to_queue.return_value = (0, 'Success!') test_system = Mock( ajax_url='courses/course_id/modx/a_location', diff --git a/common/lib/capa/capa/tests/test_inputtypes.py b/common/lib/capa/capa/tests/test_inputtypes.py index 97e27d5ffc..b9da9df03f 100644 --- a/common/lib/capa/capa/tests/test_inputtypes.py +++ b/common/lib/capa/capa/tests/test_inputtypes.py @@ -357,7 +357,7 @@ class MatlabTest(unittest.TestCase): def test_rendering_with_state(self): state = {'value': 'print "good evening"', 'status': 'incomplete', - 'input_state': {'queue_msg': 'message'}, + 'input_state': {'prob_1_2': {'queue_msg': 'message'}}, 'feedback': {'message': '3'}, } elt = etree.fromstring(self.xml) @@ -383,7 +383,7 @@ class MatlabTest(unittest.TestCase): def test_plot_data(self): get = {'submission': 'x = 1234;'} - response = json.loads(self.the_input.handle_ajax("plot", get)) + response = self.the_input.handle_ajax("plot", get) test_system.xqueue['interface'].send_to_queue.assert_called_with(header=ANY, body=ANY) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 1bdd62f5b7..a522c796bb 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -460,6 +460,7 @@ class CapaModule(CapaFields, XModule): 'progress_changed': after != before, 'progress_status': Progress.to_js_status_str(after), }) + self.set_state_from_lcp() return json.dumps(d, cls=ComplexEncoder) def is_past_due(self): @@ -549,8 +550,8 @@ class CapaModule(CapaFields, XModule): score_msg = get['xqueue_body'] # pass along the xqueue message to the problem self.lcp.ungraded_response(score_msg, queuekey) - self.set_state_from_lcp() + return dict() def get_answer(self, get): ''' From a2957cb3b72726e164824afea7be32ed453eaeba Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Tue, 19 Mar 2013 16:36:33 -0400 Subject: [PATCH 050/264] Add in some JS messages for when things go wrong. --- common/lib/capa/capa/inputtypes.py | 1 - .../lib/capa/capa/templates/matlabinput.html | 23 ++++++++++++++----- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index 42865a01b5..5a47456fab 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -702,7 +702,6 @@ class MatlabInput(CodeInput): log.error("External message should be a JSON serialized dict." " Received queue_msg = %s" % queue_msg) raise - # TODO: needs more error checking msg = result['msg'] return msg diff --git a/common/lib/capa/capa/templates/matlabinput.html b/common/lib/capa/capa/templates/matlabinput.html index cbfc4b119f..e52a5297d1 100644 --- a/common/lib/capa/capa/templates/matlabinput.html +++ b/common/lib/capa/capa/templates/matlabinput.html @@ -60,9 +60,21 @@ $("#textbox_${id}").find('.CodeMirror-scroll').height(${int(13.5*eval(rows))}); + var gentle_alert = function (parent_elt, msg) { + if($(parent_elt).find('.capa_alert').length) { + $(parent_elt).find('.capa_alert').remove(); + } + var alert_elem = "
    " + msg + "
    "; + alert_elem = $(alert_elem).addClass('capa_alert'); + $(parent_elt).find('.action').after(alert_elem); + $(parent_elt).find('.capa_alert').css({opacity: 0}).animate({opacity: 1}, 700); + } + + // hook up the plot button var plot = function(event) { - url = $(this).closest('.problems-wrapper').data('url'); + var problem_elt = $(event.target).closest('.problems-wrapper'); + url = $(event.target).closest('.problems-wrapper').data('url'); input_id = "${id}"; // save the codemirror text to the textarea @@ -73,31 +85,30 @@ answer = input.serialize(); - // setup callback for + // setup callback for after we send information to plot var plot_callback = function(response) { if(response.success) { window.location.reload(); } else { - // TODO: show message + gentle_alert(problem_elt, msg); } } var save_callback = function(response) { if(response.success) { + // send information to the problem's plot functionality Problem.inputAjax(url, input_id, 'plot', {'submission': submission}, plot_callback); } else { - // TODO: show any messages + gentle_alert(problem_elt, msg); } } // save the answer $.postWithPrefix(url + '/problem_save', answer, save_callback); - - } $('#plot_${id}').click(plot); From 57f7acf86398698d1f54d15e4702092042aa7d3c Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Tue, 19 Mar 2013 17:10:38 -0400 Subject: [PATCH 051/264] Unbreak grading for capa problems Clean up some pylint errors --- common/lib/capa/capa/capa_problem.py | 8 +------- common/lib/capa/capa/inputtypes.py | 8 +++++++- .../lib/capa/capa/templates/matlabinput.html | 2 +- common/lib/xmodule/xmodule/capa_module.py | 19 ++++++++++++++++--- 4 files changed, 25 insertions(+), 12 deletions(-) diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index 911f210812..f1fea4d8e3 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -16,7 +16,6 @@ This is used by capa_module. from __future__ import division from datetime import datetime -import json import logging import math import numpy @@ -32,8 +31,6 @@ from xml.sax.saxutils import unescape from copy import deepcopy import chem -import chem.chemcalc -import chem.chemtools import chem.miller import verifiers import verifiers.draganddrop @@ -70,9 +67,6 @@ global_context = {'random': random, 'scipy': scipy, 'calc': calc, 'eia': eia, - 'chemcalc': chem.chemcalc, - 'chemtools': chem.chemtools, - 'miller': chem.miller, 'draganddrop': verifiers.draganddrop} # These should be removed from HTML output, including all subelements @@ -371,7 +365,7 @@ class LoncapaProblem(object): dispatch = get['dispatch'] return self.inputs[input_id].handle_ajax(dispatch, get) else: - log.warning("Could not find matching input for id: %s" % problem_id) + log.warning("Could not find matching input for id: %s" % input_id) return {} diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index 5a47456fab..a62e696b20 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -37,7 +37,6 @@ graded status as'status' # makes sense, but a bunch of problems have markup that assumes block. Bigger TODO: figure out a # general css and layout strategy for capa, document it, then implement it. -from collections import namedtuple import json import logging from lxml import etree @@ -623,6 +622,13 @@ registry.register(CodeInput) class MatlabInput(CodeInput): ''' InputType for handling Matlab code input + + Example: + + + %api_key=API_KEY + + ''' template = "matlabinput.html" tags = ['matlabinput'] diff --git a/common/lib/capa/capa/templates/matlabinput.html b/common/lib/capa/capa/templates/matlabinput.html index e52a5297d1..6c02e8e68e 100644 --- a/common/lib/capa/capa/templates/matlabinput.html +++ b/common/lib/capa/capa/templates/matlabinput.html @@ -44,7 +44,7 @@ % if linenumbers == 'true': lineNumbers: true, % endif - mode: "${mode}", + mode: "matlab", matchBrackets: true, lineWrapping: true, indentUnit: "${tabsize}", diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index a522c796bb..6ce8d3a805 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -446,7 +446,7 @@ class CapaModule(CapaFields, XModule): 'problem_save': self.save_problem, 'problem_show': self.get_answer, 'score_update': self.update_score, - 'input_ajax': self.lcp.handle_input_ajax, + 'input_ajax': self.handle_input_ajax, 'ungraded_response': self.handle_ungraded_response } @@ -460,7 +460,6 @@ class CapaModule(CapaFields, XModule): 'progress_changed': after != before, 'progress_status': Progress.to_js_status_str(after), }) - self.set_state_from_lcp() return json.dumps(d, cls=ComplexEncoder) def is_past_due(self): @@ -544,7 +543,10 @@ class CapaModule(CapaFields, XModule): def handle_ungraded_response(self, get): ''' - Get the XQueue response + Delivers a response to the capa problem where the expectation where this response does + not have relevant grading information + + No ajax return is needed, so an empty dict is returned ''' queuekey = get['queuekey'] score_msg = get['xqueue_body'] @@ -553,6 +555,17 @@ class CapaModule(CapaFields, XModule): self.set_state_from_lcp() return dict() + def handle_input_ajax(self, get): + ''' + Passes information down to the capa problem so that it can handle its own ajax calls + Returns the response from the capa problem + ''' + response = self.lcp.handle_input_ajax(get) + # save any state changes that may occur + self.set_state_from_lcp() + return response + + def get_answer(self, get): ''' For the "show answer" button. From 10c6e7615bbea19e0687e62e36b26d7515ea603c Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Wed, 20 Mar 2013 09:42:42 -0400 Subject: [PATCH 052/264] More polish for matlab input type --- common/lib/capa/capa/inputtypes.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index a62e696b20..0208f32503 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -657,8 +657,8 @@ class MatlabInput(CodeInput): self.queue_len = 0 self.queuename = 'matlab' # Flag indicating that the problem has been queued, 'msg' is length of - self.queue_msg = None - if 'queue_msg' in self.input_state: + self.queue_msg = '' + if 'queue_msg' in self.input_state and self.status in ['incomplete', 'unsubmitted']: self.queue_msg = self.input_state['queue_msg'] if 'queued' in self.input_state and self.input_state['queuestate'] is not None: self.status = 'queued' @@ -689,11 +689,10 @@ class MatlabInput(CodeInput): def _extra_context(self): ''' Set up additional context variables''' - extra_context = {'queue_len': self.queue_len} - if self.queue_msg is not None: - extra_context['queue_msg'] = self.queue_msg - else: - extra_context['queue_msg'] = '' + extra_context = { + 'queue_len': self.queue_len, + 'queue_msg': self.queue_msg + } return extra_context def _parse_data(self, queue_msg): @@ -747,7 +746,6 @@ class MatlabInput(CodeInput): (error, msg) = qinterface.send_to_queue(header=xheader, body = json.dumps(contents)) - return {'success': error == 0, 'message': msg} return {'success': False, 'message': 'Cannot connect to the queue'} From af1af8c6d1f57fb45435ef037253531ce8f5e551 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Wed, 20 Mar 2013 14:08:15 -0400 Subject: [PATCH 053/264] Address code review feedback: - improve docstrings - only pass in the state for a particular input and not the whole dictionary - refactor some common code - minor syntax cleanup --- common/lib/capa/capa/capa_problem.py | 43 +++--- common/lib/capa/capa/inputtypes.py | 136 ++++++++++-------- common/lib/capa/capa/responsetypes.py | 16 +-- common/lib/capa/capa/tests/test_inputtypes.py | 2 +- common/lib/xmodule/xmodule/capa_module.py | 20 ++- .../xmodule/tests/test_combined_open_ended.py | 2 +- lms/djangoapps/courseware/module_render.py | 2 +- 7 files changed, 126 insertions(+), 95 deletions(-) diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index f1fea4d8e3..27f1066030 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -91,8 +91,12 @@ class LoncapaProblem(object): - problem_text (string): xml defining the problem - id (string): identifier for this problem; often a filename (no spaces) - - state (dict): student state - - seed (int): random number generator seed (int) + - state (dict): containing the following keys: + - 'seed' - (int) random number generator seed + - 'student_answers' - (dict) maps input id to the stored answer for that input + - 'correct_map' (CorrectMap) a map of each input to their 'correctness' + - 'done' - (bool) indicates whether or not this problem is considered done + - 'input_state' - (dict) maps input_id to a dictionary that holds the state for that input - system (ModuleSystem): ModuleSystem instance which provides OS, rendering, and user context @@ -104,27 +108,16 @@ class LoncapaProblem(object): self.system = system if self.system is None: raise Exception() - self.seed = seed - self.input_state = None - if state: - if 'seed' in state: - self.seed = state['seed'] - if 'student_answers' in state: - self.student_answers = state['student_answers'] - if 'correct_map' in state: - self.correct_map.set_dict(state['correct_map']) - if 'done' in state: - self.done = state['done'] - if 'input_state' in state: - self.input_state = state['input_state'] + state = state if state else {} + self.seed = seed if seed else state.get('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']) + self.done = state.get('done', False) + self.input_state = state.get('input_state', {}) - # TODO: Does this deplete the Linux entropy pool? Is this fast enough? - if not self.seed: - self.seed = struct.unpack('i', os.urandom(4))[0] - if not self.input_state: - self.input_state = {} # Convert startouttext and endouttext to proper problem_text = re.sub("startouttext\s*/", "text", problem_text) @@ -240,13 +233,14 @@ class LoncapaProblem(object): def ungraded_response(self, xqueue_msg, queuekey): ''' - Handle any responses from the xqueue that are not related to grading + Handle any responses from the xqueue that do not contain grades + Will try to pass the queue message to all inputtypes that can handle ungraded responses Does not return any value ''' # check against each inputtype for the_input in self.inputs.values(): - # if the input type has an xqueue_response function, pass in the values + # if the input type has an ungraded function, pass in the values if hasattr(the_input, 'ungraded_response'): the_input.ungraded_response(xqueue_msg, queuekey) @@ -542,11 +536,14 @@ class LoncapaProblem(object): if self.student_answers and problemid in self.student_answers: value = self.student_answers[problemid] + if input_id not in self.input_state: + self.input_state[input_id] = {} + # do the rendering state = {'value': value, 'status': status, 'id': input_id, - 'input_state': self.input_state, + 'input_state': self.input_state[input_id], 'feedback': {'message': msg, 'hint': hint, 'hintmode': hintmode, }} diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index 0208f32503..d5268fed89 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -161,7 +161,7 @@ class InputTypeBase(object): self.msg = feedback.get('message', '') self.hint = feedback.get('hint', '') self.hintmode = feedback.get('hintmode', None) - self.input_state_dict = state.get('input_state', {}) + self.input_state = state.get('input_state', {}) # put hint above msg if it should be displayed if self.hintmode == 'always': @@ -591,14 +591,14 @@ class CodeInput(InputTypeBase): Attribute('tabsize', 4, transform=int), ] - def setup(self): + def setup_code_response_rendering(self): """ Implement special logic: handle queueing state, and default input. """ # if no student input yet, then use the default input given by the # problem - if not self.value: - self.value = self.xml.text + if not self.value and self.xml.text: + self.value = self.xml.text.strip() # Check if problem has been queued self.queue_len = 0 @@ -609,6 +609,11 @@ class CodeInput(InputTypeBase): self.queue_len = self.msg self.msg = self.submitted_msg + + def setup(self): + ''' setup this input type ''' + self.setup_code_response_rendering() + def _extra_context(self): """Defined queue_len, add it """ return {'queue_len': self.queue_len, } @@ -623,8 +628,10 @@ class MatlabInput(CodeInput): ''' InputType for handling Matlab code input + TODO: API_KEY will go away once we have a way to specify it per-course Example: + Initial Text %api_key=API_KEY @@ -633,51 +640,56 @@ class MatlabInput(CodeInput): template = "matlabinput.html" tags = ['matlabinput'] - # pulled out for testing - submitted_msg = ("Submitted. As soon as your submission is" - " graded, this message will be replaced with the grader's feedback.") + plot_submitted_msg = ("Submitted. As soon as a response is returned, " + "this message will be replaced by that feedback.") def setup(self): ''' Handle matlab-specific parsing ''' - # if we don't have state for this input type yet, make one - if self.id not in self.input_state_dict: - self.input_state_dict[self.id] = {} + self.setup_code_response_rendering() - self.input_state = self.input_state_dict[self.id] xml = self.xml self.plot_payload = xml.findtext('./plot_payload') - # if no student input yet, then use the default input given by the - # problem - if not self.value: - self.value = self.xml.text # Check if problem has been queued - self.queue_len = 0 self.queuename = 'matlab' - # Flag indicating that the problem has been queued, 'msg' is length of self.queue_msg = '' if 'queue_msg' in self.input_state and self.status in ['incomplete', 'unsubmitted']: self.queue_msg = self.input_state['queue_msg'] if 'queued' in self.input_state and self.input_state['queuestate'] is not None: self.status = 'queued' self.queue_len = 1 - # queue - if self.status == 'incomplete': - self.status = 'queued' - self.queue_len = self.msg - self.msg = self.submitted_msg - + self.msg = self.plot_submitted_msg def handle_ajax(self, dispatch, get): - ''' Handle AJAX calls directed to this input''' + ''' + Handle AJAX calls directed to this input + + Args: + - dispatch (str) - indicates how we want this ajax call to be handled + - get (dict) - dictionary of key-value pairs that contain useful data + Returns: + + ''' + if dispatch == 'plot': return self._plot_data(get) + return {} def ungraded_response(self, queue_msg, queuekey): - ''' Handle any XQueue responses that have to be saved and rendered ''' + ''' + Handle the response from the XQueue + Stores the response in the input_state so it can be rendered later + + Args: + - queue_msg (str) - message returned from the queue. The message to be rendered + - queuekey (str) - a key passed to the queue. Will be matched up to verify that this is the response we're waiting for + + Returns: + nothing + ''' # check the queuekey against the saved queuekey if('queuestate' in self.input_state and self.input_state['queuestate'] == 'queued' and self.input_state['queuekey'] == queuekey): @@ -697,9 +709,11 @@ class MatlabInput(CodeInput): def _parse_data(self, queue_msg): ''' - takes a queue_msg returned from the queue and parses it and returns - whatever is stored in msg - returns string msg + Parses the message out of the queue message + Args: + queue_msg (str) - a JSON encoded string + Returns: + returns the value for the the key 'msg' in queue_msg ''' try: result = json.loads(queue_msg) @@ -712,42 +726,50 @@ class MatlabInput(CodeInput): def _plot_data(self, get): - ''' send data via xqueue to the mathworks backend''' + ''' + AJAX handler for the plot button + Args: + get (dict) - should have key 'submission' which contains the student submission + Returns: + dict - 'success' - whether or not we successfully queued this submission + - 'message' - message to be rendered in case of error + ''' # only send data if xqueue exists - if self.system.xqueue is not None: - # pull relevant info out of get - response = get['submission'] + if self.system.xqueue is None: + return {'success': False, 'message': 'Cannot connect to the queue'} - # construct xqueue headers - qinterface = self.system.xqueue['interface'] - qtime = datetime.strftime(datetime.now(), xqueue_interface.dateformat) - callback_url = self.system.xqueue['construct_callback']('ungraded_response') - anonymous_student_id = self.system.anonymous_student_id - queuekey = xqueue_interface.make_hashkey(str(self.system.seed) + qtime + - anonymous_student_id + - self.id) - xheader = xqueue_interface.make_xheader( - lms_callback_url = callback_url, - lms_key = queuekey, - queue_name = self.queuename) + # pull relevant info out of get + response = get['submission'] - # save the input state - self.input_state['queuekey'] = queuekey - self.input_state['queuestate'] = 'queued' + # construct xqueue headers + qinterface = self.system.xqueue['interface'] + qtime = datetime.strftime(datetime.utcnow(), xqueue_interface.dateformat) + callback_url = self.system.xqueue['construct_callback']('ungraded_response') + anonymous_student_id = self.system.anonymous_student_id + queuekey = xqueue_interface.make_hashkey(str(self.system.seed) + qtime + + anonymous_student_id + + self.id) + xheader = xqueue_interface.make_xheader( + lms_callback_url = callback_url, + lms_key = queuekey, + queue_name = self.queuename) + + # save the input state + self.input_state['queuekey'] = queuekey + self.input_state['queuestate'] = 'queued' - # construct xqueue body - student_info = {'anonymous_student_id': anonymous_student_id, - 'submission_time': qtime} - contents = {'grader_payload': self.plot_payload, - 'student_info': json.dumps(student_info), - 'student_response': response} + # construct xqueue body + student_info = {'anonymous_student_id': anonymous_student_id, + 'submission_time': qtime} + contents = {'grader_payload': self.plot_payload, + 'student_info': json.dumps(student_info), + 'student_response': response} - (error, msg) = qinterface.send_to_queue(header=xheader, - body = json.dumps(contents)) + (error, msg) = qinterface.send_to_queue(header=xheader, + body = json.dumps(contents)) - return {'success': error == 0, 'message': msg} - return {'success': False, 'message': 'Cannot connect to the queue'} + return {'success': error == 0, 'message': msg} registry.register(MatlabInput) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index bb202e6d6e..8ab716735c 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -1147,10 +1147,10 @@ def sympy_check2(): correct = [] messages = [] for input_dict in input_list: - correct.append('correct' if input_dict[ - 'ok'] else 'incorrect') - msg = self.clean_message_html(input_dict[ - 'msg']) if 'msg' in input_dict else None + correct.append('correct' + if input_dict['ok'] else 'incorrect') + msg = (self.clean_message_html(input_dict['msg']) + if 'msg' in input_dict else None) messages.append(msg) # Otherwise, we do not recognize the dictionary @@ -1164,8 +1164,8 @@ def sympy_check2(): # indicating whether all inputs should be marked # correct or incorrect else: - correct = ['correct'] * len( - idset) if ret else ['incorrect'] * len(idset) + n = len(idset) + correct = ['correct'] * n if ret else ['incorrect'] * n # build map giving "correct"ness of the answer(s) correct_map = CorrectMap() @@ -1174,8 +1174,8 @@ def sympy_check2(): correct_map.set_overall_message(overall_message) for k in range(len(idset)): - npoints = self.maxpoints[idset[ - k]] if correct[k] == 'correct' else 0 + npoints = (self.maxpoints[idset[k]] + if correct[k] == 'correct' else 0) correct_map.set(idset[k], correct[k], msg=messages[k], npoints=npoints) return correct_map diff --git a/common/lib/capa/capa/tests/test_inputtypes.py b/common/lib/capa/capa/tests/test_inputtypes.py index b9da9df03f..250cedd549 100644 --- a/common/lib/capa/capa/tests/test_inputtypes.py +++ b/common/lib/capa/capa/tests/test_inputtypes.py @@ -357,7 +357,7 @@ class MatlabTest(unittest.TestCase): def test_rendering_with_state(self): state = {'value': 'print "good evening"', 'status': 'incomplete', - 'input_state': {'prob_1_2': {'queue_msg': 'message'}}, + 'input_state': {'queue_msg': 'message'}, 'feedback': {'message': '3'}, } elt = etree.fromstring(self.xml) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 6ce8d3a805..da8b5b4f96 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -543,8 +543,16 @@ class CapaModule(CapaFields, XModule): def handle_ungraded_response(self, get): ''' - Delivers a response to the capa problem where the expectation where this response does - not have relevant grading information + Delivers a response from the XQueue to the capa problem + + The score of the problem will not be updated + + Args: + - get (dict) must contain keys: + queuekey - a key specific to this response + xqueue_body - the body of the response + Returns: + empty dictionary No ajax return is needed, so an empty dict is returned ''' @@ -557,8 +565,12 @@ class CapaModule(CapaFields, XModule): def handle_input_ajax(self, get): ''' - Passes information down to the capa problem so that it can handle its own ajax calls - Returns the response from the capa problem + Handle ajax calls meant for a particular input in the problem + + Args: + - get (dict) - data that should be passed to the input + Returns: + - dict containing the response from the input ''' response = self.lcp.handle_input_ajax(get) # save any state changes that may occur 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 aa8a077cc1..55c31ded58 100644 --- a/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py +++ b/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py @@ -183,7 +183,7 @@ 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"): + def constructed_callback(dispatch="score_update"): return dispatch self.test_system.xqueue = {'interface': self.mock_xqueue, 'construct_callback': constructed_callback, 'default_queuename': 'testqueue', diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 0954f8d28c..973940d784 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -182,7 +182,7 @@ def get_module_for_descriptor(user, request, descriptor, model_data_cache, cours proto=request.META.get('HTTP_X_FORWARDED_PROTO', 'https' if request.is_secure() else 'http') ) - def make_xqueue_callback(dispatch = 'score_update'): + def make_xqueue_callback(dispatch='score_update'): # Fully qualified callback URL for external queueing system xqueue_callback_url = '{proto}://{host}'.format( host=request.get_host(), From 38df54dc53df10cd8a23705e888fdfc229cece16 Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Wed, 20 Mar 2013 16:19:45 -0400 Subject: [PATCH 054/264] studio - moved handling scrollable and new window links into their own functions for use throughout the app and not just on doc.ready + removed unused js plugin reference --- cms/static/js/base.js | 33 ++++++++++++++++++++++----------- cms/templates/base.html | 1 - 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/cms/static/js/base.js b/cms/static/js/base.js index 18c31fc4e0..fa31f25906 100644 --- a/cms/static/js/base.js +++ b/cms/static/js/base.js @@ -45,9 +45,6 @@ $(document).ready(function () { (e).preventDefault(); }); - // smooth scrolling page links - $('a[rel*="view"]').smoothScroll({offset: -200, easing: 'swing', speed: 2000}); - // nav - dropdown related $body.click(function (e) { $('.nav-dropdown .nav-item .wrapper-nav-sub').removeClass('is-shown'); @@ -75,10 +72,7 @@ $(document).ready(function () { }); // general link management - new window/tab - $('a[rel="external"]').attr('title', 'This link will open in a new browser window/tab').click(function (e) { - window.open($(this).attr('href')); - e.preventDefault(); - }); + $('a[rel="external"]').attr('title', 'This link will open in a new browser window/tab').bind('click', linkNewWindow); // general link management - lean modal window $('a[rel="modal"]').attr('title', 'This link will open in a modal window').leanModal({overlay: 0.50, closeButton: '.action-modal-close' }); @@ -86,6 +80,10 @@ $(document).ready(function () { (e).preventDefault(); }); + // general link management - smooth scrolling page links + $('a[rel*="view"]').bind('click', linkSmoothScroll); + + // toggling overview section details $(function () { if ($('.courseware-section').length > 0) { @@ -155,10 +153,23 @@ $(document).ready(function () { }); }); -// function collapseAll(e) { -// $('.branch').addClass('collapsed'); -// $('.expand-collapse-icon').removeClass('collapse').addClass('expand'); -// } +function linkSmoothScroll(e) { + (e).preventDefault(); + + $.smoothScroll({ + offset: -200, + easing: 'swing', + speed: 1000, + scrollElement: null, + scrollTarget: $(this).attr('href') + }); + console.log('clicked!'); +} + +function linkNewWindow(e) { + window.open($(this).attr('href')); + e.preventDefault(); +} function toggleSections(e) { e.preventDefault(); diff --git a/cms/templates/base.html b/cms/templates/base.html index 6ce0de4b11..e852b5d7fe 100644 --- a/cms/templates/base.html +++ b/cms/templates/base.html @@ -46,7 +46,6 @@ - From ccd4db9ac3ed5f174fcb824a47920fd928ddf2cc Mon Sep 17 00:00:00 2001 From: cahrens Date: Mon, 25 Mar 2013 15:24:53 -0400 Subject: [PATCH 096/264] Writing test. --- cms/static/js/views/validating_view.js | 28 +++++++++++++------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/cms/static/js/views/validating_view.js b/cms/static/js/views/validating_view.js index c3ea57fd20..3376e5fe9b 100644 --- a/cms/static/js/views/validating_view.js +++ b/cms/static/js/views/validating_view.js @@ -25,14 +25,7 @@ CMS.Views.ValidatingView = Backbone.View.extend({ for (var field in error) { var ele = this.$el.find('#' + this.fieldToSelectorMap[field]); this._cacheValidationErrors.push(ele); - var inputElements = 'input, textarea'; - if ($(ele).is(inputElements)) { - $(ele).addClass('error'); - } - else { - // put error on the contained inputs - $(ele).find(inputElements).addClass('error'); - } + this.getInputElements(ele).addClass('error'); $(ele).parent().append(this.errorTemplate({message : error[field]})); } }, @@ -40,12 +33,8 @@ CMS.Views.ValidatingView = Backbone.View.extend({ clearValidationErrors : function() { // error is object w/ fields and error strings while (this._cacheValidationErrors.length > 0) { - var ele = this._cacheValidationErrors.pop(); - if ($(ele).is('div')) { - // put error on the contained inputs - $(ele).find('input, textarea').removeClass('error'); - } - else $(ele).removeClass('error'); + var ele = this._cacheValidationErrors.pop(); + this.getInputElements(ele).removeClass('error'); $(ele).nextAll('.message-error').remove(); } }, @@ -68,5 +57,16 @@ CMS.Views.ValidatingView = Backbone.View.extend({ }, inputUnfocus : function(event) { $("label[for='" + event.currentTarget.id + "']").removeClass("is-focused"); + }, + + getInputElements: function(ele) { + var inputElements = 'input, textarea'; + if ($(ele).is(inputElements)) { + return $(ele); + } + else { + // put error on the contained inputs + return $(ele).find(inputElements); + } } }); From 7dcb1bf7c64f7c9ea2de847c09be3c63e3a6da12 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Mon, 25 Mar 2013 16:09:13 -0400 Subject: [PATCH 097/264] it appears we are taking one too many round trips to do when pre-fetching children. This can be very expensive as the tree gets wider the deeper we go. For example, in courseware we want depth=2 (course, chapter, sequential). But looking at log output we were also getting verticals, which there can be a lot of. This should cut down on the total data we are grabbing from the DB. --- common/lib/xmodule/xmodule/modulestore/mongo.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/common/lib/xmodule/xmodule/modulestore/mongo.py b/common/lib/xmodule/xmodule/modulestore/mongo.py index f6fa98fc28..b76251bb99 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo.py @@ -366,6 +366,9 @@ class MongoModuleStore(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 From c660229b25f4ef1f9b922c8b325e60cbcb35bcb3 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Mon, 25 Mar 2013 16:37:59 -0400 Subject: [PATCH 098/264] Added checking for problem answer state after a problem is checked --- .../courseware/features/problems.feature | 4 + .../courseware/features/problems.py | 92 ++++++++++++++++++- 2 files changed, 91 insertions(+), 5 deletions(-) diff --git a/lms/djangoapps/courseware/features/problems.feature b/lms/djangoapps/courseware/features/problems.feature index efeb338c45..dc8495af60 100644 --- a/lms/djangoapps/courseware/features/problems.feature +++ b/lms/djangoapps/courseware/features/problems.feature @@ -8,6 +8,7 @@ Feature: Answer problems And I am viewing a "" problem When I answer a "" problem "correctly" Then My "" answer is marked "correct" + And The "" problem displays a "correct" answer Examples: | ProblemType | @@ -25,6 +26,7 @@ Feature: Answer problems And I am viewing a "" problem When I answer a "" problem "incorrectly" Then My "" answer is marked "incorrect" + And The "" problem displays a "incorrect" answer Examples: | ProblemType | @@ -41,6 +43,7 @@ Feature: Answer problems Given I am viewing a "" problem When I check a problem Then My "" answer is marked "incorrect" + And The "" problem displays a "blank" answer Examples: | ProblemType | @@ -58,6 +61,7 @@ Feature: Answer problems And I answer a "" problem "ly" When I reset the problem Then My "" answer is marked "unanswered" + And The "" problem displays a "blank" answer Examples: | ProblemType | Correctness | diff --git a/lms/djangoapps/courseware/features/problems.py b/lms/djangoapps/courseware/features/problems.py index 6b2239c38b..36a0477988 100644 --- a/lms/djangoapps/courseware/features/problems.py +++ b/lms/djangoapps/courseware/features/problems.py @@ -2,8 +2,8 @@ from lettuce import world, step from lettuce.django import django_url import random import textwrap -import time -from common import i_am_registered_for_the_course, TEST_SECTION_NAME, section_location +from common import i_am_registered_for_the_course, \ + TEST_SECTION_NAME, section_location from capa.tests.response_xml_factory import OptionResponseXMLFactory, \ ChoiceResponseXMLFactory, MultipleChoiceResponseXMLFactory, \ StringResponseXMLFactory, NumericalResponseXMLFactory, \ @@ -26,7 +26,7 @@ PROBLEM_FACTORY_DICT = { 'kwargs': { 'question_text': 'The correct answer is Choice 3', 'choices': [False, False, True, False], - 'choice_names': ['choice_1', 'choice_2', 'choice_3', 'choice_4']}}, + 'choice_names': ['choice_0', 'choice_1', 'choice_2', 'choice_3']}}, 'checkbox': { 'factory': ChoiceResponseXMLFactory(), @@ -152,9 +152,9 @@ def answer_problem(step, problem_type, correctness): elif problem_type == "multiple choice": if correctness == 'correct': - inputfield('multiple choice', choice='choice_3').check() - else: inputfield('multiple choice', choice='choice_2').check() + else: + inputfield('multiple choice', choice='choice_1').check() elif problem_type == "checkbox": if correctness == 'correct': @@ -202,6 +202,65 @@ def answer_problem(step, problem_type, correctness): # Submit the problem check_problem(step) +@step(u'The "([^"]*)" problem displays a "([^"]*)" answer') +def assert_problem_has_answer(step, problem_type, answer_class): + ''' + Assert that the problem is displaying a particular answer. + These correspond to the same correct/incorrect + answers we set in answer_problem() + + We can also check that a problem has been left blank + by setting answer_class='blank' + ''' + assert answer_class in ['correct', 'incorrect', 'blank'] + + if problem_type == "drop down": + if answer_class == 'blank': + assert world.browser.is_element_not_present_by_css('option[selected="true"]') + else: + actual = world.browser.find_by_css('option[selected="true"]').value + expected = 'Option 2' if answer_class == 'correct' else 'Option 3' + assert actual == expected + + elif problem_type == "multiple choice": + if answer_class == 'correct': + assert_checked('multiple choice', ['choice_2']) + elif answer_class == 'incorrect': + assert_checked('multiple choice', ['choice_1']) + else: + assert_checked('multiple choice', []) + + elif problem_type == "checkbox": + if answer_class == 'correct': + assert_checked('checkbox', ['choice_0', 'choice_2']) + elif answer_class == 'incorrect': + assert_checked('checkbox', ['choice_3']) + else: + assert_checked('checkbox', []) + + elif problem_type == 'string': + if answer_class == 'blank': + expected = '' + else: + expected = 'correct string' if answer_class == 'correct' else 'incorrect' + + assert_textfield('string', expected) + + elif problem_type == 'formula': + if answer_class == 'blank': + expected = '' + else: + expected = "x^2+2*x+y" if answer_class == 'correct' else 'x^2' + + assert_textfield('formula', expected) + + else: + # The other response types use random data, + # which would be difficult to check + # We trade input value coverage in the other tests for + # input type coverage in this test. + pass + @step(u'I check a problem') def check_problem(step): @@ -274,6 +333,7 @@ def assert_answer_mark(step, problem_type, correctness): # Expect that we found the expected selector assert(has_expected) + def inputfield(problem_type, choice=None, input_num=1): """ Return the element for *problem_type*. For example, if problem_type is 'string', return @@ -289,8 +349,30 @@ def inputfield(problem_type, choice=None, input_num=1): base = "_choice_" if problem_type == "multiple choice" else "_" sel = sel + base + str(choice) + # If the input element doesn't exist, fail immediately assert(world.browser.is_element_present_by_css(sel, wait_time=4)) # Retrieve the input element return world.browser.find_by_css(sel) + +def assert_checked(problem_type, choices): + ''' + Assert that choice names given in *choices* are the only + ones checked. + + Works for both radio and checkbox problems + ''' + + all_choices = ['choice_0', 'choice_1', 'choice_2', 'choice_3'] + for ch in all_choices: + el = inputfield(problem_type, choice=ch) + + if ch in choices: + assert el.checked + else: + assert not el.checked + +def assert_textfield(problem_type, expected_text, input_num=1): + el = inputfield(problem_type, input_num=input_num) + assert el.value == expected_text From 37e7d68cef72174e33bdb4d2dab06b881ab69c9f Mon Sep 17 00:00:00 2001 From: Will Daly Date: Mon, 25 Mar 2013 16:46:31 -0400 Subject: [PATCH 099/264] pep8 and pylint fixes --- .../courseware/features/problems.py | 55 ++++++++++++------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/lms/djangoapps/courseware/features/problems.py b/lms/djangoapps/courseware/features/problems.py index 36a0477988..d2d379a212 100644 --- a/lms/djangoapps/courseware/features/problems.py +++ b/lms/djangoapps/courseware/features/problems.py @@ -1,3 +1,8 @@ +''' +Steps for problem.feature lettuce tests +''' + + from lettuce import world, step from lettuce.django import django_url import random @@ -88,6 +93,9 @@ PROBLEM_FACTORY_DICT = { def add_problem_to_course(course, problem_type): + ''' + Add a problem to the course we have created using factories. + ''' assert(problem_type in PROBLEM_FACTORY_DICT) @@ -98,11 +106,12 @@ def add_problem_to_course(course, problem_type): # Create a problem item using our generated XML # We set rerandomize=always in the metadata so that the "Reset" button # will appear. - problem_item = world.ItemFactory.create(parent_location=section_location(course), - template="i4x://edx/templates/problem/Blank_Common_Problem", - display_name=str(problem_type), - data=problem_xml, - metadata={'rerandomize': 'always'}) + template_name = "i4x://edx/templates/problem/Blank_Common_Problem" + world.ItemFactory.create(parent_location=section_location(course), + template=template_name, + display_name=str(problem_type), + data=problem_xml, + metadata={'rerandomize': 'always'}) @step(u'I am viewing a "([^"]*)" problem') @@ -164,11 +173,13 @@ def answer_problem(step, problem_type, correctness): inputfield('checkbox', choice='choice_3').check() elif problem_type == 'string': - textvalue = 'correct string' if correctness == 'correct' else 'incorrect' + textvalue = 'correct string' if correctness == 'correct' \ + else 'incorrect' inputfield('string').fill(textvalue) elif problem_type == 'numerical': - textvalue = "pi + 1" if correctness == 'correct' else str(random.randint(-2, 2)) + textvalue = "pi + 1" if correctness == 'correct' \ + else str(random.randint(-2, 2)) inputfield('numerical').fill(textvalue) elif problem_type == 'formula': @@ -202,6 +213,7 @@ def answer_problem(step, problem_type, correctness): # Submit the problem check_problem(step) + @step(u'The "([^"]*)" problem displays a "([^"]*)" answer') def assert_problem_has_answer(step, problem_type, answer_class): ''' @@ -242,7 +254,8 @@ def assert_problem_has_answer(step, problem_type, answer_class): if answer_class == 'blank': expected = '' else: - expected = 'correct string' if answer_class == 'correct' else 'incorrect' + expected = 'correct string' if answer_class == 'correct' \ + else 'incorrect' assert_textfield('string', expected) @@ -286,7 +299,7 @@ CORRECTNESS_SELECTORS = { 'string': ['div.correct'], 'numerical': ['div.correct'], 'formula': ['div.correct'], - 'script': ['div.correct'], + 'script': ['div.correct'], 'code': ['span.correct']}, 'incorrect': {'drop down': ['span.incorrect'], @@ -306,12 +319,14 @@ CORRECTNESS_SELECTORS = { 'numerical': ['div.unanswered'], 'formula': ['div.unanswered'], 'script': ['div.unanswered'], - 'code': ['span.unanswered'] }} + 'code': ['span.unanswered']}} @step(u'My "([^"]*)" answer is marked "([^"]*)"') def assert_answer_mark(step, problem_type, correctness): - """ Assert that the expected answer mark is visible for a given problem type. + """ + Assert that the expected answer mark is visible + for a given problem type. *problem_type* is a string identifying the type of problem (e.g. 'drop down') *correctness* is in ['correct', 'incorrect', 'unanswered'] @@ -349,13 +364,14 @@ def inputfield(problem_type, choice=None, input_num=1): base = "_choice_" if problem_type == "multiple choice" else "_" sel = sel + base + str(choice) - + # If the input element doesn't exist, fail immediately assert(world.browser.is_element_present_by_css(sel, wait_time=4)) # Retrieve the input element return world.browser.find_by_css(sel) + def assert_checked(problem_type, choices): ''' Assert that choice names given in *choices* are the only @@ -365,14 +381,15 @@ def assert_checked(problem_type, choices): ''' all_choices = ['choice_0', 'choice_1', 'choice_2', 'choice_3'] - for ch in all_choices: - el = inputfield(problem_type, choice=ch) + for this_choice in all_choices: + element = inputfield(problem_type, choice=this_choice) - if ch in choices: - assert el.checked + if this_choice in choices: + assert element.checked else: - assert not el.checked + assert not element.checked + def assert_textfield(problem_type, expected_text, input_num=1): - el = inputfield(problem_type, input_num=input_num) - assert el.value == expected_text + element = inputfield(problem_type, input_num=input_num) + assert element.value == expected_text From fa63bcce949e13839bc457ccaa89433185a0a514 Mon Sep 17 00:00:00 2001 From: cahrens Date: Mon, 25 Mar 2013 17:25:49 -0400 Subject: [PATCH 100/264] Allow clearing of dates (except for course start date). https://edx.lighthouseapp.com/projects/102637/tickets/231 --- .../features/course-settings.feature | 28 +++ .../contentstore/features/course-settings.py | 163 ++++++++++++++++++ .../js/models/settings/course_details.js | 3 + .../js/views/settings/main_settings_view.js | 6 + 4 files changed, 200 insertions(+) create mode 100644 cms/djangoapps/contentstore/features/course-settings.feature create mode 100644 cms/djangoapps/contentstore/features/course-settings.py diff --git a/cms/djangoapps/contentstore/features/course-settings.feature b/cms/djangoapps/contentstore/features/course-settings.feature new file mode 100644 index 0000000000..1589e579d8 --- /dev/null +++ b/cms/djangoapps/contentstore/features/course-settings.feature @@ -0,0 +1,28 @@ +Feature: Course Settings + As a course author, I want to be able to configure my course settings. + + Scenario: User can set course dates + Given I have opened a new course in Studio + When I select Schedule and Details + And I set course dates + Then I see the set dates on refresh + + Scenario: User can clear previously set course dates (except start date) + Given I have set course dates + And I clear all the dates except start + Then I see cleared dates on refresh + + Scenario: User cannot clear the course start date + Given I have set course dates + And I clear the course start date + Then I receive a warning about course start date + And The previously set start date is shown on refresh + + Scenario: User can correct the course start date warning + Given I have tried to clear the course start + And I have entered a new course start date + Then The warning about course start date goes away + And My new course start date is shown on refresh + + + diff --git a/cms/djangoapps/contentstore/features/course-settings.py b/cms/djangoapps/contentstore/features/course-settings.py new file mode 100644 index 0000000000..a0c25045f2 --- /dev/null +++ b/cms/djangoapps/contentstore/features/course-settings.py @@ -0,0 +1,163 @@ +from lettuce import world, step +from common import * +from terrain.steps import reload_the_page +from selenium.webdriver.common.keys import Keys +import time + +from nose.tools import assert_true, assert_false, assert_equal + +COURSE_START_DATE_CSS = "#course-start-date" +COURSE_END_DATE_CSS = "#course-end-date" +ENROLLMENT_START_DATE_CSS = "#course-enrollment-start-date" +ENROLLMENT_END_DATE_CSS = "#course-enrollment-end-date" + +COURSE_START_TIME_CSS = "#course-start-time" +COURSE_END_TIME_CSS = "#course-end-time" +ENROLLMENT_START_TIME_CSS = "#course-enrollment-start-time" +ENROLLMENT_END_TIME_CSS = "#course-enrollment-end-time" + +DUMMY_TIME = "3:30pm" +DEFAULT_TIME = "12:00am" + + +############### ACTIONS #################### +@step('I select Schedule and Details$') +def test_i_select_schedule_and_details(step): + expand_icon_css = 'li.nav-course-settings i.icon-expand' + if world.browser.is_element_present_by_css(expand_icon_css): + css_click(expand_icon_css) + link_css = 'li.nav-course-settings-schedule a' + css_click(link_css) + + +@step('I have set course dates$') +def test_i_have_set_course_dates(step): + step.given('I have opened a new course in Studio') + step.given('I select Schedule and Details') + step.given('And I set course dates') + + +@step('And I set course dates$') +def test_and_i_set_course_dates(step): + set_date_or_time(COURSE_START_DATE_CSS, '12/20/2013') + set_date_or_time(COURSE_END_DATE_CSS, '12/26/2013') + set_date_or_time(ENROLLMENT_START_DATE_CSS, '12/1/2013') + set_date_or_time(ENROLLMENT_END_DATE_CSS, '12/10/2013') + + set_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME) + set_date_or_time(ENROLLMENT_END_TIME_CSS, DUMMY_TIME) + + pause() + + +@step('Then I see the set dates on refresh$') +def test_then_i_see_the_set_dates_on_refresh(step): + reload_the_page(step) + verify_date_or_time(COURSE_START_DATE_CSS, '12/20/2013') + verify_date_or_time(COURSE_END_DATE_CSS, '12/26/2013') + verify_date_or_time(ENROLLMENT_START_DATE_CSS, '12/01/2013') + verify_date_or_time(ENROLLMENT_END_DATE_CSS, '12/10/2013') + + verify_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME) + # Unset times get set to 12 AM once the corresponding date has been set. + verify_date_or_time(COURSE_END_TIME_CSS, DEFAULT_TIME) + verify_date_or_time(ENROLLMENT_START_TIME_CSS, DEFAULT_TIME) + verify_date_or_time(ENROLLMENT_END_TIME_CSS, DUMMY_TIME) + + +@step('And I clear all the dates except start$') +def test_and_i_clear_all_the_dates_except_start(step): + set_date_or_time(COURSE_END_DATE_CSS, '') + set_date_or_time(ENROLLMENT_START_DATE_CSS, '') + set_date_or_time(ENROLLMENT_END_DATE_CSS, '') + + pause() + + +@step('Then I see cleared dates on refresh$') +def test_then_i_see_cleared_dates_on_refresh(step): + reload_the_page(step) + verify_date_or_time(COURSE_END_DATE_CSS, '') + verify_date_or_time(ENROLLMENT_START_DATE_CSS, '') + verify_date_or_time(ENROLLMENT_END_DATE_CSS, '') + + verify_date_or_time(COURSE_END_TIME_CSS, '') + verify_date_or_time(ENROLLMENT_START_TIME_CSS, '') + verify_date_or_time(ENROLLMENT_END_TIME_CSS, '') + + # Verify course start date (required) and time still there + verify_date_or_time(COURSE_START_DATE_CSS, '12/20/2013') + verify_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME) + + +@step('I clear the course start date$') +def test_i_clear_the_course_start_date(step): + set_date_or_time(COURSE_START_DATE_CSS, '') + + +@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')) + + +@step('The previously set start date is shown on refresh$') +def test_the_previously_set_start_date_is_shown_on_refresh(step): + reload_the_page(step) + verify_date_or_time(COURSE_START_DATE_CSS, '12/20/2013') + verify_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME) + + +@step('Given I have tried to clear the course start$') +def test_i_have_tried_to_clear_the_course_start(step): + step.given("I have set course dates") + step.given("I clear the course start date") + step.given("I receive a warning about course start date") + + +@step('I have entered a new course start date$') +def test_i_have_entered_a_new_course_start_date(step): + set_date_or_time(COURSE_START_DATE_CSS, '12/22/2013') + pause() + + +@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')) + + +@step('My new course start date is shown on refresh$') +def test_my_new_course_start_date_is_shown_on_refresh(step): + reload_the_page(step) + verify_date_or_time(COURSE_START_DATE_CSS, '12/22/2013') + # Time should have stayed from before attempt to clear date. + verify_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME) + + +############### HELPER METHODS #################### +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 + # hit Enter to apply the changes + e._element.send_keys(Keys.ENTER) + + +def verify_date_or_time(css, date_or_time): + """ + Verifies date or time field. + """ + assert_equal(date_or_time, css_find(css).first.value) + + +def pause(): + """ + Must sleep briefly to allow last time save to finish, + else refresh of browser will fail. + """ + time.sleep(float(1)) diff --git a/cms/static/js/models/settings/course_details.js b/cms/static/js/models/settings/course_details.js index 148df7a325..d41545cca9 100644 --- a/cms/static/js/models/settings/course_details.js +++ b/cms/static/js/models/settings/course_details.js @@ -37,6 +37,9 @@ CMS.Models.Settings.CourseDetails = Backbone.Model.extend({ // Returns either nothing (no return call) so that validate works or an object of {field: errorstring} pairs // A bit funny in that the video key validation is asynchronous; so, it won't stop the validation. var errors = {}; + if (newattrs.start_date === null) { + errors.start_date = "The course must have an assigned start date."; + } if (newattrs.start_date && newattrs.end_date && newattrs.start_date >= newattrs.end_date) { errors.end_date = "The course end date cannot be before the course start date."; } diff --git a/cms/static/js/views/settings/main_settings_view.js b/cms/static/js/views/settings/main_settings_view.js index 9bd8feab8c..3e1690f0b6 100644 --- a/cms/static/js/views/settings/main_settings_view.js +++ b/cms/static/js/views/settings/main_settings_view.js @@ -101,6 +101,12 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ cacheModel.save(fieldName, newVal); } } + else { + // Clear date (note that this clears the time as well, as date and time are linked). + // Note also that the validation logic prevents us from clearing the start date + // (start date is required by the back end). + cacheModel.save(fieldName, null); + } }; // instrument as date and time pickers From ce884c44ee94fd6229fa2397496c698da0dd835e Mon Sep 17 00:00:00 2001 From: cahrens Date: Mon, 25 Mar 2013 17:26:59 -0400 Subject: [PATCH 101/264] Newline cleanup. --- cms/djangoapps/contentstore/features/course-settings.feature | 3 --- 1 file changed, 3 deletions(-) diff --git a/cms/djangoapps/contentstore/features/course-settings.feature b/cms/djangoapps/contentstore/features/course-settings.feature index 1589e579d8..e869bfe47a 100644 --- a/cms/djangoapps/contentstore/features/course-settings.feature +++ b/cms/djangoapps/contentstore/features/course-settings.feature @@ -23,6 +23,3 @@ Feature: Course Settings And I have entered a new course start date Then The warning about course start date goes away And My new course start date is shown on refresh - - - From d8f1c2b41a7b1f0af023f8dd75048f31d3df8569 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Mon, 25 Mar 2013 22:49:39 -0400 Subject: [PATCH 102/264] add unit test for proper depth build out --- .../contentstore/tests/test_contentstore.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 615ffb6ed0..5b080c7a83 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -205,7 +205,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']) @@ -307,6 +307,21 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): # note, we know the link it should be because that's what in the 'full' course in the test data self.assertContains(resp, '/c4x/edX/full/asset/handouts_schematic_tutorial.pdf') + def test_prefetch_children(self): + import_from_xml(modulestore(), 'common/test/data/', ['full']) + module_store = modulestore('direct') + location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') + + course = module_store.get_item(location, depth=2) + + # make sure we pre-fetched a known sequential which should be at depth=2 + 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', + None]) in course.system.module_data) + def test_export_course_with_unknown_metadata(self): module_store = modulestore('direct') content_store = contentstore() From 269152c4f2072decad55615c3fc7f612cce28075 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Mon, 25 Mar 2013 23:15:35 -0400 Subject: [PATCH 103/264] add a test scenario to count RT to database when prefetching children. This uses a shim function on pymongo's collection.find to do the counting --- .../contentstore/tests/test_contentstore.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 5b080c7a83..edb20561bc 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -37,6 +37,14 @@ TEST_DATA_MODULESTORE = copy.deepcopy(settings.MODULESTORE) TEST_DATA_MODULESTORE['default']['OPTIONS']['fs_root'] = path('common/test/data') TEST_DATA_MODULESTORE['direct']['OPTIONS']['fs_root'] = path('common/test/data') +class MongoCollectionFindWrapper(object): + def __init__(self, original): + self.original = original + self.counter = 0 + + def find(self, query, *args, **kwargs): + self.counter = self.counter+1 + return self.original(query, *args, **kwargs) @override_settings(MODULESTORE=TEST_DATA_MODULESTORE) class ContentStoreToyCourseTest(ModuleStoreTestCase): @@ -145,8 +153,6 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): # make sure the parent no longer points to the child object which was deleted self.assertFalse(sequential.location.url() in chapter.children) - - def test_about_overrides(self): ''' This test case verifies that a course can use specialized override for about data, e.g. /about/Fall_2012/effort.html @@ -312,8 +318,15 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): module_store = modulestore('direct') location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') + wrapper = MongoCollectionFindWrapper(module_store.collection.find) + module_store.collection.find = wrapper.find course = module_store.get_item(location, depth=2) + # make sure we haven't done too many round trips to DB + # note we say 4 round trips here for 1) the course, 2 & 3) for the chapters and sequentials, and + # 4) because of the RT due to calculating the inherited metadata + 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', 'Administrivia_and_Circuit_Elements', None]) in course.system.module_data) From 175e1b46e99e73b7e1dd75008b88fd7a1bb87947 Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Tue, 26 Mar 2013 08:32:49 -0400 Subject: [PATCH 104/264] studio - in progress styling of tender help widget --- cms/static/sass/_tender-widget.scss | 91 ----------------------------- cms/static/sass/base-style.scss | 1 + cms/templates/base.html | 1 - cms/templates/widgets/footer.html | 3 - cms/templates/widgets/tender.html | 4 +- 5 files changed, 3 insertions(+), 97 deletions(-) delete mode 100644 cms/static/sass/_tender-widget.scss diff --git a/cms/static/sass/_tender-widget.scss b/cms/static/sass/_tender-widget.scss deleted file mode 100644 index 422be36908..0000000000 --- a/cms/static/sass/_tender-widget.scss +++ /dev/null @@ -1,91 +0,0 @@ -// tender help/support widget -// ==================== - -#tender_frame, #tender_window { - background-image: none !important; -} - -#tender_frame { - @include border-radius(3px); - @include box-shadow(0 2px 3px $shadow); - border: 1px solid $gray; - background: $white; -} - -#tender_closer { - color: $blue-l2 !important; - margin-top: 15px; - margin-right: 5px; -} - -// ==================== - -// tender style overrides - not rendered through here, but an archive is needed -#tender_frame iframe html { - font-size: 62.5%; -} - -.widget-layout { - font-family: 'Open Sans', sans-serif; -} - -.widget-layout .search, -.widget-layout .tabs, -.widget-layout .header h1 a { - display: none; -} - -.widget-layout .header { - background: rgb(85, 151, 221); - padding: 20px; -} - -.widget-layout h1, .widget-layout h2, .widget-layout h3, .widget-layout h4, .widget-layout h5, .widget-layout h6, .widget-layout label { - font-weight: 600; -} - -.widget-layout .header h1 { - font-weight: 700; - font-size: 24px; - font-size: 2.4rem; -} - -.widget-layout .content { - padding: 20px; -} - -.widget-layout label { - font-size: 14px; - font-size: 1.4rem; - margin-bottom: 5px; - color: rgb(127,127,127) !important; -} - -.widget-layout input[type="text"], .widget-layout textarea { - padding: 10px; - font-size: 16px; - font-size: 1.6rem; - color: rgb(0,0,0) !important; -} - -.widget-layout textarea { - width: 97%; -} - -.widget-layout .form-actions { - border-top: 1px solid #ccc; - margin-top: 10px; - padding-top: 10px; -} - -.widget-layout dl.form { - margin-bottom: 15px; -} - -.widget-layout #brain_buster_captcha { - display: block; - width: 100%; - border-bottom: 1px solid #ccc; - margin-bottom: 10px; - padding-bottom: 10px; -} \ No newline at end of file diff --git a/cms/static/sass/base-style.scss b/cms/static/sass/base-style.scss index 6a1f1bb252..5c67789f72 100644 --- a/cms/static/sass/base-style.scss +++ b/cms/static/sass/base-style.scss @@ -28,6 +28,7 @@ @import 'elements/modal'; @import 'elements/alerts'; @import 'elements/jquery-ui-calendar'; +@import 'elements/tender-widget'; // specific views @import 'views/account'; diff --git a/cms/templates/base.html b/cms/templates/base.html index 45147e5783..fd2b96f03a 100644 --- a/cms/templates/base.html +++ b/cms/templates/base.html @@ -56,7 +56,6 @@ <%block name="content"> <%include file="widgets/footer.html" /> <%include file="widgets/tender.html" /> - <%block name="jsextra"> diff --git a/cms/templates/widgets/footer.html b/cms/templates/widgets/footer.html index e3063dafa7..8f0265cd86 100644 --- a/cms/templates/widgets/footer.html +++ b/cms/templates/widgets/footer.html @@ -17,9 +17,6 @@ - diff --git a/cms/templates/widgets/tender.html b/cms/templates/widgets/tender.html index e2bba3d2ef..300b71701c 100644 --- a/cms/templates/widgets/tender.html +++ b/cms/templates/widgets/tender.html @@ -1,12 +1,12 @@ % if user.is_authenticated(): +Provide Feedback From 69c95ca785b60da37ea5e3aadadf177bef1f4c01 Mon Sep 17 00:00:00 2001 From: cahrens Date: Tue, 26 Mar 2013 09:51:24 -0400 Subject: [PATCH 105/264] Newline cleanup. --- cms/static/js/base.js | 1 + 1 file changed, 1 insertion(+) diff --git a/cms/static/js/base.js b/cms/static/js/base.js index 0521371b6a..f623607d14 100644 --- a/cms/static/js/base.js +++ b/cms/static/js/base.js @@ -303,6 +303,7 @@ function saveSubsection() { data: JSON.stringify({ 'id': id, 'metadata': metadata}), success: function () { $spinner.delay(500).fadeOut(150); + $changedInput = null; }, error: function () { showToastMessage('There has been an error while saving your changes.'); From df935d422d31fcf34489f8b0fa501a4ac627212a Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Tue, 26 Mar 2013 09:52:26 -0400 Subject: [PATCH 106/264] 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 107/264] 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 108/264] 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 f681d4300d7c9226eed57ee117169126598f9d42 Mon Sep 17 00:00:00 2001 From: cahrens Date: Tue, 26 Mar 2013 10:42:44 -0400 Subject: [PATCH 109/264] More cleanup in base.js. --- cms/static/js/base.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cms/static/js/base.js b/cms/static/js/base.js index f623607d14..bd8dc0bae8 100644 --- a/cms/static/js/base.js +++ b/cms/static/js/base.js @@ -236,7 +236,7 @@ function getEdxTimeFromDateTimeVals(date_val, time_val, format) { time_val = '00:00'; // Note, we are using date.js utility which has better parsing abilities than the built in JS date parsing - date = Date.parse(date_val + " " + time_val); + var date = Date.parse(date_val + " " + time_val); if (format == null) format = 'yyyy-MM-ddTHH:mm'; @@ -254,6 +254,7 @@ function getEdxTimeFromDateTimeInputs(date_id, time_id, format) { } function autosaveInput(e) { + var self = this; if (this.saveTimer) { clearTimeout(this.saveTimer); } @@ -261,7 +262,7 @@ function autosaveInput(e) { this.saveTimer = setTimeout(function () { $changedInput = $(e.target); saveSubsection(); - this.saveTimer = null; + self.saveTimer = null; }, 500); } From 97cb4910a7b8d36123941538776a1d53ec4be034 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Tue, 26 Mar 2013 11:04:14 -0400 Subject: [PATCH 110/264] 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 87f545329a6f9f75fed6cdc16502a23e124a9ebe Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Tue, 26 Mar 2013 11:05:33 -0400 Subject: [PATCH 111/264] studio - adding in tender-widget sass file --- cms/static/sass/elements/_tender-widget.scss | 142 +++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 cms/static/sass/elements/_tender-widget.scss diff --git a/cms/static/sass/elements/_tender-widget.scss b/cms/static/sass/elements/_tender-widget.scss new file mode 100644 index 0000000000..fce62b8675 --- /dev/null +++ b/cms/static/sass/elements/_tender-widget.scss @@ -0,0 +1,142 @@ +// tender help/support widget +// ==================== + +#tender_frame, #tender_window { + background-image: none !important; + background: none; +} + +#tender_window { + @include border-radius(3px); + @include box-shadow(0 2px 3px $shadow); + background: $white !important; + border: 1px solid $gray; +} + +#tender_window { + padding: 0 !important; +} + +#tender_frame { + background: $white; +} + +#tender_closer { + color: $blue-l2 !important; + margin-top: 15px; + margin-right: 5px; + text-transform: uppercase; + + &:hover { + color: $blue-l4 !important; + } +} + +// ==================== + +// tender style overrides - not rendered through here, but an archive is needed +#tender_frame iframe html { + font-size: 62.5%; +} + +.widget-layout { + font-family: 'Open Sans', sans-serif; +} + +.widget-layout .search, +.widget-layout .tabs, +.widget-layout .footer, +.widget-layout .header h1 a { + display: none; +} + +.widget-layout .header { + background: rgb(85, 151, 221); + padding: 20px; +} + +.widget-layout h1, .widget-layout h2, .widget-layout h3, .widget-layout h4, .widget-layout h5, .widget-layout h6, .widget-layout label { + font-weight: 600; +} + +.widget-layout .header h1 { + font-weight: 500; + font-size: 24px; +} + +.widget-layout .content { + overflow: auto; + padding: 20px; +} + +.widget-layout label { + font-size: 14px; + margin-bottom: 5px; + color: #4c4c4c; + font-weight: 500; +} + +.widget-layout input[type="text"], .widget-layout textarea { + padding: 10px; + font-size: 16px; + color: rgb(0,0,0) !important; + border: 1px solid #b0b6c2; + border-radius: 2px; + background-color: #edf1f5; + background-image: -webkit-gradient(linear, left top, left bottom, color-stop(0%, #edf1f5),color-stop(100%, #fdfdfe)); + background-image: -webkit-linear-gradient(top, #edf1f5,#fdfdfe); + background-image: -moz-linear-gradient(top, #edf1f5,#fdfdfe); + background-image: -ms-linear-gradient(top, #edf1f5,#fdfdfe); + background-image: -o-linear-gradient(top, #edf1f5,#fdfdfe); + background-image: linear-gradient(top, #edf1f5,#fdfdfe); + background-color: #edf1f5; + -webkit-box-shadow: 0 1px 2px rgba(0,0,0,0.1) inset; + -moz-box-shadow: 0 1px 2px rgba(0,0,0,0.1) inset; + box-shadow: 0 1px 2px rgba(0,0,0,0.1) inset; +} + +.widget-layout input[type="text"]:focus, .widget-layout textarea:focus { + background-color: #fffcf1; + background-image: -webkit-gradient(linear, left top, left bottom, color-stop(0%, #fffcf1),color-stop(100%, #fffefd)); + background-image: -webkit-linear-gradient(top, #fffcf1,#fffefd); + background-image: -moz-linear-gradient(top, #fffcf1,#fffefd); + background-image: -ms-linear-gradient(top, #fffcf1,#fffefd); + background-image: -o-linear-gradient(top, #fffcf1,#fffefd); + background-image: linear-gradient(top, #fffcf1,#fffefd); + outline: 0; +} + +.widget-layout textarea { + width: 97%; +} + +.widget-layout .form-actions { + border-top: 1px solid #ccc; + margin-top: 10px; + padding-top: 10px; +} + +.widget-layout dl.form { + float: none; + width: 100%; + border-bottom: 1px solid #f2f2f2; + margin-bottom: 10px; + padding-bottom: 10px; +} + +.widget-layout #brain_buster_captcha { + +} + +// specific elements +.widget-layout #discussion_body { + margin: 0 0 15px 0; +} + +.widget-layout .category dt, .widget-layout .category dd { + display: inline-block !important; +} + +.widget-layout .category dt { + margin-right: 15px !important; +} \ No newline at end of file From 24301d2a0761510143f7bc62bc9d7d0d01abd5ca Mon Sep 17 00:00:00 2001 From: Will Daly Date: Fri, 22 Mar 2013 11:30:31 -0400 Subject: [PATCH 112/264] 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 113/264] 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 114/264] 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 115/264] 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 116/264] 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 117/264] 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 118/264] 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 119/264] 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 120/264] 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 121/264] 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 122/264] 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 123/264] 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 124/264] 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 125/264] 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 126/264] 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 586f566b4276b74756a0ce3bfe258ba979a45401 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Andr=C3=A9s=20Rocha?= Date: Tue, 26 Mar 2013 11:54:06 -0400 Subject: [PATCH 127/264] Use advertised_start as a simple string LMS Lighthouse [#297] --- common/lib/xmodule/xmodule/course_module.py | 12 ++++++++---- common/lib/xmodule/xmodule/fields.py | 2 ++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index b1e5fa02c8..7999f8d6da 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -7,6 +7,8 @@ import requests import time from datetime import datetime +import dateutil.parser + from xmodule.modulestore import Location from xmodule.seq_module import SequenceDescriptor, SequenceModule from xmodule.timeparse import parse_time @@ -150,7 +152,7 @@ class CourseFields(object): enrollment_end = Date(help="Date that enrollment for this class is closed", scope=Scope.settings) start = Date(help="Start time when this module is visible", scope=Scope.settings) end = Date(help="Date that this class ends", scope=Scope.settings) - advertised_start = StringOrDate(help="Date that this course is advertised to start", scope=Scope.settings) + advertised_start = String(help="Date that this course is advertised to start", scope=Scope.settings) grading_policy = Object(help="Grading policy definition for this class", scope=Scope.content) show_calculator = Boolean(help="Whether to show the calculator in this course", default=False, scope=Scope.settings) display_name = String(help="Display name for this module", scope=Scope.settings) @@ -537,10 +539,12 @@ class CourseDescriptor(CourseFields, SequenceDescriptor): announcement = self.announcement if announcement is not None: announcement = to_datetime(announcement) - if self.advertised_start is None or isinstance(self.advertised_start, basestring): + + try: + start = dateutil.parser.parse(self.advertised_start) + except (ValueError, AttributeError): start = to_datetime(self.start) - else: - start = to_datetime(self.advertised_start) + now = to_datetime(time.gmtime()) return announcement, start, now diff --git a/common/lib/xmodule/xmodule/fields.py b/common/lib/xmodule/xmodule/fields.py index 99ead854ad..0abe850d68 100644 --- a/common/lib/xmodule/xmodule/fields.py +++ b/common/lib/xmodule/xmodule/fields.py @@ -23,6 +23,8 @@ class Date(ModelType): """ if field is None: return field + elif field is "": + return None elif isinstance(field, basestring): d = dateutil.parser.parse(field) return d.utctimetuple() From 7c68508b85c0a31b0c4745172558d3075cde0a23 Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Tue, 26 Mar 2013 12:42:30 -0400 Subject: [PATCH 128/264] studio - finalized tender widget styling --- cms/static/sass/elements/_tender-widget.scss | 133 +++++++++++++++++-- cms/templates/widgets/tender.html | 1 + 2 files changed, 123 insertions(+), 11 deletions(-) diff --git a/cms/static/sass/elements/_tender-widget.scss b/cms/static/sass/elements/_tender-widget.scss index fce62b8675..4d2cdea373 100644 --- a/cms/static/sass/elements/_tender-widget.scss +++ b/cms/static/sass/elements/_tender-widget.scss @@ -23,7 +23,7 @@ #tender_closer { color: $blue-l2 !important; - margin-top: 15px; + margin-top: 10px; margin-right: 5px; text-transform: uppercase; @@ -66,6 +66,7 @@ .widget-layout .content { overflow: auto; + height: auto !important; padding: 20px; } @@ -110,10 +111,20 @@ width: 97%; } +.widget-layout p.note { + text-align: right !important; + display: inline-block !important; + position: absolute !important; + right: -130px !important; + top: -5px !important; + font-size: 13px !important; + opacity: 0.80; +} + .widget-layout .form-actions { - border-top: 1px solid #ccc; - margin-top: 10px; - padding-top: 10px; + margin: 15px 0; + border: none; + padding: 0; } .widget-layout dl.form { @@ -124,19 +135,119 @@ padding-bottom: 10px; } -.widget-layout #brain_buster_captcha { +.widget-layout dl.form:last-child { + border: none; + padding-bottom: 0; + margin-bottom: 20px; +} +.widget-layout dl.form dt, .widget-layout dl.form dd { + display: inline-block; + vertical-align: middle; +} + +.widget-layout dl.form dt { + margin-right: 15px; + width: 70px; +} + +.widget-layout dl.form dd { + width: 65%; + position: relative; } // specific elements .widget-layout #discussion_body { + +} + +.widget-layout #discussion_body:before { + content: "What Question or Feedback Would You Like to Share?"; + display: block; + font-size: 14px; + margin-bottom: 5px; + color: #4c4c4c; + font-weight: 500; +} + + +.widget-layout dl#brain_buster_captcha { + float: none; + width: 100%; + border-top: 1px solid #f2f2f2; + margin-top: 10px; + padding-top: 10px; +} + +.widget-layout dl#brain_buster_captcha dd { + display: block !important; +} + +.widget-layout dl#brain_buster_captcha dd label { + display: block; + margin: 0 15px 0 0 !important; +} + +.widget-layout dl#brain_buster_captcha dd #captcha_answer { + display: block; + width: 97%%; +} + +.widget-layout .form-actions .btn-post_topic { + display: block; + width: 100%; + height: auto !important; + font-size: 16px; + font-weight: 700; + -webkit-box-shadow: 0 1px 0 rgba(255,255,255,0.3) inset,0 0 0 rgba(0,0,0,0); + -moz-box-shadow: 0 1px 0 rgba(255,255,255,0.3) inset,0 0 0 rgba(0,0,0,0); + box-shadow: 0 1px 0 rgba(255,255,255,0.3) inset,0 0 0 rgba(0,0,0,0); + -webkit-transition-property: background-color,0.15s; + -moz-transition-property: background-color,0.15s; + -ms-transition-property: background-color,0.15s; + -o-transition-property: background-color,0.15s; + transition-property: background-color,0.15s; + -webkit-transition-duration: box-shadow,0.15s; + -moz-transition-duration: box-shadow,0.15s; + -ms-transition-duration: box-shadow,0.15s; + -o-transition-duration: box-shadow,0.15s; + transition-duration: box-shadow,0.15s; + -webkit-transition-timing-function: ease-out; + -moz-transition-timing-function: ease-out; + -ms-transition-timing-function: ease-out; + -o-transition-timing-function: ease-out; + transition-timing-function: ease-out; + -webkit-transition-delay: 0; + -moz-transition-delay: 0; + -ms-transition-delay: 0; + -o-transition-delay: 0; + transition-delay: 0; + border: 1px solid #34854c; + border-radius: 3px; + background-color: rgba(255,255,255,0.3); + background-image: -webkit-gradient(linear, left top, left bottom, color-stop(0%, rgba(255,255,255,0.3)),color-stop(100%, rgba(255,255,255,0))); + background-image: -webkit-linear-gradient(top, rgba(255,255,255,0.3),rgba(255,255,255,0)); + background-image: -moz-linear-gradient(top, rgba(255,255,255,0.3),rgba(255,255,255,0)); + background-image: -ms-linear-gradient(top, rgba(255,255,255,0.3),rgba(255,255,255,0)); + background-image: -o-linear-gradient(top, rgba(255,255,255,0.3),rgba(255,255,255,0)); + background-image: linear-gradient(top, rgba(255,255,255,0.3),rgba(255,255,255,0)); + background-color: #25b85a; + -webkit-box-shadow: 0 1px 0 rgba(255,255,255,0.3) inset; + -moz-box-shadow: 0 1px 0 rgba(255,255,255,0.3) inset; + box-shadow: 0 1px 0 rgba(255,255,255,0.3) inset; + color: #fff; + text-align: center; + margin-top: 20px; + padding: 10px 20px; +} + +.widget-layout .form-actions #private-discussion-opt { + float: none; + text-align: left; margin: 0 0 15px 0; } -.widget-layout .category dt, .widget-layout .category dd { - display: inline-block !important; -} - -.widget-layout .category dt { - margin-right: 15px !important; +.widget-layout .form-actions .btn-post_topic:hover, .widget-layout .form-actions .btn-post_topic:active { + background-color: #16ca57; + color: #fff; } \ No newline at end of file diff --git a/cms/templates/widgets/tender.html b/cms/templates/widgets/tender.html index 300b71701c..27cc574490 100644 --- a/cms/templates/widgets/tender.html +++ b/cms/templates/widgets/tender.html @@ -1,5 +1,6 @@ % if user.is_authenticated(): Provide Feedback + + + + + + <%block name="header_extras"> From 03f9bb5d38a3855ee56087b9132f8ebbe13be747 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Wed, 27 Mar 2013 22:37:07 -0400 Subject: [PATCH 176/264] 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 177/264] 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 178/264] 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 179/264] 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 180/264] 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 181/264] 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 182/264] 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 183/264] 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 184/264] 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 185/264] 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 186/264] 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 187/264] 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 188/264] 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 2d677a834c737a527f0ba6debcc911986db4dd59 Mon Sep 17 00:00:00 2001 From: cahrens Date: Thu, 28 Mar 2013 10:24:31 -0400 Subject: [PATCH 189/264] Test for due dates. --- .../contentstore/features/common.py | 14 ++++++ .../contentstore/features/section.py | 14 +----- .../contentstore/features/subsection.feature | 48 +++++++++++-------- .../contentstore/features/subsection.py | 30 ++++++++++-- cms/templates/overview.html | 2 +- 5 files changed, 71 insertions(+), 37 deletions(-) diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py index 820b60123b..9de3898c54 100644 --- a/cms/djangoapps/contentstore/features/common.py +++ b/cms/djangoapps/contentstore/features/common.py @@ -11,6 +11,9 @@ from xmodule.modulestore.django import _MODULESTORES, modulestore from xmodule.templates import update_templates from auth.authz import get_user_by_email +from selenium.webdriver.common.keys import Keys +import time + from logging import getLogger logger = getLogger(__name__) @@ -207,3 +210,14 @@ def add_subsection(name='Subsection One'): save_css = 'input.new-subsection-name-save' css_fill(name_css, name) css_click(save_css) + + +def set_date_and_time(date_css, desired_date, time_css, desired_time): + css_fill(date_css, desired_date) + # hit TAB to get to the time field + e = css_find(date_css).first + e._element.send_keys(Keys.TAB) + css_fill(time_css, desired_time) + e = css_find(time_css).first + e._element.send_keys(Keys.TAB) + time.sleep(float(1)) \ No newline at end of file diff --git a/cms/djangoapps/contentstore/features/section.py b/cms/djangoapps/contentstore/features/section.py index b5ddb48a09..1a5f9e860f 100644 --- a/cms/djangoapps/contentstore/features/section.py +++ b/cms/djangoapps/contentstore/features/section.py @@ -1,8 +1,6 @@ from lettuce import world, step from common import * from nose.tools import assert_equal -from selenium.webdriver.common.keys import Keys -import time ############### ACTIONS #################### @@ -36,16 +34,8 @@ def i_click_the_edit_link_for_the_release_date(step): @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') - # hit TAB to get to the time field - e = css_find(date_css).first - e._element.send_keys(Keys.TAB) - css_fill(time_css, '12:00am') - e = css_find(time_css).first - e._element.send_keys(Keys.TAB) - time.sleep(float(1)) + set_date_and_time('input.start-date.date.hasDatepicker', '12/25/2013', + 'input.start-time.time.ui-timepicker-input', '12:00am') world.browser.click_link_by_text('Save') diff --git a/cms/djangoapps/contentstore/features/subsection.feature b/cms/djangoapps/contentstore/features/subsection.feature index 1be5f4aeb9..2e1c4ad3d5 100644 --- a/cms/djangoapps/contentstore/features/subsection.feature +++ b/cms/djangoapps/contentstore/features/subsection.feature @@ -3,25 +3,33 @@ Feature: Create Subsection As a course author I want to create and edit subsections - Scenario: Add a new subsection to a section - Given I have opened a new course section in Studio - When I click the New Subsection link - And I enter the subsection name and click save - Then I see my subsection on the Courseware page +# Scenario: Add a new subsection to a section +# Given I have opened a new course section in Studio +# When I click the New Subsection link +# And I enter the subsection name and click save +# Then I see my subsection on the Courseware page +# +# Scenario: Add a new subsection (with a name containing a quote) to a section (bug #216) +# Given I have opened a new course section in Studio +# When I click the New Subsection link +# And I enter a subsection name with a quote and click save +# Then I see my subsection name with a quote on the Courseware page +# And I click to edit the subsection name +# Then I see the complete subsection name with a quote in the editor - Scenario: Add a new subsection (with a name containing a quote) to a section (bug #216) - Given I have opened a new course section in Studio - When I click the New Subsection link - And I enter a subsection name with a quote and click save - Then I see my subsection name with a quote on the Courseware page - And I click to edit the subsection name - Then I see the complete subsection name with a quote in the editor +# @skip-phantom +# Scenario: Delete a subsection +# Given I have opened a new course section in Studio +# And I have added a new subsection +# And I see my subsection on the Courseware page +# When I press the "subsection" delete icon +# And I confirm the alert +# Then the subsection does not exist + + Scenario: Set a due date in a different year (bug #256) + Given I have opened a new subsection in Studio + And I have set a release date and due date in different years + Then I see the correct dates + And I reload the page + Then I see the correct dates - @skip-phantom - Scenario: Delete a subsection - Given I have opened a new course section in Studio - And I have added a new subsection - And I see my subsection on the Courseware page - When I press the "subsection" delete icon - And I confirm the alert - Then the subsection does not exist diff --git a/cms/djangoapps/contentstore/features/subsection.py b/cms/djangoapps/contentstore/features/subsection.py index 88e1424898..a52c91a251 100644 --- a/cms/djangoapps/contentstore/features/subsection.py +++ b/cms/djangoapps/contentstore/features/subsection.py @@ -1,6 +1,6 @@ from lettuce import world, step from common import * -from nose.tools import assert_equal +from nose.tools import assert_equal, assert_true ############### ACTIONS #################### @@ -13,6 +13,18 @@ def i_have_opened_a_new_course_section(step): add_section() +@step('I have added a new subsection$') +def i_have_added_a_new_subsection(step): + add_subsection() + + +@step('I have opened a new subsection in Studio$') +def i_have_opened_a_new_subsection(step): + step.given('I have opened a new course section in Studio') + step.given('I have added a new subsection') + css_click('span.subsection-name-value') + + @step('I click the New Subsection link') def i_click_the_new_subsection_link(step): css = 'a.new-subsection-item' @@ -41,9 +53,19 @@ def i_see_complete_subsection_name_with_quote_in_editor(step): assert_equal(world.browser.find_by_css(css).value, 'Subsection With "Quote"') -@step('I have added a new subsection$') -def i_have_added_a_new_subsection(step): - add_subsection() +@step('I have set a release date and due date in different years$') +def test_have_set_dates_in_different_years(step): + set_date_and_time('input#start_date', '12/25/2013', 'input#start_time', '3:00am') + css_click('.set-date') + set_date_and_time('input#due_date', '1/2/2014', 'input#due_time', '4:00am') + + +@step('I see the correct dates$') +def i_see_the_correct_dates(step): + assert_equal('12/25/2013', css_find('input#start_date').first.value) + assert_equal('3:00am', css_find('input#start_time').first.value) + assert_equal('1/2/2014', css_find('input#due_date').first.value) + assert_equal('4:00am', css_find('input#due_time').first.value) ############ 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 7279f9c4601f7ee769ce689b1bcab4c63b94e377 Mon Sep 17 00:00:00 2001 From: cahrens Date: Thu, 28 Mar 2013 10:54:10 -0400 Subject: [PATCH 190/264] 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 191/264] 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 192/264] 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 193/264] 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 bc248a1d4a0ff63d504c09df4adb5c6de2aeee90 Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Thu, 28 Mar 2013 12:47:08 -0400 Subject: [PATCH 194/264] studio - tender widget: revised styling with spam protection back in place --- cms/static/sass/elements/_tender-widget.scss | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/cms/static/sass/elements/_tender-widget.scss b/cms/static/sass/elements/_tender-widget.scss index 478489d0e8..b4113732f0 100644 --- a/cms/static/sass/elements/_tender-widget.scss +++ b/cms/static/sass/elements/_tender-widget.scss @@ -9,7 +9,7 @@ #tender_window { @include border-radius(3px); @include box-shadow(0 2px 3px $shadow); - height: 650px !important; + height: ($baseline*35) !important; background: $white !important; border: 1px solid $gray; } @@ -24,8 +24,6 @@ #tender_closer { color: $blue-l2 !important; - margin-top: 10px; - margin-right: 5px; text-transform: uppercase; &:hover { @@ -53,7 +51,7 @@ .widget-layout .header { background: rgb(85, 151, 221); - padding: 20px; + padding: 10px 20px; } .widget-layout h1, .widget-layout h2, .widget-layout h3, .widget-layout h4, .widget-layout h5, .widget-layout h6, .widget-layout label { @@ -61,8 +59,7 @@ } .widget-layout .header h1 { - font-weight: 500; - font-size: 24px; + font-size: 22px; } .widget-layout .content { @@ -195,9 +192,14 @@ display: block !important; } +.widget-layout dl#brain_buster_captcha #captcha_answer { + border-color: #333; +} + .widget-layout dl#brain_buster_captcha dd label { display: block; - margin: 0 15px 0 0 !important; + font-weight: 700; + margin: 0 15px 5px 0 !important; } .widget-layout dl#brain_buster_captcha dd #captcha_answer { From f652fb5f730344ee752fc503dbb5fd52bc59905e Mon Sep 17 00:00:00 2001 From: Will Daly Date: Thu, 28 Mar 2013 12:51:09 -0400 Subject: [PATCH 195/264] 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 196/264] 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 197/264] 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 198/264] 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 zcmeFZcU)85)-IZa0HFvZbTk-B=tV#TLJxt^4Uo_Tm9D5DNVA|2dhcC?P(whZNfiZY zp@@PY3aEg96agF9^{n{r^1ge2_q+Gp-}&R7v+v?Z*2*e#&Nb&4&v?cdbLQ96Uke}} za}zTY5Eudic>;f+UvEHgT(HNPiy$xv3Ic&dfm7QcE<;a$cQ24<4{!$jYYr3*;^E-n z;N;-pL1m3$e{?_&ES?QVh>#^z60qXgP;qet%r>43GuC;8664D@z{(Q- zt-A~;<|r~>nHX?mgp9Ea>~|gUux&ym52&=p+7yN}HZWG80JWihR|v%!z~L}2oN-pe zKfnLcmfx5DBM$jwaZvF;^(WA4(PTK#k-)InQg{#)IN|pIL8-uS$?;LJP=iV^(bfhG ztHl1Rw2GmAORzzFJfrW$qYccXabRo{IU*WIjD+(*aRexi2y`u@%c=jlUH*r_V{>3^ zghT=}JEJQB;U}Wl`N}|m2#fJiu~1_fKn=uz6Y&(V9NZkjE=#BZ&O|Uo92gzo9zb^g zB%m-j9LiG(heB~!o=Pn0_u$xoynYuBfxykl5#q8~VL06A|L=D|*s~dU^M{TTDvdVi z5QY-u*z}j8fv*0S?ga$b06-9+bHT7g5>&=E-V}gpV25G=*gvHh7y-sW8W_W(f$KT! zHXvYhdFU}l*(^jHVeK!Y^{2=F4?X0G2jCkA=9B%anEvB!v9(|3I-CgU(nIOr2*P|4 z09H_!h|~k-ycvMdvj0s*vJC)u0%QLUPr%Sbqi{GV6##dfEmh219Ks%h#`65T-4p|^ zM+5rAcmga82WPb-1&CnRj?=R(vctak9)gB)2}X8g?uHHO*##Wt9<1w_NuLO&kQ$`yZt=?b$)9~ zdA|eU)@yN`m&QcI%r6k!RVWyPhJ2$e!Pu2AbVbvaPJeT)=Xxr7pEo6)HzAC!DYuDd z)!r|q^6_T!M^S>i^$ljfTmGjPUq3bfSEj|#VqK8|W$fz2UQA0_z;iZy3w#HkVINkaOTVW~%L>mb;8g;|p>YvmujzK`s?a^&{@S_C ziL?xn@llzEKEikIj?+FX$1(PL22F%k&z+AS1GUL-kJ&X3Z9IObyE{p{bI+pfN7YOE zv`a4{J3rR%%&qt8=KO-W8T)Ppe&{B=DLI-YEIcTgC*dGca_M6>3%%$Rr~B2IzL2}l zV_yqDBU`st1LAU9e}USkt6Ac`_E}`ERLtA&&`IK!Y`VWdJkB?=ui4x+(0L%qvTAYj z?4@eg@FT3@_$%Gc^ZfL+xz+S+r9eO^U704e|}7xY>~z+e!GetesiRUeekZkB**D$uIZ)Y zZC_@dUjAA5(;~b|v{67;<)KX59c3$m>YcrFX;Xqln@qnzyQvp(;VQ>V4qUx*?_0pv zJmf>GYr1zIUv_kU`JH$O%sq7$F$tlxe>is{E^t~Z(;f~KWLwCa0h>TvW+Vf-#-6Wu>eR4!x#E3T1;Yap6IfezZp(okH=DAfK2@+ zo&OL#>_5b7jWa%;^5`!z2KrBMPX908m?EH)V1C$1Mj+cwf_I3(BPxGH+Q;^4=y3Pu zaYFVr|Lb;woljvV=oYDftQ!ybm3IC6@>9Pl=iP7#o_?8l^K7@UPM|G02k)@<*%biezQ|*~?W3E<%Cod_N zLJLAvcka{23zsKTuS*ICcD=6)JonvclBTfe<2;P zYtr+eVt2wTr=$cfWPaT3ZCdx)@%(%z@Da82EK{WLg*+kT0qL}Z>qSU(?f6|)1y7b2 zyK^oIrr;^#{{H?*!SEp0M=qKtjArlc4lOq8UF$sY=IA>TB0lNS#ruD;r0j4WW;qDM z6hs@#U;z&O`vja|E)Ec|KWN)ux|Syz#}GE;VJrol!zUhxwZWp$a4f{;AF}voJp7YN z{|6jmK^@OSMmN`6#|vOTg%kEY6uxLG5{s!%aM7RFwLoZwdT3qmjZaT46%0Xp1P+r% zqb>)BbjIk6D^P1FAU0M5b*0|pg%XJSOx7z8Zb^tcv4j{1n@AdHfe<>K&^Q}9)w-i! zSleH6eU=ry7&~YBRV35wjwUj|`Z*qbSL|+lf9_+fmXfHxtt!-9&#mXY-pbt2jW@ol zMmny!XFGB_VmtCpCB#9ZZvH};*W*Sr385d(zb@;W!aQ*NX}H>!R;9(gDt~x|dEQAY znTt}dV`<2gxOQ^&)r!59h)Hupe}raeh%$Fl(bT0}vwB`gW8F&|p*;=txxR${man`| zd;DUHDp=~e>;ld?A24oC9v7V3cw#r$VU4dg9$)Wv)@sT|pC~_crSW-3&nvo8;@*?p zrfCP6ceAavkG(204-nR980DYT z?r%I92E>-485EGh@EZiI0S6EeR5)DnkKhF#6$dT*&j{50MPV7}UxR{JM+C?@2&&zj zBegzWH!QO}j>{7TDa>xrjztkG>y~KNJ`O(?bT7IGec)wL$u(T^`d)Hrd1OApXySd# zoyn4brInF-KLN?6eUGR{0TIB74Q3ZHF!*57rR55TH^R&=Fnv|8#w zb6i-gWVzy?6R+NN*bH6$QQ(eCJ;loV>f@WoLhRuERoAQrVilc)9;tSE#a0xv7LjL% zMw;s6HddpxDYyRA;biXd|hsaBCa;yqUE{K@9>e}=%A`*Un zIp9DrIFNhmXZeBrk(X{P5MmcgL^u-yc+sRTM2alph^#qHU(o z_V!%oDC~N2DeIA??z@w_Bw`%xp?b!`C-vKNcPeOQqTcUw*d1ElLyci<#<1T!@^3*= zXjzOL84z%=d5jDs8V6<<2l15g!?5D=lCb!Si1-8)LnMGtF#|Xj1F_MPF<=KYEujpu z7EfvTZ|DJMS3Mwd(b=_YTPTL@^S4S>px6vb^E`+{=IlENo&g~T8VaRG`?7{$G_$t z&v&Dzx3{XL6gTLN3lDBRYBj79diT=1@Vo8y@ymTTZPu%4gDIMxPd!8`LOKqq&K}5n z{j{wz&DCAd33VOK#eSTrl z{lPb-p%izOthbLK_8r4@KiLGg2^ZW!bvACfg&9I=6|`f1rWp}cDl^H|zd$fK8IyKz zzmvy01LuxG)GyrPZ})*kt)S9;7a!*p7C4pX8Fbcy?SFcOg`-{$X`v zV6e5Oe&lWMDwPw6)6taU_bu~_9gM13)b^A}xeh|x@zbAu1zsP`_R@mQ8ltXBzkcd) z&2O^XK_d0XvAO2gPlKoGR0OyO+QY&}Wftxx`aDDf9zGC7WssEL?BfqEVIBj6*(SDhUszC(7t9oPod`%h63dfK*dRa&YcW7c^!P-P z-?Y2*-{A5OBlLe`E2w~8Ky0wt|7Dowe=C89gE{h^bjF&%sFNt;ivAsFa~Mx&%*C|l z_D{~0K^}MrXTkE^U&V8EY(JwNY4EWstaX)6>>u!P-mpEdD#3KC&jtT&E>CA(DZ75O zGsH4ZRn1T2e3GK?Vh24z;+~bjwE7_kQFy&BO7*?-gSulrtu1C>Y_agc&MY-B%JiwH z+1HU#ucse-%MG%>i3#|hVz*Ry)^ymU{T|{fJtlofreEa2i5JWl786<;`#9MPgl6nc zOJP#9F7x}}u&Qcw(TU!-oc?oY!YX4JW&TY(#qBMNLNYBfo|hgIy>Ynuo%y7?qDaV zCyIa8ByU2xXRUTW$t_zqo~kDaZy)4|M}<*#lOtm-BI$$D+jjmBicxau5_ZxFUwPwx zaAY}MnVGeW7L$rj@l55(NSktSs75~BK6^w^$ukr_-=2G=XMYMdP~`B@@0Ojq2gO@+ zSUO0a3AvM+5f7;op?g3u&QqsQ^IPGydV59p;B0Z_iwnQCfng@1jb$apkZ?0F7SI78 zRu=yzLR`BH0TCB7DvKjTG|2?RIK*R4MySTxR&qeO*lqa0h6ExXknF2~QKm|vrPGG6 z!|V_nBZ`4}oH2}(X2UP@hmrWZ3Hifl@PXm{|C8#2NQ65<%K#0R0oWX1?EW9A>TgpS z|Nnt|DPS2OHJ1nn$&Ur=m`#*;mU(=so|->_Up9Dqy!ia3$H_e}d-R&xFUK6dVY#za zw1Gc-Ay)Uj$Smug$EO^(=r9gX?U495hrm8kn6O^uOv;Vr(^0t9GYR*ugpJ^E0e%+V zj_q^%$oFgwP79kme;au+wOkBSz$JaUd4GcDE#XKu(D{g8KUyg6Z|71_o8Tod8ZcB( zK^T0uesv@1jM!%N)gwK4zpSqqN$|Dr;#>w*p`AMICE?6n}>(Ri6;<;mX;*w^5me`FU!c}qyugN88C7h>`Nd%b$=vosC`ch3XuJ8$fVpR-rCY|>+rxNMt6YQ~Rh8Lup@r{02K zCk}PvJmxmP7HvjN>z4KhaBjpBkIFgTZx)96zhDDLdYv^B9(h$dcj)lzvN|8$DIdp( z7xI$N=3f~nPu}o;A<*K+QBX7)$C|D?v~$c=TU4dV-cL3C5oG=elCt|qaSX9KxI9}w zr|xRL(4gt>^l>Yxe;#MNtz-Cy@4$ybBe5tka~UkN91;om5;kB%qX8@gi;-ePXky6l z!?wauFika^V90K0LKveeB-U5(vpZpBf}m6}qZ&hI$Fc$eQji!%DiH}}19J`#yHKc7 zJRrQ$K`b~DvqeQNn=w~vCMuh-_O&7&%}Bf;5eB%RAh3BHVU0t)HrtRn!>GJ80!Yjl zb5R}{dJ@(E*0r_4Xu?cWMGkO21;FOd{uBMSNIo5 zASPmEzWKP}&8Uurn|V31@A$*+a0&%VHJC1a?|R~-_KEf?F3#s0sX}+7S$&l>AG^gt z+ssm8x;^xyv(J{I++!K(B%P<)_y|`jsWX9UJRm$>pSmHXZLgzgE;Mbw)2eX!u$;c_ z_lo0jUlOSHXvI=rL&e#V*nGV7lZey#`B~2gOb$p3pJ+d%1`SQAJ-EtMu6p9}$B$R{ z7yFQRG(>&UxD(oc)cS*df%2~$cE-1@9V^z4xgi$%>_`9k7biB`B0Ez{iwQRdwsdfv znA-AEg$;h!1EmUU8}$G|+D;`QkzgO0jr!DxW@<23>hBAw{LmcO1`_OvCXHm!BGMDp zO=Q>%h+A^|efh=NIqRp&+On zB^vPABv%Flw6gLMA`yusn9BqK5qYuy6}CVQ@P;z~aKr!u^RI4_?B7itptg+c6re#e zKtnVCU)7xWM@Jym7*UV&K$04KVZJT~z$J_rQ#q9Z6A{Et0SKmk1(D@TF!Zsb^~!g| zl6IwMOQSqGkhwy_7DaW9NUEda03UTV6U#Pn{?f}Ijz4?8j_mLVa@>Td&R+w4oly?> zLzPM@3kj(anYTXQp+%0guuu};-AM}^8`>|X6~PL^r9l?HJ?X!0u(eXF==@5V=qNO3 zu(g+NKhUI7N9Q__ZA0M2fOuUwc+xR~}Z51-tV)r-AHA4)lUHmA$vc_v&> zVFMi;IFf@aUXrq$Zx67i%LyP+CwtJpVqvS=$IXBBpD3$u^OR9w65&GO$wykXOf`K z(*PaGm=5LV0yJqZyR*D%nFWt`{KS8fAP_`Uoz50c=MhkgUkbL_Ks030G9+1guBRqR z;#C_RW}BK~3%n3q6mwx24_bZ-sX&-RvMdgdHI#{8gOF{ey$LmZ*+t~~rA+qt(^>5w zNUBPdDNGfIpPUB21K!|7fYkt?QUWiE63eMU%%8I7sT=AB{umi7@vu3Ds#MG{Bjpqe z30h~IfzwoHsjWFr>xvR}gn-y^wVCRKjHl8zG*C*3fZjLBbz&gn)q&;ZrAliXsg;im z&yBcx1S^p_I^#&eTx5=R!f$ver!npWo@10j;)%|z;3pCVf=D>#VkC)2AP7K@h&*U2srq=T@aJ`mxS4W znaq$V{hzW^zd6{S4d7qRA%GLWMaKWcql({clIz5E!W1Y|G#QSvXt@aBT_{tTy`gww zFlsHDJf2$HVJH``w82IU3Sw@PS}~Hc=>TU1ceg6`xL30}JMy6SK$9R==n z)`?LDsg#rZ%*`Rr{3uxiH+67dBJ5po_FE$BZAof=>q@qQ5hzGGaBg&bgX9VlO zAC!)na>+M@DM>r|7ae@ck@^c%K7jnucI#uarM`FK+fX9&D7X6^Gfsz$<;ynYM$NhA z0C7}X_gN?8qinC9Jw@MTtJ(BbH2s~^7S!I<^015%($6W04=x+9q^`%IOW9N&b{~r1 z3WiG?7fwpBF5iM07xh<4jDXlZ4;J3E)izwHvYvfMDli79mxImyRpSIuM!!;3#0F2^ zg6%KR`|%eF4U25a=AZ8Vxa%JFZnb&VLkJNT=#?gFQyg$eSr}rt9h|rML-Kgqlw05R z740@e$W!Qv<};Gx6<9%}d3S+OXp(CC-|DUlq>M8%b!(R9eL?w^x#~3$zV3gJXfh zau^KcVObmrO*A8T;)(N>j{yAz#HbzrnT>zlE*32q5W$cP5E%?bwlPK z25-uzobMaYE0lKesaFbX@DAJCZjo{tW8>1$rXzM)+04uAH6oZ1sz zp^X0jmIVM%j;W{ZMp0HEWVvK@yHNp zPEZif?+a{yIyE2jTgyq4IG`Gv#}`llhShX{4Vj-KNmG7|on0Us1_nT8eJTSG06c~A zxbkaMeav8Vc7Yj^ z8O+&;(i}E%j!rp9tYs#utGw=V`WWNQRZni*t>t%8S#8Xui$v*Dk94-acJ4~dA0xk> zuVeBF%yVQ;y>+h}+pCIp*5TK{G~^T1BSQ%r8Z=B3E@|6Hnmv`~IU>MOHYlYj-zfmL zRp$`S;+rSL8uc2{{NzGeh{?@VRrV^koX`|*N#=ryt|wYfbeWWx-Gw?Hx&c#SdSBr{ z`?u-1OUaW;`;%lv*(2h-GK@@QA?$UzW@-wy@}>{sgSkq}CdhrZqv~Gn@drqeVqQfw zHDOiArjSw{SFKcXA32Z6ibFpdgOHryM#kjUmE;_*Ow%l}bgNN;Wi{eyRo=2!!56Pl zy{GzpJG*8M($WLtu6XR#AN!fc+tzmc8q4W^-=>ZU@{rJGeR-oNl($-??7iC4=0l{P z0SbM$+w1l$&a&ySy7!tDv?Qd(D!!tPUa_R5FPM8&^I58^<~PlA1b)jsbD%f3Rrut^ zycxhFFojDTb-x4Wvk<>2u{hw;_~`8xv3k)ykDI>=M~qJZaiw;~C9de_hs8`UQ51V_ zX1pYp&y_khqRy1@7h8h4=W3h+CEYA5MNtZlM@Gh+-xT?!OSW;zD^GRBSK~KS+cjR|1@DY$BWm_-!sgS9Yz!4J74so%Ah8CL zImdlnc@S%wR6GnI4xQ-)9upeL%!-cfp)63>WyLI_hRv9txhFq!nf0ttGiRH!7!xo- zWTn6yL(kMqI|?YRjU+FhY81Zl#LMY`+e{m(FZq3a7tY@nQ^`vk<4VF<`JR2c%j!NPFGNc?Rim*St-Ajp zp{7QOB);f+KX&3=X{sFO{=%yF+<`-~BgP1~XTqLAfD4lGVnp14J(L*_(KW!SQwaS&5BO+1nJxoXi01zMwKE@7mE@Es}BL~DG#LoJKB60SAUL-0ASRQtzC{m$)pw-jAx#QE(cIBiyFW;2nuFrGjPHJjK^tWN+w9m z$JiFg%vfe`PZ09(L;(O+i9rS@0{w_Vp;%!J>O6IAjbq!c9W%JL##u!6b9{~jX9WT~ zi&Euo4LOV^V7dUjWmL6-Cwwhspd}v1{7H7cu``9vS(Hs~B-crT4AIZQq!+yB4bd46 z=dI{Y3aK>7)LAM=>a29S z)Zk>)6oocAMCL5Yc%q`Nk_l67P2}f%h^pPN=!FU+H86v0s})EB1BqP?GXYTC6kVRL z;n+4p<83;CGJz@p;Ln-1jH)v+{#=>~^;CnQAGk}C#GS<~yV7&g&Rg}`p~nggGnT2I z+9b}A2Fc-0S6XPDjg5K zs?|oUAs8m2>W2?bnJ(|^fGDhYM4AgLYlyt{_GTB9zC3cGxO&&DYB*q${ z%u_}psSmX zi>A%&*`&f3pht|)Y+DUi_?q-wCtCORp4rA*N!a8~5j-2So>HdCX2AFonavx_6g!MM zql1`xZP|#-UQr5n_RS!OfhFLr`$?u){^RicIeilG+>Myy+5U66V;1C{L`JaO4&rFE6aEVMb+oGMi=Ane*oc znHZcP`Am2uczGxan4=X1#EJB}KqdQ06>paq5{o&~R=6w(@7SNyrqFU)b$g^|ug=|k zyy?{Fz0AA`cLY1<2;bP+qT9U9Ltg{600MSUYGkf=!W63CAakB(O=WK|ah;K`+00pl z{ivlhZx)3Vj+dtT_DxnsKWSsRE0xME_)Xxl!6VTFyN&_nlpT5X#xVGR zrntg0=bn=zLw04ADq-Ls7Sc?&L@g}iDNvrTzIwN(U79Q_4%%ass45GtQ|Qn9*%C6v z^IS2u`B?SD1yFnlQxwKCN={lRPCBz*C_7+*TWBDy(P!hW^n}`wczZ*%*Gc$EPqufS z>JA4woZzpsEzPz?>cVx#uJoi!d|=XdON{?oQZu`HyrjBRpQGLBc;QbE|7rgbzt+Oq zsVhy}UM4yh<#8!ZE4`PFTnnr&xiI0rY7UTzDD+m;5E`)RbWdOq@eIrMd^X)obsM%R zu%b;i^c&H-epv?kcpqQ;9D%z-X20)jt`2oO8J zn9@yNT6s-^=c!t?L%Fmi3*HzXM<3i6*}86%&q7|)(R(70w$YX2rD)4>$gx;J&o8<& z&e^oj8>i}(Y+;PZb2pfdl=7uj8r?KZaV$oTxX5YZU91P%pyvTV1Ar`LBz_IMyk-~3 za8NPDy*5Y~KxmKzhMOwq$^cscAdwE|floe7!Xo1YY!NK8wrCU$WdwN`9%Xb8v{RE{ z9?g7RCYY}ep*#8o*>q_PDO zDECX70cSQ3i&!K5Cby*d)EI?QYA`d{4XvH4t`x1raFQ_wsu)dTFf+<5EA&#z>^q`O zZ7syIi|jZ3MDps?q~vIahJs(JElau7@_gex-#l9m^e5_+XW=V`1th#ZG^@jU*A^QcQ2+q%0csF= z_yU^6nbt*>g`RD$qTQL=s9_6YHu_-m0Nd*$vtlTRWK!5b8c|h=F~t@%KdWJgWqjj~ zT{p^Hsd5^yIEZ~_={%{8JNEO!rU)nC>2|4Ilv&0Iq~?z3DQ|@KzN4qzDO3LJv4+#8 zqv2HqV!?V@U$@Efq?Pe7z;$Kk>v(Mf#H%m$dlqG~l*<;T40t@>A*?g!0v6!g6IGi@ zpl1fGUoACaQh=)Gru>M{cZ?Kmp?S|gmeUwcKw`SR;`2mYCZsVtXMc`{)C#J}X$q6l zi<}nov88TsCK_Ze*GQ@kbx--3DilD9M*%Yfp!=dx-Q}qrQ-tf&+Jv0AKtG$(IxjUj z=o3|OMsXbR&>=Mga7Dh09;6KE|Sj%SJ|7snydqQbYysGzbSJ=tC?X5XLIA&X1<+67oRcYe93|Jm!!w`VM}y2xt%Cg3-o(3v-+v!g z+PrnF8mi2R3D=k9QYdVf={LJ7(cXeOYU6_Qu@{ZF^(zF54r26YIs`mQ6Bx1lQw%6Sh&y z6Pai23*d)P=_|MQz33!6zjZ03zc|6zl3vu)Bh* zN!Q^j45hjC7>TOi%}9b=POt;~_&)rGY}DCvIZa%T(i5&erz%D_E7To4?9raOb>8P>!9>wWoy^A%b+=z$Inw4K7#t&V=F460lKSUQL-!Yb^z*GYJ;PRJ>erj{|?uAzqnD>*P#KR>)6~k-oU;N>*Yyx zVa{wn;yh)NjXK-`cu9DBHuNxw6ao-3=qL0h&?d_kn+`rq1t1APHo$^<)e8;^3c`UWCU`8+6C*K75oaE#;Js{_oJt0?h;^(id!KvjH%mK8lQvI8kB3u#%2&Dlrb3%WX4qk;bM4MfwoK@U zg$g=%2ZrWvNhi$B>F?<+D7>-j81GIJZj#h-htxE7eU$ItSokDgqe3vFqi%rj<}_cy z(X!1ahnhMZIrXHZjOJ#WR~*CHG$o=k%1*yONp36Ak|kS~9kb;K=Q!e$d{W)L!OeW1 z80)M4y(X`*e&f%Ouv#H0V}CVUufe>PsQD)0p@xH^(>GH*yHPLAM{5FuR{1B51A96+ zwPK#Ewl}CEkg0-a&X&X`RAxL=6D97wazZ&qY~&-d->zRz8hf(*X`|_c=3s&OS5k@6H_|YeR_+8CtvArzFotYjDk=trc^2 zN~#R^_ifF%-kI8_uF!{0-FVz_SAx3KkeA!NTI785`P(DoG472$&*yqw;9@o4C)zEX z7PqYL{{nd()%)-ZVhX_s z2`W+9Gt(K2tc;BTyX${|3!4%E7FElU05I2fW+RT0W$=K$$^;ook`v+62=h#Hfcvq@ zY}}fyd=w3|AV^kb1F5+djpmava586pleLN&o=5b~=cmj@M%=J^Z6VfuNWeZWLQCDKy(c%h`2 z1mfqpW4)eZyr({dKJGp9F@bGb{LQ5QcU0Y%yQV$;9oogyL*~iDvDI3ffzz>Xj_nf@ zlz!Ui;V<+Kb!zt86I=J$+~9JKhIlqMh;`~=*P4V_?G_#K5)OYMii%NsWrWAHQZ)-Z zf{%HtXCz!u0@a1<6_8?FEHtBdv|Z}HH1l~_)4$|vUnU(|Jv)?=(qgV%G%zx*w%||R zQNQFRKvYVJtdtKmPE(}ae&xzl9bPv*&gxlGlp=7oB#geRaIVYe_8o}<`QhCUJLP^a zP4s#9(wmxUTrQ?i@B8!z5?e;C=IxJ0zY9)WatJSRwch`-%|m#BRylF`C=Y&urwOzO zC?;3sl=MeWXj$4#EfgAy0%l@Nz+;%rV+`dq#SsyhJTJBZFp#*DH9#ViKYD^U_cklDsTDZy+@5$TmA9z-R8Ei$FD zz8YH;#9@KhJ&rjWnBi&BDZ|JW0+3Wj%Vq!uPc*RB&Z0@K#sFzf z%P}>FWnR6_?x9%u#AQ&pu+IE?+_uic$|FowKt6A@#q&qf9bP?{%eQaHEF2)38Uuso$ z?II0UpB3?LpiLf5{=8mev+g*XaOAp~#HK47(*8iEF+!&|;TjwB{TCCowy9Rw&WW(% zoJ(f}S!!Yi=EC-}pPicP3gsC;%0H2v`sx;CSmS0Aq58<}~LyVqv(b0*0mL4JGv^e>R@{rY)Lk@q)@ z*0sK|k(xpWB@@OF!P7r)KYA%ORY%IYyLJQOan$|Z;Jo#{t+4hb{j4@GpGimAg}_vM z!i|^qmr|!4y_Vx6lo}ciSp0Ackrv7y4KjT69+|`ix+2F0DlS}q;cz@yRr(UB{`7;%Z{M)KF@1S)<{~szr>{6Tr1b^b>8_S}yy{CxAu8QjmtvSDFfp(9`ji+0D-4hj^53TXNB*9vrC*9O z_5^+n1GJpTj$#I;xIpxGf6vN{hK&&5G+-fTtS!9~%I|4QCo{?T`Pu;M1!Qo>k}Nn+ zGcaTE8DBKmR8EIjiaxy@*J)J5@=39w<^73O&)$y*bOe#VK;}D`HhqJXgL}RX<{)Z{RuXWrx)>n8ZSsQs~G3)(p?bxJ)7mu;v z28zfN*|-p45EH^kY5nO>xB8@*W82`C^DCj8htIo|F1Zia7y0IRqmv+wZjr}zPAA2~ z(!onCwCQeJi&kXb)Jy~S4;u-t!xgF^l zJN^J;qR+9DpSKc@T)scEwls`=u)Q=1PFH>#yOjUFeOIUbsWp9Q1l_l^+*Bp!up3&e ziZYnna=gXKbbh%>@4A3?Z+y}RuI~MMEdeh+I^L(pcl#utqVUnMaIG@X^@$9|+7Ph} z^B5fK1Rs#6PT6qGQ@kz;aLyIhE9+UeGI)b@FxKb}sjom~IoK8ZDE5jMo7LcA=>b-Lr(jh{15n$ggsO%5o04CO zd2D91cR4!j!xKJyg1jh@e9uJkx`2xtE*3N%&m%M`cCxx^%KfDElZ8@cmvp#_YQhDN z=-NwZN5;Dwa@BrqL`SeT@>)BH-)7VEu|Zy7wNw6~S)*%fkkJ--lvlSQs2F)cpQPVc zjh3M46_FyP%_e~QG~2i-taee&VW-HKg&zeur;l{fw_}c&OGlTp@r0Ku77kja&&$sZ zS8yKf$j*5na8Sx=iuy6I{cPHuAEPH`?)?H`U;Y9eDB3V{c{G%D#8t;WVMDz?KiyD- zXW;(T%43`5dsPP}#NK7Lys7A(Vu5z;leD|@}8$V@!f#T)%wHa4k zN)fv)vd2UG{>JGUxHZYr=ZUA-=ZCPnR7{HMXH&gLF>%91sWGR&|1j=BjW+gCHu$*| zZ-p6hXK5gjr+-q+ru)L8(+Ks3gXJA;3lGLkZ@c9yNPAl5Yn>xr+4Y;f=W|$;b1qna zW*2BZx#hwcw1wxlBp2tM!}1p#Kg9Vx5VYX^nR$bCv_C9|BA_yT)+P^T=K}j|6fVDV z?bU&8zh5Aiqcy)k89zF&`nNx~y{&hN?L*^%yeCo1!>vhnukr3z7DTot91-Wr9)J0~ zC!6Y&*C%MpAvJT@`I@fiioVu7PwX7UxkN`h>vf!a!@d^h5{x$Al?_ABd1I;?+j9A9 zzIQ_JHQ>H|GGi?U(kDtWSDyL!9U$yCz0Gw7X>ha{8Hvg~S2p$KC8+%Ml>@YWL&h~9 z;yU!senK6Y_EMD;pB44;z23xky?nRs*^%Zb5h}#fdUy>!sN!Tr%(IHuDD-?)1F?DC z^ftThrj-E8TJ@;@?7$sxw>8HH>B9B%ik_RZP48c`oHxlA+_;G~G-MeN|9CU|>xH~v9GMmeF@&zWVZ07FM zu}g#tnj@0yRWa^4xu)&Ate?oGcV<*%w`USWet<15e|7G(eF%B{O#PhoW1(Wnc2;Ocuxovoj~g~V{Pl=Nip9jWU!d*FIiYR7lT z?rYonpkZ3$nJWYDeoDQnJ^hjPvou`mN}z6^D_(Wu7YLJk+f83ye9!*oxz(e#QI}4z zz16qv1>A%+8mkHGrps`!_5u6{1Km2Ei?kaQRf)Bc=gAxcMfVb1e}U2~oka=fe`?1$ zEd_mz`l{Y0fAXdIY@T|L@MG!VtoHkodOu9M3*R|OPc8ofeTdi!_*C;fv1(q&{6}+i z<;h_AYej8VoF24M|1Wkwhg~)~bGU5HKFPpmF+x(uq-;eVvcK zTHD(<&(GDeSH9Ocqn`sZx@Z(*t!48Iv{iE3*C|g6XD(XJ8VU+OZGREHEZvchQ)}zS zZ*OdpxJM64SUj5}UB7kYRzX)fce$c|c}-mJLD22HAxCdlfkiTnxRIpF>mg(@iE1S4 zlaledY1+XmPGDit*TYArrA{5xy^Hz*wnHJOubs179Yso_DH2C34TrBKT``Kq2E6;1>unJ6-*lbRr{nVaoAG%<*dVzP3|eb6Zr;LAO;^riaf#+8!J=fTpo5 ze!6*0+uxpgwOHy}Ot^^GrQ53ijqIkw-E6x$oZw}v1Gf| z%g0_sJi|Nx0;!Fug;D6J=KdQ?8_oVG6|-GLb4TvGfoQ9-=>8owrkK&L$gWtpVh=;$f4uRl>)kndZ+fd zE7@2^Vs3rFH%O0Je8zG!d1PW{z9ckni7u6|Can!GsVtu@n-NB2W$+j*jp&_eM;);_ z^L^(0C+e5_#T|pQWlx0h4YFUNzRPevq~%1d;#CIpJc`0)IM;hmej)2~PnM`CX&Z%A z#}8!`3xG3^!s6c9=d|LqB0*~_Iwm^F)P^g=KY4D-TjCtGFvY~ozSu9qHYbIl!_h9E z_|JU`_?Yn4fy;9!BS(PuN>6y9Hin~4M_#)3F?hk7(0J)_$j9;R0nrW-AwfgVu}a`o zb9t1Xt_21^;WXSbTo_y1bn)U|40%OsIhP$Z*o2p|`weJ5nKsccSIv2I{&a?X%H62_60kSHfF828~=bYmt3%pqhBj0E**uRJh!n znL^NxT-H53bZy2w_u~1@R%yhQxWuJ%+&tVWeFGsOps;T|LYGn+%SA$*9a$v5{SN?? zKx@BMF=P%rEO(B_e%>WIo+-(}vY{~(A{swZ-m`W@k zH%qhd@KN)$?%MkO9`)>;4--t-MXYnE2ak%8nL!#$>Q+c>6&kH`osKxk27WOtCG;eS ze2t9}VHs5r;oG%}9IB(l_oS|3A0sx1wa#`hVZ3X>PtM}E`GEnQ7P-#F{xDTfP{iTA z{e>GLvZZSNp^46Hd%;iCF@KNfC=HZa->70A$Mh5(LlFKp@dXNOqShJsr%_-7y~2^1 zk1i5_Vf&IuXLMMOO(R(nJV75~q_eSsV?T6BNkdEIY$DegxW|Ot_#e4X&SQ_nPxnvU zl7v1+Wh)2HV;u#7`xv^Dm5&e!urcvTLLVbCEmPES1Nn}e2Gg&Cex-@0n6ds-r;4&5 z+EBIre-ls48t~$z=VIykgWOOV)U9~s(J2rnG}o9znU3si55=8H5NAymxz5G`{XkD( zQTm2A@f4IG**%E0Wz=!N^2>4TW>k+-t^DlW&-SLIT`b)}sO(s)%f_5ZmC=tN4;4|H zip}Z`Irglprra8}8dCm7!j*t#V~|L&5ICqg*ub%{dli!=bXv!#;(_rWaZ?_liz3_G z#1Mel+#R{0TA!$5iSFK6y9$rhF*{}c5{<59NxYG`nl4@4QqNHOJ4`ulj;kN)nIFsk z6^^B54fL(pbej=~6hV(4DA&axF`_i05gdG=1;)kdxfja#9kKLxYTv?JRhAjo&(di^ zLUY2eUdip*so!w{m({swb!XOzR z9xI1M)=@alxymve-EPOMM9j-3h+Gb`CqwU9b+&wYqosE#lfix#b+T}}J}ow0YLuf} z0~pRkxjeO6H`~#{l`+M*Oa_eH?Q$F+iBcVR3`9fZ!@H3Z0Q#26>-bRsOBUU-8fy|g z%BOBB%*#}GCs=11%xt~n3MD9pB48HPV;0?6F*|_;i0oaQo~hHNF;K>y_JsF19$qX~ zhF(vvA-1Sg0VG630(%{9w!FoXM?K2zrR8rtOcUSfMTl+ZSMF2*@*#BS&&Q5G`iy<^ zi6AhJx_6{H$43RHgC`%@Roc;%nbAr0By&JuT=N_i@@8RS0mLLCc=s->I>$(mA~-VB zQW^^Ob&it3@ntPE(mE{bx8Y{`bZgd*JracUnKK{4r)6>*tAL4pIQFiby3a}B3|U0J zrJ_T2Xss@<(rY_qgN49aY=wGXY~c+zS=#%D{3|E@y^3HV>RYrD zN0@)Yx^`bhAj;b#00Zhk`WH6u+*mcZtV-7G^8Wz5n#P1<{{Xgd+;vev-{{Xh| zz|zZq#6IP6kstxYodU&PO%rCe z5-jIzf@&HO%OAI^kHdX{abhDaRvSNVO1y$J| z+Gmr<=(+R>%+@Xhd{vj{*`ZwVWa2i0%yk5_FLO>tHbaVM8w_}X#CNPYco_DBDZ)F4 z2{3$SH)N8dP$DE1Ap(0*FUuSY(mB)3OkTmAEGCGVfuay?!#xsL493(jr+^6*% zTXu<46TI<|3^W{7dXa?84eQTPX;c9YmkGW5UhCw!`XE zT_dg5WjMmD%zdmtc+MbuOD>7NGIP<%$>;IqWPuh55;X*a9Xaw>wAMFBdXaz*GzGt@ z*LeQq^&CeggWKh^lEW^qk9qvtv8*y1Tt>n66n6grCTm0dDC1$`bxf$6ajBK{LrEBc z$Fq{fIpkGrp*M`9p@9rS_I(b@qm7U;#62ML8y%dDel1rbQt206DdozT+bw1ZkT330 z-w92Hx8{s6Na0j$ouJpcR(=-vpShN6ak6Na`id;9$nlg&a(q{!$FHW->1ATqT!@&A zB@%e^WxLj{q{`JxE*2E)v8(XJYZCHC>uFIClRz_%=X_QSRiFcuOxjag~@w?aP%#@G3XN4urMDyy=VmM0FpysM|O zip8>LF^pW8$5L1++n;qKCN#`Y0c#-^i$_U&m!id^rq^(mC!{UgF!jvt+G1U7RaF(@ z+l?KffeC{R&lWR|=T&b?FCc+n;3O z-vI3#N~ABGt*z!?aBWL*UZ3<{l1KS4{{Zq8Kce)wjqs)~9YGW3I4s+GQ_mw+*oK}N zm_TUixfWz)9J5tX+#pHXG;zmO+U2IIY3UeN#AM0`){t{8`Z?2r@q0VEUh%~qzjZ}= zr2haB!Ix1cTVg~HY1LSD2?Emd0yJ$GbN<`uMo;O;_K#6d=p8h~Zm7pbaW?x`(EdZ7 zCxvV%`c5e7B12dmI28w|NRKE44k6if4@>BRet^5q55g=C=aSaQ!~MUHEw)`@n9zuw z+kA9a`7DpycN!n5%xG&J9aidbBLg~bob}tSWDk3-dhPV* zzB&=j)2(K(Y@Gz2DIU9_OxuMS*%qGZ1MDija>&t$Y;F<*-mlOE{zfKYq-7~<->54m zRKcEoKQ9T0OLSxXGd$8NEUzgAkT)633snVIu0?6MB)sh z03=9`9m+rL{3g~d-@LA!ecnjNM`!Uf&VHiGu-fp+?{>T!pqXy{A=KG<%Z}?06ZMXG&jwN~it(#0(e-S(Y zSuD$(OINmI$i?}{uGs1FlYVVQzxLzy6#Y7Hr0Z_YgNvu~Z^$oiw!Wj0Wul$J!UTaa zF$MSz3q)sJWDc8nw{rByrcO~YGOu1@A^cRsrdlEdjZ%=%os9ngLZ8XMCLg!jpHetS zE`VYT!%K{VkULezR7vvBa2gGCW!wEa@BO3w)R#=Sf*!CBWPhn^`B(fT{yyL1IAbQc zRXYRiti(&ZbGOu}Gb=0{l1Cm(yAGL^8pLF3ksph15#OSd)3TYzq~p8#l>Sx!00{mz z*W);KWx{%pVqAgV!6%Ujozf+M`&o2ujH;NlL=g=vi*oJuD7JB(M8YAN!R}Rit2Yo= z?JARUbK{p(^aQ_uV#z!4b`Lx^?-qqU^i`T9r&wyv7M2DkdEUKx>^7aGu#kMn`(G0Q=F@w@vJ5Bzb^H9wj$cA zGgd*mb;d$_Nr{|G#Ia|I<`(auXP_+_*q-&TiVT`k)B@mzwTX0oV&7CBNI-8MB(I|689=#T)+5IB4?`xZK`=_LBJMqYs} zW|G!iw32kNopgLKVQx8%8;SO#86*xMEHMUl36eIBUF&wt-h&-1jDRDC*>LtM!rqc4 z>cTP@(i#o2_`6$*ba0(?#|+B19$h`j{H7U6hb3C8eYfu283V8mT$XFI{{U}|ECu*B z*`0lhH?7j?gf~EnNc^aPbPDugOT97690}EVDqpt)R&paj#OMNwqGIMjq#(mwOrWXKxxa50pPZ>I)_Qe*()*TV{~`Yz-bq4eA*R*^*)o8BTgj~ zuFxFFfFi@a*R55aZAllyq)zJ0!^Otf9vbl{y>$Lkrv$(^Ef%IAYbqbiAH{V@T7xok zGvy3g1mO@2rop-Bv@5oD6Dv47m*z*$CrPPapH}Hu zv-GdRl;k|ZKt!}-{F{1S`RinexI-0N+Zf>)ke^=uZI7b?GTIH6xnO@mJzC z{GahMivd1u8&MOZca>){4ln1yA!Z?$tj0eNlsCd%WWTLqQ&o9CDrHr# z0RX|9pJL|yx|q~!t%T{w*SjJxqEo2264+)tdjit+-@L7^r=M0=Nf*^X#{$5?#=Knl zS#V>L=aKPV{{U*Mw?>yd&j-79y_DgGM^M?fNoaW~l#CCYGUs}vk1m`LT1-efuTN1P zXSQ@i$>0G8+@RXThT)>njihpAVL3>Hs&x@MZ-KoV2@l9=?OExoBKXQw)@C3;lWKh2 z7cw?ZI_SX$P0-e<5#Ho|N!8>PIh_N%OJt(b9El3juJCr4Z7lv~;qP4IkvT_v%gcvv zy-?&^E_x0e^iH7Axg9cXiwOV;?+G((M(qSh9Kl@Z-27zaT-pwxu>b_>mqEBJkUX_j z^@!rMgr*52h*>LyvNSfr4aa$Js3%bqruZ8o(W~*%fabCb>jFT)$;O18q#pq5oguQr z5iuY%-4|o;2qz*$WnhRr$UkCr5LB`^O}(EIk6K{=0JUq@D`^wx4ROgol^c zfquu){fX5C*D;CCD%yT2}V!AnJ_*C zr&SlFV_;-YkzvbKW07DDDQI{Cboy2y(z8MIsEg9N zTutAZwS!3aF=Zb+e6-&pM|oN?jqna*A<3AkN)vJAm$B8>iKgABOXlz4xck({HzWfX z%z?*`-npA(&c{v<$v`Z4p8Zo=dm#0PgsKHO#O;#sPl6=zdK~4^K%o#1_$a` zjzQFbTd~%el=g{9;tAuZ3m>l-k~bfAg%y&>I@^iSjqS*n{{T|u z`qLZ^ibg<@JEmN41A{GVMs&VsSLX-t%fHR1`>L|ca6}#>u$T8PTqjwR&eIYZm>D{} zvSAIvGBfObG*eMw_8l1JLE2HCgOr&4Nj$(n5Z(tjkUvmd&zgx3s6Ic`tt^1g4n2jq zc&V*PbhzMkBcBM7CaW3{{Y*u+q)kk zvoGM3!+XG`4CzeLo7t)4BF!^xGG*Hf0VLNaFHt$uN%B6UI}2oeKMKsR^` zHV;ASRAtj`rk#+Jowf&5%IFG_*Yt+)8Ke2rUk4S}N) zIk}NPTE|_GoP=Z!r&IkMZ{z;h%#^>>nAaZ2&N6?cWX$M87Zl$OJu(Sn5?JV#+=U50 zfh)G`^*3JT%}PcwjHNkQ9kSXZ5)T2#;ae2QdQo@O5*&hf_b^&UH+p7O7Jxo&<5uwt ze+xg<5H2b{SJaNy4yl<%26X!J z8(K{LpreRfiCLDox+!L65a}#6Z;QgNe`T*%o5pPWTAC2xBf9mR7^E(}xQSZZ&QLkkFNz zG*fL8uIj|=Bse@YSn(nLk^_L)%sCBoS^A%GykT;aSE6NGlZ)%vK$Pq{0K-56rY2pN zbxg``VH!(7$Bwe*w(zK?u_)36Yu%(r08B&hQ5aA%?3H37!VjoJIXj0H6h%u9&5wz; zObF3yWdo+}8&18;Z~-70gK=7YK*R!h2ptE4o4%hEU7ugX^OCANq1@a;*S}!~zEnA3;;| zCK3=40z^5OJh-h|?Gc!iz@SJZP_BObQJF5Hvg% z)GEN8h7lmn2U0%?Ro@eur#^6U4HmPv(0=8!Uq9BTbbsg-bW9FVoHuZlCG>w_~#sOAYdwo(I>i-j`kIVVvje&D&rN3~iqFUQ`b zE<;thvwAtXPFm9!;(yq-4fe`0lyi=nZ6JpC@m#TV03dPkQZ9o75vz06Ar)HO+0%{I zkYsY|amql6dl)Q{&$=*>8C8!Ml23qKe^yVrQSl{Dtyw&jgk68so0(Ztya74FZNqGK zkhLC9#aVvgsU_1mX9e;A&RNS249)WW*x`1Hy6~fTN0i)mY_w;8w5O{ zCG*9Mypk5c_CsoFE{@!dcYY_{Q(j8u+8LW~dp?TQ%M8zM@*sH3#ent#Rg_IHnTJtFWsjFHLl-*dT)bY6HGckYT2{oA|fMbndhNc z%<#AgZax|&q(?@xXvSn2NQ{VaA8O5D!H63Sio$BJvWA=0b*nNkQWIIVjpo+d;g8AKfqZ_>9QVYI_>S%Vxy{M*kXKv z<|5mBeXHgrXtJ_cA5LpE(%wC%ro~}=cJMvAFkIfbln+&|m_`J~F^s42K9Aept(t84 z8SRja^wf<#%Y#HLqWIZAG(pw1W!Kv;BWsH|dzsd|5s+m@%H%A7-4nzHc2uIFd5|)V zAO{9oI@Y%7g7RkI!;*oMFxz~q($i`WBDzl%CrWs9MzI=ig_3ufeOs}Xl=19=ll(lN zm{;|@)&n*@oR7IhyR4R$zyJeBf#8*`ml^LZ?^lx3+*joCV#GniZtxwep$?IgY$?9C zlms;(_mZ`#oaH(MER+nOM5V_^;8$w9rHe&$Zjp;^6N%J6bF!9JoFN;hP9>)Cz~*I! zy@XGe5p(QXITpa%CX0~TmD$O~!xnG8^Y}oH&C!cq)IvfGenCkM$u1BVAT zeTzvP?&q@>Y75Eo@a|TOgiFplmfcttSOJ0AxNgug$a+5ErNw35tBI=Y-SCr7bSC1s zm}vU&Q!oMBIFTU5!3@WN3!{(GPn-)3$%zf81#3DtsUag_VG$P7mx{~WtWVr+Z0<~9 z@i^u@TiE4NMq`YhwRQ*X`16#s@Im`}E%C{{pK_gQcKi$cea_E`$K4|OmQfpxauSI# zluLpO7x7+>u9c4pg>%KO!yYzF%W?5y0?sfoh#%-N$mB>RI`riJ1^zbY2EHehzbhjm zd+xG!%YZVIpQU8R&d^lqJCYov>NxGzvJX?-v;yi7{B$yR^;JZ*oFqu-||@C(~+ zC!fsWezDY`Mc*)GC-Vn#9P^I_Yr4NxwgB5~<6ODK?H@w!!wNfQBZ-dsX|s>LW5}iy zW8EW(Od=uYUpKC|Qxc~+;ZW>q-K7fs{96S#Yd z>4}L{DKLPj6CnTy4tuvOQpTcOYl!fREo!F{XxvW+2)sXa=E9*_jAt?b05I_{+%#9W z_kKwb6xSG#8heK4JlIKQNGl|fJbft> zLqn*K1w&zVVKOVn1@JoNj*@ug$=Tbxx8YlTE}oelp^j+DGqZRxiTq#qLgM3@*mhS{ zc1VKTfqxDmbvi{EQR+C7mMw6MWCWx*$6j7yqpF5=qw^!&@zq0is|^AU3PGtjKez*3 zQ6Ip5waV_~y1jPF=aC3PK*|wmHzG@}SRIQO;%jdu-c&_qs2%_+2SGP|yXuCc8H--llu~fXvQPcfi^5fGTniA`U`aT~q zYkzDH{Ht~{9iS@$G}UMM8=TF5A8Gw2kLcYyoGl};*gxP~Sa`S?R*Pt&Tf_WzIM=&k z$eM9ZzU$7xY;7!6P2bd8g%KzFP5%IuSM?itRT1s}rKzGB034QC>ff2Jl{p=rnR&Xb zyCofmX$B*@D@Kl^j~e4yeb4|ODf-sONAqH3ka&Z`!E*-)^KiRb?QPZZXP%T#cX4o0 z_8XGwG+nls2?-k_9KeCa`1=;uS-uos12;SB*cfisL^9>THI?@7is*IM_3Cq{rarZ; zyG<;~xm`QEX?BjM)bVl0gc5P5%jb9$Cl-j!9xeo4^@OfV#!(&y}0sFaLnTd;BF9}v;LC+E!_ zs*-kZBUP=NX(gG_yQrEa7|wKVmjFoz#U<1%^xi_a#zRSN)3cabajK|;2~}N={%a$? zL%@=v`aODMOwW=p?RL=nmn&}VOeW*B=(evC(vtDK7)#1XhZ#6-%@%ld0YwpnYn0~R zNQPp3Nm{U=#s(1}M6hna3b3ww;zKKL9ZZ1pW za(geEJiM{?ES34>k zv~4ZsAn)J6Yk_?x~M;eO4Vq zBFasss6f|F;ylEd{uNXEa~@F=eZCSOWR~U!OO6&UR3K#c#<3E6M4v^NQ^$^Lu+}kz zEWIzZ#4vE-gG}t{)AeT)Dbc!TY=K}8v1{bfhSlupk%>PRmJOr(ziQ~m=P~i|#ANdk zlxOO0B%S2Dzhao>X2?msIXDH0tV~0aVuFaq~2ZB(Nl&<&H*O`Y6xL z$@GY-sKR;>b^)i1O6Hutryb4)*>esb#d+)PZGWmU;&-s510U|YSKT6u@(x79{)~pXg9L6oeWP=G8&Q85uc z!?ASE1~s!1j!fcs0P*Bf%oxfZx6I3E^J*VYdhDp<8z+a@W!+8*tYGI$rR2_LP{*7% z5=3GFj9-(Pa9PJo%Nlw!?;u6L-ddJmVp(w8)aO;xAi{weJhdl7xD8fAhlXCG)@SRR z0rd9Ea3mkg_>-qrl)1xEI+jhsjCADLko4T`z#8WONE?p;JK9QTjXI2?O@IUC(f!RO zj~cv88|QrU<#m;Al<1sf@DlHGG+BRK>4bu9i*aJ>HrkcsQfj-6dcUzxPFsXTtP+-y z(Ek9$vqKXqJY{6N7)bl|Q%-g!RpNN_jH6S|V15|(t-89;N)NjtqCd2yHf-ftMkT)N zB-IJsPnp6Pw+v&PF2c+SoL0E9I7``1JDo{mnJRNvYOEbJ?z>QOjKxF0-(gAT`9 zdkJGK!r!yQNo+V(nk6iV7|3~N1E-okQhlpvbgrMZggl@#0=O5>IdPYv)GNdhvCW&# z&>w@uwyLz_WW+$()}gRWpHfmHHUq*jk(YAxC{cxHw=;#6o>sC^!zeum=^ZeB1O6lT zt|mz7)e8@&INz)x66|&2Q=MEusLhAH-Wx_G+<6^uh~OboXdXgExQ+Sai;$;zO&z zN;-q;$3|N?*qu*t^?IbT2_tBQqD&7R2CI7M%*Kmwg7wOo2@2#Q1D&LYjC zjCSeTe#MH`s&Mc(XLtR~L#0=jPs|G@Sm_o3>(7G8UrmKodIClux0rzJ4yq-UJZMG{ zn22x0+cJ1Y~vdsgs5D*d|#o8~=np+#C2?*m(#{_F3AFb3*o|RcE z*Pf(u4hBi6T^ZJ79#M(ncAmwtlN)TO7FI%gL;aeKggI(V4pJsoOZu(+*+`8&X?~uKjT! z2#0MnDe~o<6jM=*Ivsje>=a#Ah2|7 zE8&$XS`1z!G=XyDd6%PrJEJK;$VhX3Zm~|GEQ2r%NPv`iI`peJgUsuWBvoW;oM$M@ z^5y0NC(H*i-nX%G{$$)mW-zKkAf6?|yK-96g|m}t1Mq(F-`=v~jhLAS@d?Be;7Cc6 zF$H?1alaO1K-n66@@8?NZYzO*Ov$vHVEbq0U=EL}^f*#7r>K+J5%fuGdPGA=lNR9p z!8sLiM=uu;ip>TFQ`S=&gDrfam5vNOJ@#WtnFrEbK;^-2!lUYI7CShxS=88BbF2Cxk#8nQhySMRVW?jGMTC ze&thzT-e7xbXa0PDl@HtZ{e1y5?O*PryBf;#(P9+h!x20)&4CNv$}m2(T|5r;~L37 z33lx7;;W5;o4&}?kppdH8Ch&MZyk*jqR&-5LN zekZ|m<8wBv(RFHie1uw0kR|z$CPXuK9X-p7ENo*V7=QzZ0F~(4yw?|rSys8%> zB4scn;fs%JI!5ns?9i)vHG&w#Hkt#joK=2aF$9?pd0izOWyz9O`5-`I2Cj8FsH$+zWv0uN#PG;8pXE9k!cbPneR-(H;mX zfyOsNUkMS|Mx|LsT*)3%%zQ~xylch#Le!Pg)I_s(s256F868ToBy8D~x1J<2v6Qy! ztB>iyYl*i5syq1dDFRIJk#;(`Iv#pD!fcd&A>F4X5wXLa6z<)jViZNsiM50UnKeey z`9?rY9W>{v^i)@gJ>$}gR(6JzxeoQ^eNf;#toXf0BN}9hZi6w2C5nwVmIq4NF4NKo z;(Vvf`a?s&PC#*PC1#yCur-fQ{L*G@AwNAg4M zCvUW_Znx%kTf2Ed=;w=wFZ4DSVb6!&@M|+V5O)EmKE;~q{vq<;`~l8C}&S33kl>$%wbmc{sV%#)8g;5ZmOf%ZB{V4Pa$ zw-)QN@?`-@h=oyUN<{M!lEGDVnNC+sdT?TP5M*C~A8NyHJ(Nh-WPHr=J)vU~nbCod zW{#7@j#I?QZ`AG5FQ;m!t!D>u^eEg8hAd;*z^InM#D*NgsiI@dxS85zAPR`cOI8b` z(Yke6^Ls6gV{nKcOQ`DIDtMMx9C=FkP9|DMP+f>WVrt-PLutN8IFrf8xk!-wM0Xwq zWEgouB2ve(kMf>N9P2S-I^_ui_<;o^%rbN%j^oY#wGcLMjAP~`fC1pA9=}%lq44^X z5PuTO)93VxDCyq6rwGouMT|*22o0*uu93aEJ5K|ZM@!C)u@+D)hZ!`ZFQ~4zHU>ZD zdf3q%HtvX6>d1y6vUZanQJZWX+nH|_vn<;r1MSdl;N1LIpK;y)0Kx3qrrQ4i?l~QM z6jeHoJz@|LdF>1x^jw)6BF`U-P`wrQ41ozq0Wk4m<|KG&s?`{E$i`C;k>boj@Fmwx zyTwv@nBCjeX4XYIPyH~E9AtD`#IOX#!}3_^k)|@NS6;vljC{KB2yK>x2*`Fj6%)-?ovisCYg+T*ZA3+h1II+#I~GY-RBVhyUw0B@Ry?Uk9vg=a01xX}TdCoZx3tl1 zpo5@S+8AgwTG7m^agM<=A`bDlf%YreZP2rB-S8qZiG*ZMyrk=Tmopk)BVa@ixoc!* zLXrd;a@E5VsO6whc3w=h@!b>lB~qOH(}9HD6onqI8gEvY`#S!|1*h|wI z`AFL>K80a%$=!~2#GPbfT#jQ!H&ZYRHnVQiEY|WgtHRMn%!+)Y1HY9GPjE zS(^Pb8v3#)Dg^NloP!eON-fH-OITT_fkV02vh=2lHcW6daosCIJtGT(gx2a-rdVG|igU~{-= zd{=He`HeeQ5(VcF`feXu?x5mxdfhi&(=Dc$?v#jO&qA_ciw{kN2=j^qj`k}rQpkob zEUuU&0vrxH=%X59I6~GeDiZJDO3&2$jr}!!tkdW;nsEI)Vq;`%mf(B9D-H%V(CXRN zSrQ{=wQHdpmd{Aa`RZkmB4Q1MZRez76yoJZ{OpS36B$qhOY?aszL5UE&G(zpBm)ug z9xIvEa^s6A#&DF3;#kF-i)!R9wvXPt*Y49HIt^jvwp;`FeJb9vlI<-m4Zc-0C7GEx z3gx<0#Jpo865jH*s>$^w7{?k`OdlXSOMN;nJwC6l1mbpU$-5bE;Fx^S+oJ-lmKppE ztHZ&3O!<93BKdl;$CYz!Vj~78x8hi1jVfXV)tE@}GHqMt8Oqj7^o<2(Z*^t9CG4 z@hBDp>MBDM$g}pMx=vyIO6c0s+V~Zf$)?AJ++dgMwrd1J zQfBlq$jGdmYogWzyVM3FUOYH1fY++C=qexYFE}lh@i8N~7X)#B)t)(-=Fe1FAz=-> zM{dM>)x$goLep$loBN$y>dDNr^0Et%+a%CF9gBMckx8h^n`j(Kvd>Z2{vR=Ak=+E+v}3t_X8y*L&vnZ&0A3NJg#R;%EXZt8sq`uCLOzWomH`VK7L!8t9XyZ z{!`#Xt#Wbh$ds&+^(>JswnfQ5#OuA&XYx4n^rm1e0LhMlzqGq6Sypqv;Okv*lqoqD zbUvq826Sq80DRu!%RVpd{OqDZ+d&`tx*uaY?PJ~ZGX~oQvD8J&>J(?xVJhWBA`b66 z%vbf0>m6H6LASG5l=3{?-9I*&$`Yzt76q6e1w)0LBqQj_<-<-Ze{mC+BU4M|uta;H3>B*_4d5^(L@s=Fsnt#L+y#m`{$&ASVI>b{Ot zekvvbEueM1EW4#eaW=|MWw>td(bh_G;~OF7;6sNTd-P3AXk5-z{YuW;1cB@!1ac*; zYndE$iC(9s=8H1SOv5oH%m@Botzpg0nYu>2jY9?#iT?m~N4WVFZ6@A19&$dRJ7W8f z;bQA8)nw5a`tY4%Uk>2Q(q#2HM;?h=cjl0FBtC@p#c_K6V92tvCT7qCo?!a`QEpwd zlH-|rG*xd_I%W=j`12>)qkMSq?IBjUKbgq@Y6{)Q>9*x!&mx^oWv36hmq%z)*~wA1 zl3CyEs0N8v_^yaiOVbcyo&6dhGJ4!wN<BO;^m}l=RW;qd}9=1f9G5Cx} z)UiVDkUDj<5F-pr^I~FkC!29w+NzHep7!Hbc&sXo4y{PWpiJ$Yr|=Q3ovs%cSV`hZ z3%AyC@a@H}taG3dB0M(7vsJ?p*UEK0tJ74KQR2I9_Ilo3h)!0#9$2H1} zAn@AeBr#g`e%;Q5gr&MbGlCD^xM-O@-A5|}(F^5AOQgk~R!g{_YLI`^SA4D68wndk z49ku>?h95p*5hE{{Us5JDHADBRG#x zoyEV);iqo(Z&EA(wEqD3hx}`mB7I8O=={7E>x#xtbtzZR17~pExZ5vf zWBxT#U;!Sq{g8|Q0KBRJ(UcuLS2w$It+maW9=*{%t(t!bwTR4O9Zh73 z1E#9|C#6-?vJF$0rw|;F9JBIUnH@g{*%_D^%3Mq(_DRCn>2=Y8Y>XyIkZ(Bnf57Xu zwWTV!cpiR}*4^tX9EBgWB`L(iCMLPgP!W_1jl2$par%Y_$=^I_hPtxSAh`A~U(@pa zTPm;diOzaz1RFxRJwi2QbsO#?Q?JyQ5DZu!VG7po88LmeHA!Zq)1#pY(T{(qWG=2# zF#~SX8Et3gwk!Ry8OM3B$GDKXl--Y39xmb`5-f}cr-Xr9mf7jy%INYNfg?sl+#u+a zcwsmkjDOqsbwv90i_aovV&3ITe{N&Sa+PP&fjU4*!1t}l^*Ip!UK+6b%B=d0lHnm9 zdImp1Q&Q$@q}~u}NA}GlPf+Wtx~cuGj9dwq@hf8SV zn2<;gLj{%YDB4)!Y5QBG-<0Li4E(9xZLl-8Ry3s%p=~)Sw!15q%OYbh;US9b^Z^~2 zTP3&)GS~$K~`MkPn4~p6_GKEusBuaZ6cCtN`@D&MnWH?e*&b%A_CtG4-uU;9_IOHqjXevI4nCfclO=2lg!8df3;SNw9(?UeV>| zNM%;iN!4K_w(EAKo-VDUPxmEuUDUv?x~&4ZZuL27$%tB*j|^Rsglm*#At*$W0UeG? z?d4-*X69XqIqb;%xkF=aVIMaT%R@h*Thnnts^6G|rdTsgrLTcj^mbTtGq-g|j}?J~ zrO$2l7ykf}bN>K^@kXpk{wdThn7r(IE- z-{_c5dq@H%Vr(PD9@`6XIfD;SoB9FA{XK5q+LFgjt+>Au7CfnmMvEmafYd9j$`aa4 z3F3KiQz%nZL9;5im~o_-_!e2V({+P&-b&MsY}<{Xol4IcIPF~e;djLt%5fZJ5J>tJ z=(gETQk=AjF%jXQUR}>`WaTD$_V?=6XFw*41a5GoSWcmG>EDslU3r(u{{W}8zk*)? zt3Xu0Qj7-}G&p0hI4UpQENf&xf z7LL_Es9Mu$l4`6$c}H|W-6*$Yi01vOna^iv@#nbz%1!=n=h7T7E$0+C|FFTRZzGP z0F2C`@nuHgus`cjs~jI;&U?0V^n1FX&G-34q^ua z-HQ$N*KIL2nQVXu%MeM_Gr3)ZLb9Da^MaB0QBa;5)5Ps8u8%jtx30an9>q|^Bk7< ztyZl>#tO^gX3l0fy0f1#{{YNl_eLeXl31qy0Iih5%t$@@7L^j2X z1NWr5ZWqpc;l9BmF=oxNXNYEZT34#h%l7p6n6+DZKit*eb21<2dXB6;E;{L?R~|5i zjeJM4U+}M0Z%@IT@^AHQ-F!AmA~Euf1B2{dV%$2by2!@uu!M*PLCXy_W!qx)hNp#c z?m2H#*m-!{JUE?Bg%SR-t%Tl*2M!temjP*t;y?Ymv;~Ks{spr{9 z!MsTAixThUT!B^~4fBK4q#ky@C#=7fAJU^{y6e-4bd-*q*3JDYqm51! zvV>=iFEqV57ux(7u2zjn%Js?Ct?6nU=!2PbzxW(?{{YkP^+DUqDKx$i zHS(37eLEUh72zDf@Sfu_S=Ulow|A{v`mrm>68Vq}xbJ}+02(&sjXb&8n^wcZq{kjS zvT^YEv}hJj%{-nQ(%bUT%KPGe+XRI{Fop0l)DkApLzZTx6+O@bLuh5s>7%d zh?e8zBk@E8(D)5V-lrEI8jO(%ZrvAe;U-IFSb3+-^YbwSSo(2er)t4QeHnD*&WU=S zoJ3?m;N+#X^eN04UL|NAQR_pDbApRoF} zG9Bcdc`HuNnHl<*7@G?@)csm2&!}|x0%HR{I*5<7tTdvsf+NF$SY_1gmXNa+16^e~O#2y@lu!H^ zdZRCyxdVRKh*TsPd5pHP>|hdc&y+}vsjow;N?&SHEWQzzJ@F7=`(h+to~pNEgA#Ac zvLU~SL1`;;x5Yu0)aPG=Y`m$+vAry*Q5FHC7@d1wL13O8p~>oZ6V3#~UhM*=W!(w{ zexYn5J)_AfN!zz zT)zN+nQGtFx_%)M$sd?Qvl2*tDwTDvoslC}JPEUop}CD+PMx5=O3r5ZlmxXPcY78s z^%+D0C8;bm(`zglJ#VIB+38&ZZ66d_&yNLMZmEHpG4hqR!{?C=$=PXbJlUS5cuAMk zq>u9h4M^7Xko3c`|RUL^{V$G-TiCZ5YyoXAPmroQMtJ$|2OTudZ2e zqis=vCM-O_5()YjSUF6#Wj9nOA-hgLOn&7x5ODHk9tL7h-MFm}`(-v1v;pH&LeGYY~xt z$wacxvnWwvvt*?02*ve*ZAm-}a#}FJv($~0;~3Z&hPk49e2&YfiCvMb!oes^6DE}R zkR(fag2i_6bCX7`bdraPc@(#%4BUF4CL&@)^^1_TPwfy%m#rOja~^zGSXNd^%WO=C zAnoA26;(ckKaj*F9@&~m7*UZpk&>*s?`9w%<0cH{e$ z=zRmHBWKs;Q~v;R+RicK?UDPH)n4xcbniH%{7)JBa2ks~j$GC=gOL(MNRa*@c>DM- zL)5IJK4RF@9D9Ept3MO}01)80#|r(;2}QeQqbjU}+kA}}VA|WHVhi@W1qO@aNR&xD z+lR4rtLXXG2ZZWA6DRCf{R^m#4C)X3$Nm&% z&PQYT@?8PYbB`Np9@%UE0EJ`A==oIPS#`G7PB`5I7k$5q1;?~3&3T$sag9!F@o2JJ z_lW7kx50v;qK;DYa@Xt8VBTe@e(eg4wsG2fmb^?t{3c0L03J4Zzowe3Rvp^v?W+wI zYUsK|ZK75`APV&q^m?}d47N%<8)yfxovWSqH1O1P+u2u~bV1}ycE@h=(rp%7ac{%2 zvvf>M#^{Fy#|7Wb>Dbg?8of%f;y{s|yiYGE5e~PEKX2s59==p%DxP&9>=Q)~S3U9w*e0m~SZuNBdSoDG|C%PZF%!rt745}_?Egvv$1Dfnv({ruc z>rz?+Y#ob7vx=ngWe1IwfH{H!=dLqScIZx4gmNWr)@K97BYa>q({~a%t-CQXNnzzA z@CTZa)2o-3JIQWwCx>$;y7rmqUUN*>uQpY_$uK;5D$gQTw&HhcVn^JzXM+m-gt7i; z>OJa`!y80nFaUP)>Z-j-ZJo!Nhkm4%9jfuBHiwDYe~W-gVag|QuovJ6T|S>}c+^!B zkWRz6(kgvV5xDYMz2Qp4*51sxz6j);Yiln(M|U+KkOsQ*E6{bUx;lm&E5?*6mgkU= zA;WktJaF!en|x*bz?LW6tu3idiMh3=-c<2KTV=ChydACr#?#=#*f^DQg=y50^sB=f z$jDAb$ZiKmTbjYkr*`r-F*;@WGK>Ud=d+i2BrT4s(dUItP^ixNPjQVb@^jfY*N9j)RPICXn3$R4yd8F^n&2fbrmwJaeW`czeXTQT(fj-%6T6Q-%{lT6hp zyd+zhipiVQx~6ra%WE(IFo1-ZH`2ESk#C3(YN@h3<~{3Lv6+J%HxOf{p)nF?u%04N zkYnCfTQMq>WBGu>dKZb)#3`#HxXvP0$7|~3Eve>UMkmi+n(+!dxn5>!w85~S2==syR)skZH<<|Mo$tBgMn8VR@w;= zGKkg3aY>x<5e_P}_XzsuaKwaV%EMsb-GxCK=&6(`5)sRBumc}Y-5t!z#lH2HZA_TJ z)3Ac70S}`$0DJfWwydh#X!LH<)>VfGs1;N`7zp?PkjJRYSOWZ4 zkDplT%y#6@*dzBWC#`jmNBXJ1bA8LY-J{@edpBH9cyV$60Q91Li&v=EU3EfGfCqCh z(ez$2U3;u+{<6<_A5d8A>pfadC6{b~;_~{Hv#U)i#F?cnTPE!>!xPdQqqZRFsan@r zLAJRWA5uRATS7UPUTRqxGUIIc{{RuyV>a3g8A!I8lCjcE>(yxMbxSFpM(J57P9O-x z88Pu~7HoX%s;G#sAJl|j&#tG~xI3pg0l2x-Q=;_!7zy;+w+AP4zlmL{qN#E`_j76d zOu%}MWv66uSe`!+;o=fm`4w3?oO2OBA^3{v#=7BTNDLoea>tnwF$hF3B)Im4UPe=v za%;|x9frY;WQ>Cs;{1CR@9R;>rd66J0WAyncmdxVhicp?^}I7zsh-PTT{oGSTbRJDQXO( zCoUpc!*f-UaWT=8-qhKIJM2_X0b<9JhqF~2Eqy4LQ2AG^`gn-;4ISqAG7&O>y zGZ6&NTw*&^b0w@aYthxPWqbgXqBjR5clR$r`i3t#litO0Mix9}0kRVFM^O+D5W7Du zNOpt(?Aj9%#L~{c6FQk=Z^4rd?NJ;~alEXnK2q9gW(p)T3>9cFP!kd_XYU z5KkUlR$O5Co^Ow6a@xCnYdl|Zx%Bs%T|3d~)%>+)b^=V|NDr$H-sKbuF|w@5jAtq2 zd{J&10 zjAG;x8MSUK%LuZGehBN$z5JFm9aiQU9&UG-klc!ampH$aYvR2PxR|*V$|l?|?A%F_uFf5w3YM*70bR#9;&DlE$CnLtDqv(oNx>Wn^n<2g z$ZL#T_Ykt-^n3}MAS)5H07mmI2ZOG9Uo)=0!^@VGb>+=byJW%G^DEk(lhb1WJqXFU zm(_FU(y(`!`Y~XB)e>dZCz>@##If^|;j|%@jE>0H8J(bNG!A=vSANcy(;^;K?eHWl z5$XLV!qJHbw{ag@)-e){;WG^568drWeafaK+%xws5(8tRukJ9 zDW|l$yADt$V1ihVU+r0_os|%4wADA(K$!b{tGCnoFH5jaW>b&=urdH?>|0pYV2i(je&X%YsA6aweqljwcXx9Ee21!CkSDa%B;#e`Rb#sPw5-7~72@XW|bXj(NNA zU60awNYrCY_{;wQsZZ%$E^-l#0v)8tl3V-BOQ^WXA5G&BV#SRfjBUro8%ZQeH_{mJ z;_7m7D!74eKdH=a$%cgST(i?MVUR4@5maRI1|rsr@i07m7gF2t9x^hdL}&@Y`nOGP zGI*TR&bSx{Ps0-b0Hml8gO`QBL!r{{UB59`O}_nZ|eZE9T9E0CoQQ z)X3_vr0l3dQUnPZW7QBxZqV$h5;{g0nPL#jg))SAf2f4nw9`PJrgTULn5anT#@K<~ z&2uq2zGSi?3n5UU&zcn0XY3jU)BQ@PXR!YO{WTA@UIgRZd91oBhJCpBwuq50gH{^y zlG?)QGmWtMqD*&rj0n;_p>Exmd9qTDEfa}96yxS2y_IFuGT;Fiw1W}A0{;M7s@OrB z)jLYSS<$Vx4o4OiS&&{cj^H5QLen%Bl}LlbZF|6kC!fkWC#+B zX^f&`f0&tSvelb`GbqQW-10k)>9hB|r=lj-L}@(EH&e>484;>jwm{G}`g66vLCZ)sVp&B1UXj|i3oc|-Uu zLgpsf^EmkSZxhr<#FqE0rM8bWelAkcbmyH>ft2cqmZxbPzJ<<~Tn8C9GB)rXx`i2} z+!0M8+3b|W9KP)c_N=0sp>36$ADAEpE!gQzl~{i!&BSU>z!l5{rMhLv?r(c6+f?>) zH&jmL{~e`>#@(#R3{Xn+VKL7`k2^^B=b+Wk7gtV{T{Z)(0j zRm{FSw!$nxW+#ued0wTQ9>t~P-bU|N5RMJoA8BTP73X1*I0-{>5s=hz2iUvaL|bQ0 zv;n$gaEL!vtIx*=I$s7ovL~_<`L#%vyiPhddX}^%(veDPvQ5OC0C#cJ4{E-#;SxL7)oj4&@q~!Qq-cNT$L?P{%EdJ0 z#zEy7*nGQ45!bmT#zRWflAL*%F(YkP({zc941-ItkI=i*sC5YdCFx{`dz|f@Vjs>U z&%J6BU!ge48=_^xEvB98W2V<-c7eD~GKmomcMZUebX@f(k3I)=M_H_8ie)IO6j=*S zT~gXzp3Q&oEOqqQTOc8qT=fCKu#Yid=VNhQkK1^bRhuZ^wnx*@aqQPnyPZzA#WAWP zB$$>OKGG%0N~Y7xGgq>!Q7&uto>DP|6nvcWtMW{frJ6_`e*&LYK8$Gj+Qm?jm;$Q_ zd2!UmW6Qv{7tVG{F&55Jh}7{f$yT17J8TfH*zPXDip`tL19htPOXlFqkyI(W)4Mt% z%Gi+_rbeOrI|_kiQ9t$Mcv61#exuW^mPE2`n5tD7tA$=s zxai;&q;RW!Es+7Z#C_^IRdA_mds$~=FRhGqId?y3sz33pmO~yG@W_^3<`zm&09OZi z0PpATTE`Nx{{S*y;REemDfEfU4!Wmk5U9$?$PG^5G(P2Or-gDQ+(T~56F^T3cK-mF z$#!pov8VW!d3>qClOnOi4Ts-_3QRZ#IrQj;qp~z9xF}^EMizGdLf^U=v_}wCPq3 zM5F*{p2>TcM+*`#q|65)j)7iL8creY(1JNJ(?FxQA|)BiF|H6Zw(SW0Qw{crInaaj=LygSQSID|;8! zg?_AMvT=P!9YW2k2TI#lfxCnnQ<%5Xlw+o&pNiWUnEQ3SHiYXI_pBWzs_n8~q(q30 zjvt?T?blUR*E-`EitKIiVXG-uZVv+PD$BxI$jYM_V#%)rz!M0A9w)JvpPy5OHW56_ zuZ#}3=0{=vQoUw=H3J$=2!aSM03TBMzDy=^?gBb*lQXv!*KZlraNTdqr<*(VbsTxb z$~D>tIKs0ij_u0hI;}q^Admjv^{Vu-kTBy@`d}9?5cGvznw!$RL8=_o7T-(4d zciXSh@jGqqTk2RF{Z#sO;cnnTB)1I$)eLxWJQggTrIV3WCQZ-K8hM^?QBKEH<-d!z)(uvLHXiHIAo`Ag~OY^mT8a$Hr^s<~5NbBdH;A zr7fvo^G9pmx^0TdoBsf};A@UtTuJYgwB-DcdgA`*&9a`YI)p>|aA|iCxZ|qjLi;Sn zvUEnt%EK5)fbu-nwoXO)78`AuvdOc`Mb3knUrjC^C&jhi8gPt0MoC>MOLJ|PcA71^ zdW>TbEPyQ9kF9d0x%K9+jSDTbRSz_zS|gG=qAklTjNxk}ok&%s+i8nZ(v1g~K)hR?o zm>1J<;8aRJrFL0mQIsnnOz7irVB8A93Na;Qb`t{QLSO4w14~v}ol}uJpb3zaUvznZ zUy{X@2<1z4k&Glb%CI6%jjJffho@9)2Xi|f-BrxuIF{3bh}q z!n%&$4hZ4zTaCc-LIHMG2_~hsWmsr422sX+kOTrrq1mkScA!J z5y)^nk{vuYsSQhD&i)5-a~o}n(GjtnLk#1X-Oj~@0?2UPV<_5*yR7>i_B_z1TJ z)5nLBU5>MCJ*hTN1~k>9m?fCn<3JYdB!9fMt;El|!(oJBx;~tv7?vU3^^sl8k{UXb z9XRNY*Lkq1MUphKt?E}}*F|yJ8o&oY1e-_kD@Wxzs6XaNjWpfCcH5gKr&2hnHs)BX z;BkISKTWcbAh~HvUhO2Jo}HNhcR)%~k(0k~itaFPtr!yHg0pU6D)Ko{!O3RX-tMy1 zg+0K?5w5IVbQlBmt4Ccvm7Gf^?CQdv39P~-5x|W}?_3jwIC*P`5ako^Wzo^LlE5mR zU0XSh5TJ=f=H;BcgDOLMagO(L2|PQNjOJU5u8&l9tn>3X(7AIQ)?m_QAv3uBD=zce zu*X7G)r47m&XzepFAwj1VPw>-Sikh~@si54k%rkCzuJy@>V+9n0(4NMr__KsP)vjU z3RH2?NG!3sO5UOVwN}v-gk4XWZ&bdjV`*@)_o;|-qf3!hoaSv-eaj~$j;;$L^V>7j zvP6s=8!U!ZuqODBV<#YacxW_J`fpF2hR;6tC*zYpf~~m~LJVTY16#f-;!go=W9EqO zT%7ET_^`O_u73fq{JD?ioOrIy?W(UXr`&N%aozc}spfSrjut4KBWR4Q(7=#*6^!Fv zVkZF^i3bfK!Fls>96#E#Rhv4F>g6aNZNuESTB*5p!?B)<;GtU|@<#qtt*97T9g-p0 z!O{Ddk;%4`lWysllQQvMmVH3v$PA7EZQTF?Z)(XpI&A5znqw(M2ydNWk^|~kaa}f5 zD`yG$j$7L8T+-5U`b6t8%A73YK@xCu9ji)E!<|)DZQ3gZq{ml23)9tJH>W7oT|zPj zGL&PoP1(RKfYvG-aj}F)0$~kX#d;Hm5Z5Ej)TxyDo=!N}E+Q`kodfOwpK{#8uA2I8 zUfTqr>QpBn>IyLpJK~((Vf$fI!;7_A5iL;;7YwNoP=T|H`VFJ zF&b?G1h;C+E)@~7LdOOz5ihg_!rM6Zb4RAT$t*ollSa1qwvj$NS2L>9>*Ui}lS)ni z=6G;jjEm%82dBOwBg*C^k4#awx7My(q%;!lJ&U{hmIBME;=(-q0^^7t?$xzq)-N*- zjXA0|tDTiZ`Y|wtpAn9NUM#3==D;^F7iRMx&>%Mr&%@Xk1cJnkLuuZ)=~B-|w$Zbd zkCBRCE*e=8ILbPL2!1`!s=zEfIEB^gasL2Mf_Q-m4-Fu+EWpdIxLGR6jK<*3NC)_b zBxxY5-xCx!$dLd`1121R;#7#*O9;r2H!~lFzApu{gH?jdiHw05_PHSF@GQFVuH%~t zMkI-J{{T!M<}uPLae(ayosE`7_Q;3g#E+?MQIm!PWa7fo#0x(VJ*ReyB|Q^5cEYPG zHOKK64k9>_uBtETS4R0&jQJ1%6DANo3`*5hokk0!=7HEAD0*z zJ8ou~2ZA1>n>flc=@(ZL7|vdp5eJ&ky04e%HRDVVot^%%0z-9h188#Xs>NSc>NSH* zrRoRTcL$e%F1pT~8aZiJ#eB^9714dtHq<6z0qfme*~vclC) zzfp`MD3DKc^VLWb7Kqv9asdZI3+R}c;%*#1os-mP7f}-%Ap>LT@-aNty&{}@q-d*T z4($ME;sfc}v(BujTN@+fU^d8y4m4QdJeup22!xE;%bo%Id{%9BTpKq^J1t2RrWJgq zM13*mm0N*lBpDZ2fZIgl7=R_TZQ;!D3nu%-B~Stpkq!eF+rf!d83wq@29sczX=(l-#mo%4?mF)n!`vcg(kBTL2;w}F$Y?(}JJ z8*d$Wu2Y^~_1>4u&tb@fY=mR9M|6h0zEGA`(x(C;QG|?0i3UU33d*%ohid#ywQ0h( z@J9!9j*Dle#wRWtfjx}9jOZ(RS`1Iu7Z8niGNCE?ZI%}%QjgDyYi%jA_mF3~N z)mNgbZIRacp(75dHk+3ifG=J|dZuE!dSemUZh79Q9Wn&mdX2`%Z5h5Z(X{eIeg|yEiZDdFh zh+!ec$y_gxtdjGVy8dJ7@p82~rE$fk5~z@6A`2Jg-n6&s+$qF8rnsRA7#-0j zG8`le6}^en?S%H^lRD+XZRU@Ua`WFcd;DZ6j#c2k=k6iG>8Qi0@1QH4QIL#eBpen6 z!t%Xt`(e~_XpD%eQH*6-)(m0^V0%_xoBM_oSLtLzafpy2RgoYEX4z`T#bC{r2UE|! zb=oT=0}CwO7|003HzqEtX{84`r&i@)bE*OZ%cq8nwlVh3!5Iey^y0Q<(X42|T}0SB z?vN!%>Gn~!*$UWxDA~B{r@}(RtIX)GvP2wQay}8EF3Iw=5vJkcBlZcfWSyHjm1Q`B(2?Vc4-b$Vw`mOzcUbyH1J4;sd{NFQrOb~beUD%irBD;l(b z{<2pF$aPOj6pQN+hym+>at4~0b*^xx3)yJbnH>CgEvKi^MElvz*H`XtHQbJ-S6Jy) z{{WGV8{{}h!Z3!O&C5o8doEHZ)1*g`*mU;pb&4G}ks&K?l%f2P#*_clX2THTgQ5Vm4tpSpGUHGNB;mZS!L2C z#<`iq>Lkqm!pfhe-Z|;u589%x{{Rer1$OrQaQKw<`gi{T!Ds#(r}l!Ue@*9wcs%yp zPuB&rhl@I-Uud5xm^`)q^3}=ec~Bq&4{`g}>bEdfvQ7U0iZN?#;_Ft@{{SWo`bMHv zWWbJF>pxfug!FO?PbxCx;;8_8z>r-G8BZue-%1Wa24p|NS)Ff;Sv!c=;>hJ-?7!_5 zhl)@Bg>PLpJ+RLNW@Wpos*GPl`F>BzPsTF_9uBG34Nd6}bd+J}BGd zM3*)|^#N@bKu_Xk3AS8Vw`&zy0o$UInWUQHPQ9p1k7BPK6-k&s4&mI+6;(mmnX0nd zWxBE(u!kD2LDIi)TTMQM<6D!Y<>hY>7n{^*TrJ#8{Ca?nMhzJe{xS?_@8EsQNwvld zTB)q0&y$ySO}?cfGB3Jr01e<)Q!Vw8A*TWtCfSQf&@lKw(V1;kmA5yWa45Z$PdBKLEh3X492^-@lo=5q;IOP zRB+I@)}mP1wT&p0rOXG@#1+chmU%)tS8l7DGTYQTg{*yf@MGp7Xv$d=lg!F)c#`^d zxml-HOlKH+QZ6|1A3})6!zo17D46m7r{c4&*|@iRH=?;$%iM^OA;Sg-Xze}f8RNx{ zjG`pZaS$vHp_uyunx-r=5sZn^!p=CssYJp~7>?jV$6%aCvyW=a7>JSp@dOQItI61md-Nr0F_3NT> zCy#Q;IQtDq&Qgo8a<&8@1bmT^ExF0#>?%G=lP$5yCPVOoA4Tc#`hEw<(<`eImfdF{ z=0z_lN+lYzgU{2Ak8PVI)s8!&PM_jk>{|X_nAJ_ulwt`6QF2_r;VWeHuATP8dY!K7l|_^O8Tfmj~J%BY`K0o)_6i<<6Z) z@JcQADfQ(yF{5yDJkp3_do@@ONY32nHT$Fo+@ti)p&y4N9bZxXNdiskvPE^qGOk;I zZ8o3$%LKZvBngF*Fq69&pRsCB7pn)`Y$fhmr=)cZkIW_xOC4DK2;?Y7S{+xXnaA@- z5a5HzzhdhiuYu@`_ZxjhnZV#fp;3 zl9RJ%Sn2y^vdz-uHl0>lvg0ZoLS@Gm?(tk`^lquS_SO$!ky}*a^;ks4MyP?K0RI45 zL1f*<%mi)pq$S7(Z+R*Pe2wH9h+@HrSYd^m1Grfjk~;#a!^|>sbO7R52Y+Nz9s*V- z?DK>R7#C;aKJ}s0ac;_xx#JNC@ON?Xb}M=}0tB)_@kr1Vsbv zW$D@EiuBtfA4Wj12P4Dc3vUe93~*&Fu4K{S&#`LVb8RjkmVljmSySdKGBJ_!!T0L= z1s$7SR8_XB{ML!Y-RGbke1~&|Po2!%Z1v_OLpYOr*1U8qG}FaS*>Eu@nwX3Xhc7VcE2igaCHq(}faL>J&fn4;j#tZt%c-vbe2>8Wj2TKc)J zTZBBX<^Y}^<KP)uFOb-Lcj#kT6sxBbJ6cL<3~ZyzMN__AYlit=V7NS~OI zxWw(#+PORCA8BDBs2s zlOjYx>}f+)UyzvARqM&661E}L`dzFckza15kJ7h2Y*_)eLSNY;U+$|hz>gO*na8UH z*K}nlNrt2rW!Bw{00JHXf~~g0eyBt!7!e{wPN#v?ES6T) zdcqT&w{0}W2XF)NIcT@FcPOGhj0yC8CfzT@>h8Je^~`)82NzN z5s!R!L>O(1nq~eNku5!|(t2fx2StK?OCA7&RpgB{rf2B^M|&)dExNqVK56RK4RG&2 z+A;fNwsE?5Q;nNrT&b#h**KUMEvJ;o61pd>;e-BN{@P|g@~Az30w9AM2YHN#))d!r zx^0?2r*(-BFo*Z15B^fAm5-T+RBg(e&?G%iLAwk)Vpd>a&?CY$rCNk;kgbAn{9nK+cP_5 z8M$%&0_?^VGqL{w5Ljo8)1*nARqi}Yq(1c%D_9)-dOCcvDKNa5dXU;XPT{8o?foeK z01^5Zn6DS6!f~<@tWE?THe_I0EDKchl!moUx(gd7@ZgsDC7B$6318L z%~}+6sAw_32e3EVxG?LNN_JK&i`EOBb=sn?w~=f_#c&>I#f$S?8n+aWN_2TPdR!w4 z^59_RFQf5Yb7AGDy>M`@+hiwffB^ZA02fByrH2}&WNnR`Lzu=uofB@)7{G3&gzz?v zq-&z7zFmXdxr49ZPEd?Yp3W{CZ;IR#jI|v2E$V#EdTP|cl2_Ttk8)R$WUhysmE->uu&w@_f7VvoeLR;mpU9BsynU;Gr?<`5$bGE5?={|!YRcWu>^l7lb3H}u z(teqD@zHhqkv@oSE#fvC6zW@(uGGri=bLxv{HyIe_g0WL z!MW}~O3{a^UNn*{AnjrT+UhfbvM~kr83GQPy4IrlsIXgU+X%bMj*Eqtob=Q7&Ca9i z`tp>|3^A% zt9`4uWI|WygeEbh6~#%1yOnZ_`(QH)|lKmf1|7QDL8Q+63P{{W1xnqOhbUacpI>pdQ; zWaB)D*>Q_&DaI~8gtEnM2T+}olq0qTykcYt1F_eYpI7QG-#2gg%Jwu}kuTe$Cwz_m zgr&dnI0cK99Hy5uN!LrV`gj`Oqh5T{teZK@ba{Igdb(aqZLx$U#mkpoC)et-Vs5;+ z;%6_tbw6#-GJQ%W*;rsBA?KNH9qQVSGeVb5#58)kFfE{yQ4Y=KKXTagGKd<)OcFbr z+_U3uFeK_+vd+hv=cSz;(axS8Rj}J^;x*JnI6n3vaxVS5P_jl=LNapWaSPPP;sLmo z^WL?vb*$&t^QFaF&odUXm4V%>rNXd8vMQ7Ek#HN_t}t=pNEhP`M+5%=*1VR;81NdN z36mdB(=dS6OmtPQ(ktSyc(^^3`gpDzr)5yCI+;ou4{4Iuw!AsS#Pu3s-wmLT{7OG^ zkNyb8I_p-PUtY!I1z841@~8d-_bXb;nSp)Bsi>GeaK`CO1iGDaZX}qJewCXa1E=H@ z3~bS1j^fHKO(_uZ(m)yM%?Hc7qUFRYuplbk1!L{}NWVK;TmX>YAMh4b) zFvGl68JUfsnH>rDEm_qpsG1_eQ_vH^Z*|Kmo@8ZIgrn(=VnNl470+yp4WNS|q=zj# z*E>BYr*5gJ?~|2hNFm|?JLFF7lVPidiz%a~&l2d}xV%Byc1SVcIIh+=P5Ch#R5 z!y?=4GmTLaCB*iQXUiC^VG=}|vC@$yJ?j{A#aLg7V~AY?AkPZ0D%?B=4)M-_|k?>Am1#xe2{-^pfvz_4PEB)nFAH~>-RRgo^| z>rmmf*Reo7qlXnbuq~nhOK?zh9tBvD*r5RoPZSOco?H}b*r7w6RK>yYPzslGrz9XR zs*YXAWLZ?m`xFL;R4yuh)AlZHoEJx_aIf0A?D`>1eei+v7Jdg-`W53i9Rz_sD;3fF z(%LZOrlGnrB)^+S*NE>yIGub?`grO%$z}|-@M0rCXvg9|glogOY@J64j|hOajIyV1 z@eg`DKZtwK$NDf*#Ryc&AH)=HhibTf(i9QifUfvG;F_aO&^{VC6hWGPcH1GIn|yAaBe&Bck<$bX7IdaisoU}VG=Cbb*RWBYX+q0F+L?_4CVOM1yPKwlG*-PWX~N zSUBc<%Qo0q$;!+#{nLlDHRVa$M>|}JaZOxRvXy{wgzimjoZ=HS7 zl#lzuOIFU0(Hxrkg|Qrl!7u!aO5VSU0Tp3D9!7J2pbxcZm4+;3+{+)#y&i_pA`6*F6D~X!fhJ}`d(22;L1}Vr zORSX0(|j$2rzploQiRB2U1{)6TJBMLfClVB5%P%DE5dg-x9c279S*fx)Sfc9hHBHQA zMU&kfI|2$+FM2tg34q6qi zF{UFCD<(`ykqlV71|(S}j;rz`R4`?;)Pde1yYmR};qSmRaMa1G zt5IT{b{kgX$4V_!0y2fGzleA7YkiVS^P`c*8mQxIJF@$7SXlBeanb6JR`FRi7^ps99|` z<76768X;VpncL<*)wpvp=U=H@gyP3o>!?(_BvdOLNv;xL#J1N`Y}Qy75_spx(W|EQ zI?J57k-&mf7LBjukaOmU7{t5A_ul;$a&-OCr+;cJaO^bGx?%vyW-EGLesz zXyEYiQcPHK*H!4P>uHQ&^WSdNgy$45%bE1$8-(XmmZ}?2_D&2WxwMt7{rn)eY83q7T@#?98g8or#@JWd8s+7F6W{q@oX|KpL4pHMeAT z##8LmFl718P z{cBUE&T@g=bd-#NJ^C`;iy1^X)V91A2aCY;*9D1}8rca5zdqHz^3`*y^2i=9ElTP< zeGS}RZ2rZ^a;|XiI;&u?;|8`xu&$#!1hxV`2i?Rs1(nI;;<0rbVH`@x9Ap7+?G7tv zB5;X-0sNuaV@0v-kD0AY3_1LaM4n8wGctr@Dqv3j1dSWDirdV}6dBqF4}|h^xF+bC z2^n!Zp4HQ9X-jtDku}2l($F@F0gu#ItJsauK4mH zi!$qIcr=c6m5MuXkg<#o;CM2&$?h?4Gw2sLrrslV&E8Y5wkxMA>lp@Q`COL+wRx?! zm7;k3k3zF*QsMI{5h4j~BgIL0fLlYLD;#eTjgcUY;i7qQLtqe$3+{K&=0O5P;T zdx4-6Vhi^0QR$Or-UZ0xrkb5pO4!R}fFMKwcn4)~SPPB24$|&9jTc0cGdB4V2=S01 zhNb)XD&|gg;>yU+)02S`H9SB+T9~kbi4J7N$8x7v8*nLTfF)gX-5z9^gY>BAnrv!@ zk+fsBQGp}O+W!D(u1{Zh#;70SBz7}#T`AisN)y^Vf+3$nwP^Kyr>4&90465Ak|4YR zkp-5iML2LXYdFV=!*I;8%5$&G+u_;xhlgsZ$gh$20;A?DJU4%YuE#dZjEabHFx`Z! ziz7W^M2Ed=y+=_|lj{2wqc+kt%h?uH!7(=LzRKh!8EOLu*GKPN3Oc4N+-6r~O!(PJ ziEr*meXEVW&Q#64h-Y^yWZoe~WVo*o)c*j%=taE{jWcbFYSN_ri*g-16K}6PpHhvx zfRs%@jv>!t@92u{E39?eJ5G%> zX8OW%oMnn4eJUx2*oWuDx$jxbOY2;R_g2l6`nc7`&7#l)$^%aQugP)k%Ji>?A3YqoLdzqeV;XOaoEkqgk8Z8{H0g&WZ)1V#42LlJd8n?T;?k-L`fn{H0@=8 zb!vnXc{{Tv)qZ20FJ>hINSc4PgKZ&FE z1*!9ZN>!P_%CPeii1Lo!3mVqERncmjSZQUwFi6^Cr+`x`T%97vh+xHI88yg6{!F+0 z8V@&yx~!r@oiO$|`&RX?b{bzQnCzxarym)8>gKbzM}HEen1ATEgygYVCI(TA!**Ez z09;mTxBv@@S-%x=gWB@^r^5mympH0=@} zVzCiFj=vqv^{Lde`l`#*qWWx&Yd6_sWo#ok07nu&#g;Tg#1UL0?f(GjSw(w@)JeGs zg0n`MY#ayog0FLNEHwNK=-cBz6Hw#FvLo9sxduB8HvKDT)O%n&WE0ZG=-d21CTaYYCqf_l zvHO(|O~gEF9>6~3xn-1B13NBg$B&h9GWwnNOZ`0MTq1m=Fp=>^eamHeG^tO+@-yZ1 z>^kwSoN61R7l|5c>|9YsOn^1kChw#Gra)Q0WC_#3Zv57D@s&o`i3Cq@4Bh9DTJtqV zJ27NEU|-e`0((St;I~(0>UxE$n)2W!)AQj8PE`oTBXo(E2=LS=! zEcmQ|ftOr9mKcNuNg`{J&R9m80%SQiS(|Ovd*7G!1&|1 zKVe&5oHv~{Wv$Yu8FcJfKy3!7kq`g_PE5Anzh4FCvQp1g8`8T`LrhzO;dHp$KBczF zPF0A{$Vnr+itl={;AB0aYSDv%k9V&~jBR2cbht~7gq4jol<>&uHiogy86}NbF>u=H zqKh#qBUpo|;sp@*1to#Vl$-ZKK>4y|`HBxt*hQfphFzQYV8f zHcDQcgK|9A3s#!4H&JWrt02>4LaVv5_0q)gbCW@tSxsIE#YI$Dc|QGl765-0RJIKAc|$ zj&A^)nGLUBfs)Z@rXy1sjvKTEa|SkjIdjSs!e(HCH|eWz)46sSI9#kt^5xa9rpd~9 z(Tt1%W&{}fS1PYD+Rw|DAKbKZGsLyTcK#WUwP;{bm4lIe(#NuXwxS`1-!YeOdhcuR zT&|PUGO}`VD#&6g_y~^Q#VGxYvRIrh@WpW>FEZQluB2ky6J!iQA;X^qgQeVXF=bWM z+5TaWF@a_++u*x5%5~Ez#DO3*GU4siD&yNLY29H4PDhvF`&Eog^6xmEUn&`R!n)%) z&NCD9#KbTK^;xmHhCO(c)oh5e5R7N#Cr~w8`T0iCZV%zD{pXqf=cbS?*>#ORsbvv-|YK^y1 z>PAxAz|&1ArwEysnmM{RQ(qa6VcuKG$P6?$?qi1-YjM_Q50jXBw3>qKH_9fC*j z?c^5Rvg?iU#&hb*CSWs$cGRs>X|n4h=?(B88OQ^<$0cB&@M>W73aXYzt#8L+Fft_R z%e8A{bxJDgR%C%IV=Chqhz@?$y-l#Ud(t2xc8vzT`UOv%2P$}?u^7T2?|pUT%&cTd z&%EU1^;3~;c4z4c=KsCrcR+ zoGYA;)5ctN>>#P9lVx}2k&Lu>YuO+ZXowq}1HwbQxUG@UZ>`U(yb-{LZv7THbX@KN z{RaSInmb2Vu)`)EZrw-qg_b9DLrI_9>VHVj;R^te3|;l`(PSMHs|dyHixK6B1^$H{ zBZ*exd<;@N1csxZf3ZXjf#fxAAnWz4 znQ32SPP}6tO6lw)-NI-3{*lJ@C$)?ie-RDzsIC`Trp>YbI~pw%!mkSKDdc>ub?w~I0mqkz6}}!4a?Q{AY4JSY%>f|!GLaf_bz^;I zPG>opabQo>7p2FQHgu_G3Mz$>iIWKVK^zp%Dsbrk05t&!-LMcuLjvu=8~Y@xTrO>| zf#++q?*zuu90GP9ho2D3p$wjgYMf2BMn~b4;CIKtysNL3XQ|R`l-pX~Ogoo}x_M9p z(9?%42$1(^uBM)tPrNECC3$PH9m=cd`+qY!%L}{CA|wvaEEM`(4yy&DJU1wkK9jY1 z*x!{eEUO{^01zBK>egdlaB>JBY1p;Yd}~vir7??K_LcQ)5nN$jMNo4kf z+e!5)00%#s312@LT|n@xw0VV)VRaHn^ot(QuCMV%V}!HWPxl{A6q_MzEF**f^ZhG& zx%!pHd<3;`9|iO8(z=7Znjyy$2Q}@nYyyho&LdOalZ4^mMD3~}#5ak$3ld4-ToqxL zRJ8RXdwj$1T83edHLE;4e2v1|qb;!}C4gh>QL||&I;M5FYa=G&HwAA@PhF9aJ_UJc z^vX-^j?vws=jY>y*=kGSv~V*~#Hhy~*^dUV7F(qch<&8nm@u8zB-FYQ78(4}`1 z%+7w+b^%w-}x-P?Ot8>ukMm^^@G z&e=+5qM4-}4B5BSJbNpmMn+!?hWD;F%VcrWUiGpG)krPP`<7E_7Ta~RmnOK!$Q;09 zrmJ?0&ZSWR-0%bvIFsD5xhkaUnweJJR#I)}XXRyq7)#QJ@Pc(c>n}{k>NMt3Zm^2w zD3;tvI<9y+uWyf3JqpWeV;`v#nM})~tXXq9m(C`M2O9T<;%@a#vuJ4Ni|QZ2dRbQ! z;G@!r95`|-qqLl~Lw277${aiPm)e(KMh!Z|&hj34ig3y*ouozAPQ6o_Q0CMjB<^W(uQ6G*VcrPpo z;&cx9bLDl?zEF&8lw%nXPYCei;;ZvgIuH-NY6n&XyL~GS9TX)7{JNF$ zsx*-ukODCPYQw!^8CD+0TCRC}m9^Kinvpq`ILO%fs{!MuYRg|#%C56bQx(EQnFz)_ zWyGy6@{UfN3aLPDb2dg@ftc`6axEm6`WCp;hY+Sj_Ip%;CV1po@ov;}SqD(diwNB~ zjw}fQY64sZZU_k5T%>0xiAqG7NPy&?YD^+O3_2$t4a57;H%x)!9j7a5zPShHzg7$hXin-pY|b7LDRa;lyv%lh_hqDLD;-_eG23E zOaB1+{trL?mB#6_kv7xJfjEQP9zR0GbR|Wyi3|yNlfjRA-KAAE;&QAzwQe}0&{zAK zQj|fUL`b&n&d=;vZ#(au#6%k|E9h-c;R8tK}%Z_T`7!t6>0Oozt;NF%%e+}0?q zX~f;#DGm*@D1pP=8CT)~%Hp5g8ScXh1GtMH>qzz9q)+`FU_Tv`{{V?=rG9JTWp8b~ zllUFjS5yw*8e9nq#%0($b}8!DLmLJ*;KsW$44@1?JtgrSuDGMA^_fdrpaK5?+8=2} zw&O9?thJ@#*meVuSQQeiNNOB+>bYO2b#udL9Q=@79*v!Gx__z80;iGVb^x?qlp;kVqISB7(}$;AO|=RWM9CvmhVl?$Hc^i5}afrWL=C- zvL~CZkC?)~M`VMxfa$Mt?wwx~%@?jnoqja1H?BW^sb zi2`imB#tf_Y+Hv3z`vWv%oeopWn6zMQmwf}xc>kQv`19J+Gy^%6QRjq$e1+D+_oJ?qcTngd*`m^iRKH|I;bg3BK&&x6#nmj!&y;~Q zOb)^Taoput+gPm3x47_c19HqvEYT`#m#82?B0HiVUOXQ~rJ2)uW(;9;D4eAy54ijw zd7O8vQGR7+Ml@JAmn6y~?NrK^MnnW}rdvah(}?1@L)F-Kis@Wl1m|NKjbk4i&m7eq zBgQ=RD-IKkextfpf76zQnzGp4)Md6_oU{XA^#c$z{uQw(SaEV7W$I4ao&(fduY4_tt{n)4^+r-YxIJq`X&E}Bs-w~?^)wNZlts4$BY@}|MKJT{a%@`IqCiNOs)raAF1JF2oGiPlU?X(jIV&l1aexD#E zPj+N~-7DG5{#&fPVBK(1VZ*D-Fi&TS&*|%?s@UHQhkDK=tOHxp)%kcS^oIvN<*-VJ z(MSflr~{!X8o!7Z@LLYb9asi0Om-GQOf@7I&ysPqT${VO1nvz|!^ zNrcin6nwio#4^lDP)TJW7EU)=uViUG^7|Jw0?Vt{50aPHS$#orgoj~JsMfX;^`ybU z1S{dwJ7XaopvGE>NJQ}LlC8NE*KyqvL5nzUC0Z8Oag?ie&>h|yrJPntMXK!`Y+Ou% zOA5&#o((UktC-3v@)vL#OcO&_nDP7vU@HcI% zEU_~sv94x*uT@mp7*PQMw8*lWGJkHuhzyL3c-ypkk)4qQOPC?nzE*ZN zZb__qk&qIMXAv33LHM)3g5qa>*tg~lzc*ANDI295V<>6;rXh6Hv2{ww=BnWYI=((@gDg*4fzNv`th zqHx-M$0m-Ci3wLLXoZ2s=)AU(A=?rcMmRktv*o~`7?%X^I*xkDZ_&`Krcs#@n2x(Q z9k1cEEzwp)A~*U~$=%^r{X6$AH`w2^!tRUWc3ayCdUZd6$e6@)D>11(vXn{s%GA1e zl7B}V9#RA}Od2iEfeX23OqG)}l+M`_Od%M>-`=@j_N9=#-A+KL66PkOGpXcvELYm^ zr;Xd?$kX>bABQ~6YjL`7%hhkm?z&`)VO6t^&hfi)i#m5rc!+$+ch)Am0PzyqSD~+@ z;0Xy3O zc{-a>hU1>qfxw?c>X9rcHWA0uoG0~7*1fnHbnA9CwT{_BKzrGDvbru!w@WVS_gWd` z3liKoXtky5%5e0qoOlx&u0uFnFzd&6B(Smk!F0In}m9xBRw7tCY3gimh%*_cMfI z4;~&04KQNhF&&J(26Y`KsWn(8$Q^fAY^rWwi*nV-#OX2z(isq!;g0A^58mJvmg`@x zrqZ>(iyYZbb^*R-Vmbg$gTZ8?0_!7J@sSrjr-H~aYVA66R<5{JWGr?NoMcJvA#i)O zcuSMA+p@+H(yyZD9X88ufB0khI-7h@1Oz|gDmPzDP-~A2ha&e+39E)X;N%C3g>|xEA z@L8jqiz!Za+GI|Vl)*A)+ZNgzMa$%aS5-YNwa147TDr60#OqfKY?@5rw&(EEsKb2<`FU;8Z7?+rdnb~%Sk^BwvgI(FnSw5&U>3`(Cv(J zC-o|z05FkmJQluJP?gQ;2|#czM?VFYT@x(%pkG#*lB4ZeQjR^##jTjdMDPtvhoIZDg0g+Kzp4kQ0W$`Vd@u zU1Z4C5+D~LqEFDZ1rcYQo472(z#<|E5gY;I^rgMV5fWv4UpA!lo4C}|MG@b0L2reL zFYHk)sDeO?#rWtS-n5OOx%*Ulnt=RQp4*n-p!8OiS-CgHWnx3iCJm;GG`h8X1yU`m zIQmyD!n3*4MVEUURjIR;)kU?M{TW5r{9nR%E&MC9b1jCNKf2dD$1rR$@aMR#_P|wva(?&AP1v zkCp)6g?RQ8{{WSG%6f-R$;qk|kZzNhycp@J)t6Ey+`Fo%T3S5Tv()t`_8tll$_@q& zLH_Cg0EJ_e?19-VYhxA6f zsyaC;+)ZT%j>NjiWY$Fn4=yq~4ctoW*?m>S%0veIPFvp0y7R-w-7K8TOrae~nGplr zxw#*>x_k_%rwn9JbR>}xA^nmLid$`9?BLaREtOWJX-hvAI*EdKFyqI(DLq#dFx|P2 z5)a(D-9p?eY>e>emM~+!z1yPvw)QP(iEE>-aU4#m&j>s4&8l(T1EJyfDLHU`4zIpDn?on;svBk5N4t&E;Cm>ix&rm8IFMw`wN2Vmww`}k@WD|4X;nrBeRtVC>y zkr5r9X!agUnY{5j%e1HkPO@27;%V%+oxTS7>e&ZtQ%h(|83W!1?G{O9*O7}lY>v>; z_{ck&To@C;E@tF^=n?yd)M;3edwQmxt2j5PJvyoGzOP;kS`Z)?<-~xn-5gsVYd}g) zBg{qq<*&GwQ@fc9o7{OqIxV%Fv!g7GJA^5#98)U8N^U&ohXs!ArB6zTvRJUm^@y$_ zMYf(V@Rg)wPKqbCa1gW$051@E9_4Gf=1$3Y8Me~X6>;8l#9{{S1ZoF{t6cRdb!u(4 z+M!pa`Fm^NSi6^0j!>3_F_3K*okR5eYb%tEvYfXP%+Jf{T`Ov|(s;fHJJqI}SS}}9 z(-ABiwQb^FD5A*$xS2b4uv}R6It&cAGipKRdzMT-uT06V#)jhJPon9zqSDSgx2vot znZ1XZla-rgk+amYF(AeyvCkKKvW$qz6C}zplWP3@ z7esu|n84UXOA-r>J0FVWy&Cdw4hL^;-%mB46IZ6n`ub&bP9{1RjDZDlceWLKw0xu8 zu8Yz~BPVYC^4iYi4hV9skQ=th9vh*=tV)*g;B?aBBD{F>HYm$BHEbA-TevO+r~b33 zX!~gUmv)O~DK^-|@o);%owZ6+GR3Aw!M1W{G98CS6gK7aSrZ!Mxi^W*$GSDu(<8K> zQR5S)hj$g)zakj8_1!pno~_ZOhi$PsZd&bt^*$6oBU<)IjlLWfzgV0vqMB%`V%c32 z7z4T6umV>{RVm=*{pK|`n#Dr<5z#l*JglrpK*m^N1lUW7iFa~ZGLSja)ae4+pW$_j z2I{BPbERzTc?f_a9mDfmCmR-5O5u?xZ;sL>9vU`KCDW@+S&SfO}xI3mu zvm!B>BJ^YWq&gscM-hd z-L^iAqS4fD+!`HU1#@|r>IV)wtaM+MLB6-Cr`(O4T#sKe+*!kDtBssr z*tfD`aj~UrN2p;P#vz7*ar%9;hDTGjKF>SSHkJDeg^EqF)?#Jm6>O%22$iz;d2r=E-R~Q2$rygZ`mUybeu8A@?t)PwFzznhZ?a2F>t)CS=qG4Fsk{+ z5Dge|1HqO-)jEtpKEEAEARn=FcEz|lS8b}LIUa*N$YR=KBN7IZiDEsWdCYv=yBte8 z%320t$EmoF!mn>v&$hW$WT7ZbG`y|#E_A7h90+c1{r!s&=4X+tRbw}a?HaLW-~cj? zx+IK9Z6Av+1bXI3^GcWy12~cUQ}xp#46(-Oog^eUdzVsXFO0j3fkNSQ5{)6RBA*$ij^5hM?|g5;P)N&PG3AjXwp*ed$n62yX=P-Y?S}{h<$&NkdrIdMY%@nz(ylQK_82`vjM~Q11;BZs z&HG1k#e=*o2h?ySKf^s_ zTyy5S)#8)R4?WV-+UAYixxiysY|;Qck8`YDK6OhJ8i+A6JUmuRy2ZGb*6b$}2l3HU zr{NqbWkie2N>A-8Usr*L>0RYIoT>C6Ep4x+?hea60uZ9H9U>wbhbCH`a~8~;XxCW) ziSBdV5PBux=FkGN;q?qkF(|Ivfv|uW9oqM@O2-iMt{fTC9}sPIR&Xof zz^pj-(~w@7iHFsS%6!n%P!AA{wKeAebE9uRPj9iwSR*gZxD)t?)UsK>^$_p3(bKWr zX;NhyvW&Uf&*C2+(qzY!VZDzTKWf)9hs{o4`Iv6)WfYMZ$+USAut>H=lI;Uk zq;q_G&4D=CmrL=BknH{Iun5pROK{hc^YqC4?Idb-9m~)UP=UHIJ^ujEBPz_ZktfiC z7_s227;)FZP;oKzVtY=Ss!Zy`7?Il&KJ^M}^=kb}>(I!8PJz}wjSaH65O~G(;l*wX zsflA;gocC(0^aGX3o37h;p~^#>LrYp&Qe^AmP9v~RZ|$2moEiK-9)5fCop>?2>$?j zD%<0)=6k?Yl0*ar%twZtbX^*ROlxrL>z*uchQLVeAJ(^VZl9;Ekp;A2{c1sE$!tfb zZh$;bF6GI@sDGel*L1titEoB0Q6^U~=D>eiz%9gx08T_lhS;4Sv&y5YlFNwiH1c|U zslvWfCPvF*K;qi+WtuqNu0~x$LnuZd@Z<{Fo%IO2*(j64U4Dg+=BhGi)j2t5fBvLZ zQX6e{=kqnK!}M~oD}*H|S5<>};N)C!tLoB^rzta?B z2wrDk+$B)1LLl)2ReSLTme)F-5w5w`#wGmWoGm;asdj$p*JPsz7CWK?>B~~9)H=j) z^I?yCtn}ppBP!d8&<(8r07|for<+b=KMW3z9bSz!LU1#kK!G-HJXkGJfhuEM_ex%b z=V%f!0(-ehyB1i=t0HWLwutTAT=Hk^T>k)=>6tr)dDe}f%16zh98R%FLU77c`~Lvy zZo{c$kVI>#%keVis&aB;VV1~^%;pEPLB(?+XH1)#y3h^$O{e&R$SZ`8<@Ks$PM&X1 zU!*NTHF8GH7tx6c!XnTlNg!-Id&-F9)m1)~HCyPHc>0i!9xtI-K1U*74^hUM{_oi& z9UrJ}Gllr_lz*@;*yJ9Ko6T z72BoCQ68jR@$-T9q&Y08?K1wYj6NvLYy2SnQdW;x#S$8kyF6n)f%#j;)VA`P#(V2)rku4t65*I(IUyE&05tt{C)M}z#E|{Ool*C(!?^Tyh$Bf}z zuQ8O%M9J8m%@$o{LAu@?@;JQ?BEF@*;wyt6G>Z>;UeMh{p$*nR2J)T5QSB?0XGoMH zNtJWk?`5s>0lo+hz`<6V%)*M=&V5HyEaS8Kk!>~HKJ|{VT%T;?F9`y!fGKr)RTpvuGf5f<*UJ{0Vk(7ky8RZbb>Dj9F1I)y+Zbf;%y2Ghj;h$-55WHI( zma}QR4`$_3YYcHT2V$4kMO{>u;JpR`DyBtS#Y1->DkZ31^Ab;qLG6T2o(sXTUgM_B zO9L8Qe1Lt3v+yPTJ|@pD;-Xv$Q_E1REddO+j!eh6f*m(@Cnrz<=c3UJz;IRrwLm%# zwM#JVSu}C~0CYF$p|zK(AGLW{{Y%w;=MRGzjZdi_0eOKI;x$>c;9aHa{J;(cS~?!| z1Ck!@3LQyP9v{BC+KB)4eM z32jd$L2WmjU{99GJz6LEBqD3{w;e^y zS~;E*sY$wyOhM3CSOtbQ5_q3#$<>&3T(Cd&PuR8NFX_c|YZJ8}m3J9lPoYvJAOq{z zs82ts9I$1WHzoHKE>F^^8&X}$I36m%lHl>|C}kr1d1|OnVgm8*(IAprbca2ARD&)r z;Hk$@kJ?fTCd@N0tode3cCE^~u^;9~z1Jv6apm@@e}7`C&1B5F)#i@q%f`G0A;b6m z99HHXbeTxjK_VoU?H$E=V0G_VVV9L#m|HlR1|%Ljt#uq?jC4|Rnd)~E!N!~v6R#3` z%JSzoFrV}1?<&=GEXuTv#zXjKHwDfgGp-z$!1T`prl;;r;Eh9j7b32?0sykIJ4{ZV z<+(;~HRJN^21FkzEMLH^;#fxGgQqU;E1D!>Yt~qocI&sy%A;fmm(50O zHE6L8y@Z%o$AaN}%>Mu!V?Pk4%-V>IeG%_b-nbfDc7~4jFT*a^S;FFBW@E+@-75!V zK`a(=n~pUm*?UjkwD&8`6Q^!W>KFcjAGvaleko>*&ps#bRF-Zq{{WPA?K=HyJzDcc z>06UK1gerdKwN*DqF?g9lpR8->*z#s*ZqmrsOejg*ET?LkoJ(OZK!@Y`@-X%pd5ir zU&s2DhftV~wa4sJs*BOLBdhZm@&5ps7V$%ujz8uPr|w*dbt|Magkn4uPJTl-lzR%D zR9=faA2UheC+`5Cu|dhJwEqBv{mYT_*<1ktcI^tX&dDT$H}7DlSq_Ucb*rH`(jx8* zj-ZY#xglrDzRNammgaseum!r>RG77NxHYn3C21#8%J^bCB;UoW_$uUSVdbK>AOJ`X z(5C00ZGPv(*nL=W{j|G$Fa#+LFnS(JKi3D~r zTEgjin{<%S2@dzSGq{3W#d=_8U zeKEQ_EtcZ(^Fu<+cyU~F83ukDsAezRwzuw-Nr$mU<_}MY)W%(Lm#4Nw6DR@;9?o1; z$Eo883EL?`1;ffDYV5qgEPET6IxBjzZKhH&{ud;BirV)sGQ{Tns+=ANsrmdL{dg;U z?iUYOdym|_z{rDnfeoM_y4tQ)kR`bA*Fv#xb-I+LPZn;z(`%jl5#0IgA^!j{pYD|h zs9~SReUU4Y^>UH=e&tg---|aT$@|Ujdp{rfHoczL{{XZ4@H*eAVh(dZ-Tt+V=i-?= zOpoE)AFbCg&c1K`J*X_K4g~)IPZgi$?|wa`X?s1d!+(j?{X-ahF|d0_-lO$ehv1nV zh}+I`_i?>B$^v|bal$(;oC3jSnTU2F(gnl?Im;l zXtQl3cc1E1ov<<5W9961KbLt^yu2&_00sUg+W!FC`TqdPvo5zHBn=g_Y6&E1^e$|= zu1`_CW8+g*WseOBBl(JI5IilXth?y=g(-ogULa$+zK0C!@nV9x|sQxq5$q?gwvb>ByLaC=JE5 z95q)VRB>Grg%c+mTT|5u9(G+Ds7}e)L>|IqUQS%=*sUIi)HK>_i5;Y$p?W6>3l)xy zW@Vi}lYR=Pjmj5dT|93fvM0}EW3Hjt618;p^CfNd+afx)NCY^LEdKz@6`|^eE<+}U zrVh&TlW&4DH}vgOuK=nh2>5|@lhUw`z<3gV#g_{_4Xko0xB;nUh~3432eoGy@OPAq zk7&><#u1K;J7fUY-kPYmY9p)@jjbSm7pI6>NvRF&AhLcEJ(xW;kD4Yk1p24&}A zhc+9?*YJWJ*1_-L$vO4|+^1H>TQpK(lp|J#EPGC>hT*xp*Z{VSraR-v5vRFDb=M1F zaDy03Ol>R)iEtHHP5FhW{*sZy%anJ@9-VCFRvnQa;#sTdlAH{Tr7;nygb9f8ott8| zF*0S1)Uq?IlMj~>==12WB0rclPl1v0WJclT5MWx*~Z{g_Qb&M95D_@dOQ4{n(i`R%eka zacYk_6J(EsV#CU!+av!%}1-yD){{V)-dx%z}qi5Jb zj?xZ*_xLQk>VI-Zl~orm*3NkkFBKZDj!#F+$J-g0nr|1-)^D)#vJ}gUY0PM-D5_^{CFYS}C|2 z)QwVveJX1f?QJD9BOf<#Wze7P{5eRS#t|jnQT*rcS3;R& zSA`Wg^k&4}QOuF37=v(>WFx!{M+Vfb0oJk&yRM$-S$Tx2Bs6L|M8~sl73$-I(xa#m zetu%3<$5<0U1z+9KT?`k`20;Pw!Wrv@bf1-#d5MT1ean7EIXDt^#;GF)&*ubl?yRk zb0bkM)!Dv`I;oA6R|xJb2n=`?_u37XN1DMIPX~#&MjY|7uF92>lx|=ILEH=TaJT;e)BMrFHW8957fVkj&TM-^ zsfY zmmr0zVG=`u+EHN_~7+z$du zq$??FrxVRgbp-3GldmQn3u0EjBH$jz6Q*zjf?%J*d4OT_w;Xv;&#pJJY!lMFjAHpWC;1d$*Vk0X!XJC5Z$qxpr-FEozlgwrIl|g>rCMf(Si_i>=so z9N3dFlRfg{UEjsl0s#)-6)=gi$isk{5eLNcS`XxI*VOauoGYFC;VHxrVn2v>F27gK z$i<-9OBqWd*q0CGh>GFlV#}pO0wSl+E;N_-SfR1g0J2Np(rwU7+E)uI0Fx_k-%z$M}Ya z?phXfX`O1gN-x5CeIaVMryM)ZH}>~lpE0jaW<@f1$i>GPk_yXLQ^~H#%DIpMeXpaA z<+Y8{XI?G$Wm46~I$9?aw4a6I3!PUkQGu9gfJ=FGTZ*%mn^j|mCwmvJ*lnGeFK3ma zA^<)MW{j9wE2IbpQzS*)w|esxxIba$w=ixNIa1 z=a3pNB}~W9)f2>m-)i+(jkG_4F<(>gEmXyU_Lvc*qIiO`nHaDfG+1Nt&5FE>p3Vvy z=6L?LA^NT5hO#94maY*aFB@!#{Mhg$4k2vyw;fXEEX3%wFr_1vj^|HOJiFAUNN+z^ zaqbL;@$@UoKt#$#fZ|Sz6OH4wv{#jWtXe_i*j9+5O8Fm9?#piPf;2@m<2dz60MXkiu&x@76vtNWgE7&PkN zn0}=_0Jad)IbH@$ZHqTO9TuL)7=3EK9p)(fx9)#w z-ljlNF3(O{e|34jmpyWbAM&U572m9pk2C)OwoAT+=Onp{arUf-qFc()eS|GwPc}X0 z26#0SqzAcUmLvgft=q|WtmwI7K)}R?)Dquf#V(Pbz{WBiqC-T$Jx3;ZrV{ebac~O> z5%m-YE*}O((QeBp_pZgAD=tOE95ZnX0qMDbx1m24aHJMl$js^;UWi1;Ht^(+jGwJ+ z<8?l?YJ~oF*N}FW*?npgZl ze8@lAt_S}BD!=RfZX~^xOSslP!WBI>XARt`WHsQJiR?4~0NkBed{t3D7|4B6SN{OH zb3Yap5^>uh_pL))@_(sXRd5Z-F(w?1pZkI`ADh!EllDk`uvD|H^oT%8?vwyO!eD>$ zg@e;4R$AR!ksNpt%hGEV0P?N>0P;)kQhN#FdV1q=)^R0{ zn@C~e`mPsOt15Z+5m}flHyKBeUWO&eX$K7L_O3Tn*mGz&utA?-)%*hP8CDdhu8ssdDH<^>=Z+hHdIcPa4821fL*P6H7SQp{$Sh6Kxr0n>) zYOd#qQ8Ilw5Loh5%Q@inAY7Wr>@o`xJw`5NbO7w!7CSELB69;IL<67@t^WYsSum%Y zJ3l`zMkA0B5B~s#R-8^IYq?i#Cr*Q4svf5na#m;5^Hp!f+s}#yP}?;+o)j}E708wZ z9SI*&<`3HxpNdk~`)knFL;1P=OGBk*Uy+-iRkPg;djx8W0KksakL*c@3zN6N=a1VI z62KBUhLinkDE+gD8Q^`#?_J%*X~V&C&UEYRhMTSQ@jV^DOo+pK_wz=f4Opc)&&f%O zzuS0vywE2 z6uG!G^7<|$WsU_vZmosH8ZnT%MvqX@1w-2FZ+rz-dr_AR<=#jgf8Tm@Cv0AytmpaY3qi@$Q2V#^+?VIe5`W^P7|$AZ+! z{l$@P9I$0oVirTwk@Ifa+=jqOSGy`5idE9T@c{Jlfxno9AtO!P8N04Car4FZu)iA7}*&4341_m-A!6CE{s;bD- za+|I&bZM1Y6+-%nm2(17Cr63kqUl#vQ@Ypm;S(tV*aBhWoNP+5_ipr~7;fPEM-@3W z3-PR`S_>j!ryWBoyG5{_J_af$PEI^8tRGI;iAQ#Yk7~(WA|eMMvEyUI7ZR*-Ezzn1 z0!N8$7HH`Z&Xp~*EuxbU%Tj$)3b`0gZ6H{N=CWhvQIh_Y-MEEhka&)*{{Z`3zy9gh z+^|491HheD*Hx<1)iTU$l!vHTf!r1v>By9#Jt+Yq1IT~__PJc_o0vLnwWhtu-Rn5m zc-b|_3H>Qo6Chmqx$#{72f2Db(<#sDbX7S_;)s||!R0Rz4gUbC zONU;E{?D>l@jo~rlp;;FN)a&xa1kJdr9azwyF{m80$kVu?@`sF^(u1tC?%Ine%-%w z8^*i`m0mx#Zj%{bo5ZB2bR#_EJM{yG;M>?N+@5GP?bRWflTk-w7((3Yo z!-L45scjCKi17rtK38+VsMjK^D09T(`tjNfyrXPnUtK4`UBi|xLF9R^t(_+lGm!*F zy(1dPB>8`t))S3_B22c#OU*C6MxZMubB`Zq1$rv=uCB2m4^l!qj{yAFQaJd2n6NO3 z4Y{A#t5z*nOva)?4nZ*>0z5!OgSpPgqEz2HWvPUWcWNEEhgeKWoMBkqITjHC$vw-v zD`$Nb`ryvW_ij5*;OCfb#Rj;>iDZq`nq^+HiHX#YFn&QxZOg;FS8@}c27dvpp2pJ&jzag84>UP;>;=JA~8ArK}D2#4ae?8?Oe&Qr&3O>X1=0EP+DW2}uq z2@pvH4kbPx_AP@Uk&k`Unx|DE>@bl#M}df1bx28!bq9%o+&ojH`%vol7IDc;n6PGU zE}+PXurZl`g|%k(6f%TH>Bdlg&3=U*+cdXRCqNrOJ?aaM2BViGh}-~=Kybf2k_W`? zKT55e>x;|~Ig_aOtUA6%mDe&!1Tu$i2i&k@=24I&Xqd7iNf_(JWZqm5cym#voHD05 z3;0J~)s7@~h6#MMkF{cnUH1Jb^X__?GqRZ3FH(GBTe1Dh)$H9bNXAjnymaKXa$&w> zlC}^LjKk{S{R>;aw#;pQ>9KeQqDasfyK~el)us2lkAqKQNu)K@X`-|?iONTehQ7`V zf;~*db#axJlFjLW+}t^Jk`UjI0AxeNYsHHwltHpnJY@}o>DTF2t~((Jlc)e5Tr^2! zg^8_HWf;*iLEj@8Xxukyvt^k=BG~D)7j7ZhR|KAahS~$P7tt$MkM7RUPeSSKCP!GLwSj}hmf71=u1WUCOix*xuVb{81``0m2aqg z%p<1TcMcklD>zA0iX>Y~hm$i^VYU(`J6N`*`_-HMs|F_KxWJ+IrM*ljkBYB-z? zRlO=o#Dd!hJ-WKus2!k7Q^!s#&e}-2Kp(_@wVpW{001nI0CFWab|$zoZEvM)k3qDU z>&Lk^ovuRR`qv&ammV%w*G`n>k*O!kE;_Fj{k$gUBzkMREa+?5>_>6fr)fYBCSGHy z9@ON{$leMGjAHVaf~hv)0ie;=E93+Yq5By`?U4-=w04b3);gC2mTh^i`+F}pfs1Y> zg2a&^+Uj|BExZ{==4&LjpnV}~$igw*BeZs&U5d2k<3jk8CyYpxqyb>WXuWi=nH(a%XMpMuL?!3j_dm>&;cB&C4&7qO^=oY=bOCvh!=(fSM{1G9dW&`R1 z>fB1Ila`4~Q-pSM`t$?C6mKp5VbUY8j@8XQM>NZKuPwyBng{L+Sl4Mf$PV2WI}Xo^ zdj9|q+PK?qb>iF|X>FR}RWG$20oFQ<3!4PHr4jZr&knzoG`6@%jwBNwy>Wd_SbGoq zRgIAvI6Z+x_ZJpv*KP1edLOtdBl9Vof3nj1md$;0r%wKZ#t8*^{jsE~{+xYK` zGie2H`*@b!ku5tS0`C$Zf{h(sjZS~a+~@K*gZAZI?y&p5<#GFdjZCbW2ewOd<}WwM zHu*0HpRA{JV+eZlh=~= z-F7m!qu*INNWf{w;tRxNVOv%4tV%`8MD{+bN|jC)7$CPTr_JWY@hk&}St2+W_pM7Q z&BmV7E!~apL4EOap$r}0fz&N%e9E_1CT_58HPzW=7F!s4ahVW(#wX!^4#!7YLi^o~ zPDf`I12hsr2XNx1m%!7#UVk%u+c{ZXmTj_hx1wbb@DVNm;lQh9=454C>SWB$T)6?@ z2L+@u=_ScKq^Ufx2#60JL?c#sGHTndI3r+?13rqTT+4gbDO(p|A+Oa-UGf8(2GrAy_%}l=vOn=Z}YzO&Hj* zw!0%)Hwh9%rrWh?4HsXe!_&yjsNXBhHiNmmqs`f%UNhE_Bar3Em!SQtWMMqGPG6a5 zjG`Y|2=3jlwRBeCVJVhxn31UFw5_JeWP&R)l&1*`8{mu|a6T(^$(>Zpu^^I11;L}# ztp-p;zQxnPAC(sqtL*E9bX)y}w0!7v5W!)T6(WfiCqA^p%Y! z%Q$}I+^A&S6m9@(!?j%~8C-igs**SWv57^Wmuj)#?A*8&Qh-bGV#&W<11hm0*s;C|LN||hOC*2|n>i+<4z2`Oi zw(bOi<3f4%_aGpV^tf2WgFC-rQcJmZ9jlqL+%@78bFg=FqM$M22M*nR$nlVE9nhwJ zr_%6*qk;Q1qzCFiI!|F$5iI89=pa8b6{8lK!xR-=PN$mU9taHqmY%bh_ zvCpV!D3}iLvkdB;7Dm>M#ToP=)(B@~9UZ@?HF1?e4kSJLDG!mUtKw(JHO6FL9njk$ z6R(y{h0K!~#%41ODCj}cf^qNcQc2uT6U4fL zA780LfMh%gQ2zi=G~`DR`hQxU-=Q!Vk^$$ZiZLDF_9zEI$4&@LoG4H`-1jLjqaOgE z5_RqSRJa74J5ods4nFh^C*<5zg5Be>_$YleLXI2*cZAj5xF`kuHPf*Piyxto66Lt5 z(fIpRaU}3OG%5p+hnl3~OtjW6OY!bdH7{4Mh#j1!dUx0;P%`A&)XzcFLLjxE7iiIFl5+@nGdBkoW%)t2kWM^4_m~rvrs_5JMf`Cb^Z>R?(yk*_TL{~*lTZ50* z-}Ot}1F0@XycVt$NX^C!Kg^E@?RC_h7=1$7Wls4~Wc4tUH?h`A`Y)wrIU@J2G7=9S zJo{CqNN&AK(TtHh#fRdtyd z2oo&;`&F?p#w{_W$pOeEwf8EPeWq;ZM`C9euM_%|Vo%|U?$AJh?jK^skL{0%3fx}S?Kip+5yj#7iow<>zV4$5S5&0{BIFG? z_O7^=*uvR7Txgk_#;>+R5ul!^0T76&rk1e40vZT5d z--gJacwHMMRZ!n#gktREbN=Wn9CI+ZM=y5wbrNOStz1wTGKVWIakH zzP=lqnDIz~GO#(9VqZJuFm?$r+b%kII?0cPkzCC5o{Yi%)tfu5;o#USxREJ7(GX?z zDe3)R8>m?*(qv3NB0-M5%C_SZHC36eEgSH0041RVf{@%+k5cM&U_e`Fl%n7?L-Fxi zxIefl%*hm2T;nMMUQ7mo`4Xf&%9?Hy$C29}3boT+W>btrWizV|J}X;0FXb_3klT%5 zov+209*^B}$iAw@1bUJY1juOT`zpo~O-ojp^M7;E*=Vj3oPn0ns2o=ZJFaxg#Gqp` z2x*yACGOoN*2^35r5QU&ejY2sjJP>5$A^@V_7g8}Bx$uM=X*u8_DPzYy6K+md#|cw z{t+00AD+e9tM^_t6m6U>y3wYx96QSe=cyu65|rwZ4%HDO6^1Z`Bu$eLcX9VDL}Xod zNhv#-pSx#~>H1jSm6c0;M2_dtyOx~#?sfKqW%d_3!V}s7J%THlF-RuB1%r=bn95cW zV6R}{2pd2FR=Ang&cub2hpB7Ic4!BhQ*YuXT$T&6ASBdBY+?X+B$L3GD_`|2n(Opq zIS8>}<$yfawV4D*%O*Xn%bTlYc9Uq?pfay)Vm7~N?R7&c_UiK0+?ESaWli?VTfUI$ zCul7wo;*UUZIIsITTO)s|7>>uxw1YR$>0lU>pRM^I$op8~o#{{Xlwi$@}%Fh3MDKH{{7G{i$!Bh7>*^k}2y z?uguJu%6wl-u0Dd!0PI_vvzm%%M7k<^PHb<()#wfWrSzlQ6msnNS8Q`^3%7H)-Z`0 z$53?yAGKN#h&qGA-leYli%q(&(vrx$*(uqPIQoUjjAG}t^iy}tTL}7gTV+slag?GQ zf(%4r0N}D3g9%H(NAFcM7__3qk8oDIsb-|k=F4l!^qo$Ly1^`hWgkvQXprNkrJZb7 zRW`}tWTi1bQMVR%aQ^NKha2)L1~ItT8F2%LXVSVI66w|JM#3csM9wUAJhrb*ElWaJ zo*n9L(rs)NeK2Cm$&l)^t>R@|tDhYoNeAG%HRM@-Rn_`Ym2tdXbe}@TJRiYx{{T?O zh`#%9XcInY7_qX+I}Or27S;u}b?KuZ^8J zWK30TX9(6v9NdR5;JK#TbKZAxRJ8Mb*%@=L`F+=!12Y0hC!c!6da*yZ>&w|0T>1jy z8p*tPEHJ(qaeAdG&I9udgn9D#zJ-S*iBfQ~YMnT85hEh7Whl?z!TBw^IG%WXPIcXv z4nG$xxEOJ7>m5Svn31d)$8l~9?f81;U|&K>RnJ~WLFG}L?dq4#ySBP;hH3+N@Gb|VW}}bb zS!bD1Cu~{yFj$C@%%b7&O1l6(=W4cCWM+?q$&zr8X98etXXD=L*zPhTvEtR66b0wYbPLx3^+%Ey^}p?zjH`g^qE!|q#~glpX?2=|r)>qsP= z%k9WEiB}t8GujJj;I?SDHmUlCvpEpXUxDPW9Gc`9S+ZcZ*=g=4MN{)TgoV37&;t-d zs+&Z?ndO(6JGVd@TnCGB{`RUeuc!=7YbNfKO6&R%K`gKzv1`AGLEc zI<+#LMjmMh$Prw_0(lt@A!@C@(>6&IyBduAu~En--%o90(`b=MEKIx8r-{>r=-y0m zvjLRi2IC*X{wx-V>KBafUmg&s{1Flxzz0I(Mc5Lyb>rehLNb)$Dcb@I>-b{&Eo4Rg zAaf7MW?~kdHPs60aj0$HX6;%O^pBW@UoB{zmQg&3z_?=q+alb`+sZp~EfkVA+p-b? zr8Wae0jAgHvj|;QKzN9d1Ihz%fuAo3qH1yARfQUSZnQgLe zdT>ykFf_4D1r}Y)9Otqx|!D%RS;!dAa@@-M`D=5wl=B`mJ-bpz2iT&#~qIsLG+Q`Q$ zO|2T`8Ij2OPtua2>e$Y?0sKtk-WJ6cV;PAV#7kc+hRJwtFBPx=h~FL19kDMG{{Y!h zRumE1T%2pA`J%Zb_FD#`aj=$XkYHK=0Gm&xV>p=@_Sk1k ztFA&UTgp$0=F`)P#O|z}r9Ai;ettqz5`*m^d&vuL)qtMMnlHzeL2hOR>sJqBpK3NM~Kun zys&Ov^gLaqC z`$y|miPib@OULKW`$hfgk|ZxvQj$D%ac0cM&@@@PebV`zIl@aoop`OjpZ@?+;TdsU z@3yqOI2~!a-Rs_Ux7hki#HXz{FYH*HG2}ZHU56J7#nZ3hN3&bzWjPZCl_hk~#6a#? z%*o(5@NP=7oMJl<^yIB}>YSuUcavqFAeSdz#jk(P#Jw=xQ23+>(a$$oWimLhBtIdd z)|ts7R#hn52$8%2@IMD-)|7mXcDmx@xQNpJYmVZ1JFD1YzyYIZwd}sy*joV+E(O&5 z#eCd$XqqpZ`4_23h4V7DziPp478^ZC$&3gw@e3%jvNRx*%bE!m)0*g;PE6S=D4&6| zJg0~wOA8qu!`!u`OOi{ItCH#g?_z+Ic5gCPs<{$1)T8|x1pb}M1M1v3>)oP;grvY1 zZsnGk#@eA|CQN0?fFSb=4b*zE*%HT$q)p4~X;Z(PFO3VM)8HwJpRQ zovdRgq~O4JUh;I*t-7!FUoCoLlc2Heh^~+0OAYI zl3K|02K7)qTG&s^ySD#M1cAbBo2#L7cbN5Ma?n$#jS~-CleL|(_sJ& z!;^w|Xt;CQBt&Jn6QJg{bMorHB3A1y;mEJb0e%|sS^{2SDNAEXxO^?oM6FC+%kDCn zMTeLf4g;7`5>K0RgT=dh7AZy|-!!)ee=FYPm?UZf9_ zQFW~$DHjqN2WD2P+eexOo0^ft)ps=bo{tkgH`N`|0^&T;c4D<}&#D>wLWsn18IqFFqJC;e;RZx~hzcQxXTwDV(L-9HV#;nIXHaW4jAW9^9dXuO-hJ&?q zdW>LtRdBXq5Zc>>N$lk7qN>LQft_u}FB2v`wPua6uh6^!IEc(nGKnnvqoT!jeRX7- zD}`1<5#2EF|o{K^74p*wY*X= zm{JwEOEEn~A|@hIZ%oV?2#@T?iC4qB=PEM0Y^6{V^&mFJaU*vEwR*(q7_`RN z%Gj_ixW`^vfL2bLYdH2!5HJFu(K08Nj;@t4!;u}He0iGmb!+a-vLa?xLUDwtFpOce zzOk1Qwl0sSe4;oFM}b<#oO(>nP=@ITBnK|<{70Hwm0Urx`G~s%DKJ^SQ1dR^#kG_h z9Xfyfj!b|g{Jnnt(^zsOC6jc29A)N@VLIxy`hQfU&m!u*IS^{7*i3}B$jAU=!0W+r zI-gLNx`grgZ=XSnGjqc*&iYs#01)RVQi9pNDD+1##X;0-x5a$bHta_HAv7|y~8 zvqsmFnmYB7-8*5rB!&QwFKWhyYpYzO@9qr5tXE`4n$4DQ90KBJb#O~jAVG^6g2!I& z<WQ@ zma`(kSR{i8m!69>`X)4lOlCut__gsNmb%+%#QY3@8+zEa9qhr%U{>d9x zNa9mA_(%>(@fvYd>DYDR3BdkzXRn!t)?0IqGdO{ZABZ^iGQ-`Ycr@^*pvpF3qnMK9r2d=WY=@ zqrY9Hxbamqld`sdALs52E4A>LOJ(Q z)G?0PMl%DLFUQ4VhDKE9uu~&5$%6EEjp(Fx>u>t&>`lz^+q`J&as^YJPH#B!W*4UszTc z)VZ0%{{XQ_EL%Xa45|oi!(XLLZmhFNclN6s$*FK=K;uVpc!eTN7_jf+slK^zkk?;w zx0S=5Orfd3ha^0Z2jRqV)l0;ktXPFROj-Ez=BWtUJ9X{ktaaCj=BQ@>0AiiZ$%izF z5?PNDKIKwN_^R(GLS2};ZX=$hG?P*!zYa)5FzfLQVcJbC++7jR30`u8M>Ec}l(bfRi#zVX5%Mrgk-Y7ZCU zPcgw%2h^Q;a!w#OgDC})&JQmi1!~$hw-GU?h*XF!<(ukNnUU5!LD2VwBN*^5t~{|f zgo$Qm;qO9tKlLmSuo#2dANrPE@$r7fT=cb>*;zG#G9{Vy=j=kx4+G7Vg2T8D99487 z!E?ZOEV7b#4IMS(5Iwphm(Xh3sMN4#`$_NW4s|CxAX^{p)_eOvrEY!HD4Faa54+)l}NWGd87dg{qmxzo%u= z45JZb2jY&c?BcOkVULnp6UeUj0(keI1dUksNc!;HB^HSh1cS>`wznq+yiYy-rCu~~ z*v=QNF~sUQtcO0?k4w}hA($Qkg4@aIP_xZU%nuV8Y8HjPJ1&T{ph*5G`GV zq>a-JV9;>mC9yhA1en5s=?UxAFfs+3?NA1x>5t` zbrR}%5+H1y?-^$8Eku)TXsV-v{tDwBgYznStn z)zgC`7zpm1^^3XDTI#B*aD;0F%obq40pgUH`qgqikUHqHUnrc4%!p`$%NG*D#Xbe`h!0qUd#Y^(v9kqoa0`x%Nz1 z0~o|am)1PXaL}l@_nNj#@}tqxcGnO%02|z1^(*V*sfCgasLz_5-U6u@dH5Nex{`S^qwAuQK|@8f z?MDRasiwbDvyBiMeO6{T@Y~L8LmtDXovW5OX)iDuMCwO&tEbf@4Fg{@>CeTx`xhH` z5%@@cU7UU{D=~{S-W06?>=19}|Cfr?Kc zHWDYpfoD<-ScWH)0ibR!WUXALNYS?O4j;QvqS~dQjJ)N+5^VzTp3cIexWvZ7M#knF zo{Z|-BzH*IJUJfrJXSc@>9+kx2^TC%w7prxh_r_dy09I(@lOKyle>1bIQOq>Ozed<#Er6yVhH97EEHWCR~blvS&qGnEH0*G#uJRBIDkPRdb7mx zsj76(6VB)KPNR{Vb=AOnb2<`0EMGHN#$V^zw+_?6x*3;OkBzXJOZg6F#G`80q z{7!dR>2`GrMNp7{a7>Q87N1V&a>2=tLA!GU76G`1U8^Szr0xT^P_>RlFym|iJOPkN z!U>`*?50@D#~C>Dpppc^i9Rczi~DC9n`^Q+SY=rfh|^Z>TmJw(haZai$M?e2&gxY9 zN9FS233kU*>|~rEnqlgPCtD{Q*T*#Om1bX#IuN||zqN!6w_7WAjl+}c!D-Rfdaga5 zpu5$9K4@9y2g9*s$?N?_-QJwUB6|SbJ*p;GN%KZ8Gh)u2i_<47ZLC{efvc~F58@?h zN2KH5Rkqo>7Rn}2uDwJ__;oHgEje46bi}T@bBH`id3Uquw@zso{{Sx&IE{A@TX5yT zB;rT6W}L1NjTlE5R{$rz=*l&UA{tT}`LrC0o1t)&fUJnajGM|mrIgWW!I!B~B0^h( zw-D|1s^67u(w(#8FEokJIPO_+X6hq3;$V~-Vb0;**sGJVo+D066s>r>>)f$#JO{(X zg{4+w2!QNC99BqK`GO>II`-9mzQePb`nhH02vZMk6{jeg@!Wo zIhLG|WElc@_^Js(wG8ViJ|MKF444t81<=FkxfvA;01>SIB6+S^U`D>xxrfv($Dfp$ zM3!&rR_Vu->$ReM`7^awO2()}+C52gf4O%00Hj`G00JbE3joY@Wy-4WSIS;DN8^OU z5Ih!5k>WvKX=>AKV}b8gtE^HIe2+ny*6`_D2S{4Iu2LYCO?ENbTzXf=MpJ(YLp!7i(Ge znT=5~lt^*xSZv3v$|eJG)U#+ksw6MkOzZ*w0EB7&*HJ`mnRrRWe-jQJz^ta$SREQq z2BfT^6aHivw`VV6&rR5s4Jxu`1|oJwae$FJ?GPhS63mEJ3}YS5#GYN{X?lioE?9FQ zYCE)DX}s2IPd?|jtMt7mtTjCXLF<*^&B+CeKM!K&9Q+DpCkQkD5m%UG+@et-CP)OCgS5CTEs;M~t01nOLxn{W+xYsFIFyrZC$(|O>%3FsL z>oyeYrrE+~QLiE%m4-6|Xe4=#=lFRodm~&n$%uG7N#<5cv8}g>0FlC4oq6{n-MyX) zO7=GCSaxYO7@f=;I!-NDR~(3zl$vvy)N<}rzo}vvP8QjXkx`2Z$&s>5KZ{`tk#-JD z00vQ*3=Cr~9>uIcjLc55?I4d8uD$DqnxmTbn^$wxy|^7{=JfiyZPa3k(QTodhh32+Xo0FgY0isbif-br^Jiu-CQmuq*jzQ0lQRwg zepEcntAwMFKM0v-+4Qb%*=RsLrMo?gC#(Y&)Djz#%iObO*3A*u{YIWUW?Q*s$I7nB zf)1zfo-3D&3$iT`gzJosyJsQ^9ojC8dJj^TvM`D58^5U)uJ}nEHkNJ0fUZ1L7=^*=?k!-9%a90llK%isZ~}1>1A~WJQOSg* zAao*4spH-h+mTs`X@cGAw}J^dco`)a&va!FVg{YQ<%{!bJF1zEjv{q-3LzivT%DaFf$e5W!MsdZCi=94^Fn=P^GyF0eW%n+WYugdC%d7VB z@mZ+^-o_(u5hZ2`37k*UbBF>}5<8^F>shgIdX_!aT~HB}BnjSP?bs#7cI}j*AsA!M z-5-2RVmv&>)kW8mL~#fsj_V>mr8TmuqgVd`k(I_*Lan;AVj|=p=GschJWo=x)>t?b z9ZMgzZ$s6cW>!`qV0MP>!2bYv1%RCKA1!o_?b1JBQ(G#kGGpXeGDPij5InE|fD3VD zc;A#HCn=d|#uC&WU1E)S#$d?P5c;%afpn1`bbk%hLmVz^y=~@ zy1;iiG1^FHB$oH9{+o*`O-2(juvNzzgP z5#h81)W($$1ClxxI44=2%|@Q!vqPg}L~xP*^4gvufBK81rxP~%fK~ZMB3`A0g8I%% zYPT?{UPdT%oPEFLx&F~pEDUIHtb117__W?Zv=g^L(vTncNLas3!Uz1A_!mD}R$n$D zaxvG3irc4D4+T}B#0D@oGrrYsrmdhs7XW9WjP%CB-h$VzWb^sKSqNIdsLdq{B!bI)j z3}heP0;g3GmrSxzc)s2tkHy$l>v7d;^{qN}o&#U+1voPCUFVq4nUEZqvxR!d8)7s7 z2JX>odbJZEWW*Me>8i{NhaS#qHNfbf60xj^QV0H_knq)H%!ri}BS}O!?c%EKw}IQJ zE!@h}F9JE1Z+b*T6N8c~j?BG28CSTD!uqUvHALl8ey(I98MHd`S;L!m58(s1i@I1N zBTyg`CgHn4b!Dcxz=YH9G4!|^eQNbG1a1dTOJ4Zf9;Z74#wG`W{zl}ts*FX~Nyy$n zAKf3;qWZ+LapVT(GJaYR$*UQBQ8f8dPbO2PS!}46ts^-^fH!dgyH@xtTn1Wru{B$f z08YrvGx#l`F#4?9)h6rd=5ek+oj3j?^{Ns)Rqk(EW4!QK$@4Pxa7T}eAhOu$`s8sj z^2^LMJUiB`q_!{%WN~jSzNMRbUva8>>)v*cPtSxzqGMzTc$sK>mbT28OY`Wk$N32Q z^D!h6di_oN+v_tJ63BXu3Vyo5k5|)r-=t zV_da5kt++4*{NNtdVN;JiNFwe@LCr198wIUY>^8yzI)NF)L;zgyEVBNCJ?{*QBNNOJU)rpC9AF+dSP$}%AF3*AjL{W1 z$)2vCk8iJ!a28cu_QQC63`CjFW36tb(J>FBTq0|Z=tOMturdI#_O1S#16aBfnXrok z&RU;_Iuf^~h%OP7nC&7!YoUu5(OTAW7IB&7`j745Pp1rf7`$R9Fy!tadA;jfGooeT zh=`G8js&)o5J@fNckN!kCl*--GGft`ONKlMmjmFj=g@81V$T^w>!%4s=zWUmW_2Dj zBNIOop=HpJ=P0m|k1kwRFH-2&>dp-UTX6r?^&_>7F_6+^N{rX z!aE#E<1M=CLeu$7@i}d-dm$~dur#B={8Z`om#fVgK;(Jz99HfhPqQiZk1nq%1{!t} ztApc_^sgcVS-QSS;Dhz{QR{ z=O%L@@o^!$7h@Rt8Q)GX_pEWot}`rNO|SF|G$J7YI~9mB+?cGe$G^Q>3o43fzRPTk z1Z=WJ$QlB*vTlrJ2(c!~FktJ?Wzp0!^6!-u3n4|4h|~k-X&x%2gsjNi;NAykGq29# zVTT`18jo1#&*o!USmJ!7jXWJvIZ)ta$D6Jf+q-hl zPTuC-R8|qSx<#V;a@RrHpq+H#z$|gL20s%%flsMR>mW7Mu5kGsWpg&^kTe(LSr&g#k3+#kQ++!2QFQbm` z3ZmgTMnt)f4|>*~NRitZNQcmIRF#)UjWS`*LB6$VTP(2gGkewN=I}SIopYRI4zc@G z!Z(OeBN+yW6K?L+p&4XN#40Sh-9M&vyCYqKFs_N}4CTxFA5!&bpqXWDlv1UiITlUsjI?BEHske&sdL0CO@dA!7+#=K1DuE#7fJEU>-7tt6owdX)z`NxqBAOsmYAD3eA;~t2BquR=tq07P1>V z$ZZFTBuMZTO39s(I`Lw8cP+TaLJ`~qWDc#Xj`v!XS+jBf01C0VzOKjOB&^vvz{#|6 z+;1an3oz{^VipR%A1j_79f=E$#yZ644aLc_H*@&|XKk->hQUNR^T?lt7698GBzv&CBWr+hyp?#vku8y`*-) z^p|xAY;#ni0OPz5XEo-!k5IQF)3BQB0k%Hirg`%DIKid7xRi?!L;KdfIJwy#XjViA z;@~~y9A?__Dt2p4Q*H7i4=PmTLUEY`baw`Q0qiRk)?{CCkrJ3BvDZNS7fu}S|98#c#lxGYKUQR^`xBgjKJ>|CwAdwNo-+Me5LzNxb!oGd(ekSY_I zX0m#i_;OnkXJPQ@!SrG7TITT(?XL}Wf3Zl&d4OMQIUoiFv|Qb9Y~1&qmA2@xQQT)T zj@_UFJ6VpuN*8VAfGog$%TwkjxX2$d<^slYtP%zc2eT3Nj?<(;8kzHM zz^o^}F&n@Ub`m2`7wuf5Z0tJ7lgI_0HC?h&**6S9VtjR4TRorWMp1D%$%*jMS8H>UDp!JoYty4^n#=KU zd3zQ83U=-r;@sPSZsohvu&K5lps?;)#+*2=-9MW}b{PHjmrclrqc|GmVKQc8WKWhvcCzalUSX-+7-=4V*0D@u$izkl z1iqiptkNql3F2F+!xK`>TyI`clzs@CCPOyY96L^hXUxKh>2=Bwc#sGn7j3RMEaHl} zPD8YveTrinB4Zlz3>${50zWga4K8qHhLi2)d5W+hZI;4EXoO`T9kmUj=+)4v7`Iq_ z(u_=^Ux8p-PQ}x@o3a2FOj^uC_Kq5|wSKldZGf&4jgpa^WvDxbyu#_Zxn`29vdHI6 zw5DO6PE_4o#6gfJKsDr6-Uc3CROJIzEQxcE7CS!b&%>B)?DnVmre_$^l6MBFxIrmWJG zjT^7YrZG2CV;OuphxRN(sa6M?1V3o{n6CFy#2Iu@G0Y@l2#_<3IJ0RZ+_}@jvX~#5 zq{h9bFqqpRxVG0ZCq;JL)@-5n6LSkiTWHjeP^v+k2G>wk#%?(}SOGobitJ%@+*=`# zAs;j%M6~#3B3BOF4bd_&CNa>-iv#Tn*7dcWVW{FPty-%}a%>A~k#RTC^dPA9D>@kg z$3n0Ui3t*8AX+exAh>xppGsfSB~9LtBm)pZbK1A%l%<%}c$iRZ?ybv9PZc4!dq;PA z<>X;z(F0^;Oa`RBt>CRjHhqzO?f??hK9baOT6IZGnd&y;J_k!u0Qy1zSb@Q4?CLND zqZw-21NIe3Waic7l}BFk%10)RYv!5KAnolSar+c8@NOEdFPY04%9%GYq5G76sjz`C zVtW})FN8kg>s)0Uy0dXSyAXCCp=u7JLu;Xa@an@pgRbvrBagjUe2J>O&8obl)-?lA zdyf#Zo;qu<4%Mj~iiD##elGosH_TT0p2iC;JlVS0WhPzQMx3~;g2=yN<`$iqbcZHd zc4)E7KB1`8H_{dzW#nyMIGq>bB>Hg*!0}+@bMFh8Ur~89U{1cy8mq@L`2s~!Zfzf9 ztXhp5uHt;n@_R<7Uur(R>s&{uSzxTxVJVHk%#Hv*Af|Y^@sl)0q;7I}2P+bm4%PUY zmhw!_3j@p!T7)tj2@j(a_9(;Yw_YY^du#*Yi09htUi>_$*(gF&oL~W)Fm2xt(2DeL zO13K;hnglnX27x$Z!qyw>e4lT1Hn!|MuhOi_soYSwO|;5snv9<=~$J67}O-r9$LE& z^^0!RWUXe3rKyyW$vJez$d|FyM?SC*HsF8Qv^uz|yokn<1a^mR*3>OH*kxYQ#D7i; zy=$tf+|NGmxar;2C8Md6lZR+Alsw1rL~Ew3R*b2~F?z`d_)64{<_0B+E?I2v;UfC* z9E#SLT$?<5yUzBQNWm+JmdMDqoOu;%Es>Dj@p2(yz9u#TJfoL^R}*2ZcRw)ByF!G{ zbBQyyQz8J50iwwXNDb1_hL79Wwb`U)B44wgv1R#?5AcTUQp=JfGsZQ|i!{!>byMj? z7m?GKYQ$%_XkE`CqK*3|OY6g%1vniC*eNCZXrDuVB~i@5MxIWmr?(~%v$gsc&ci_n~@Gh?OG^J;|2 z$$1t61P#Z(MZ1yJG1wZ%sUkQX8E{P42!KSns!GyB0zmELn7jhCNw4-Le`&SwUPE#1e)7GC?D9TR{{ZbBeSuV=Te!<@-k+cDJIoZxomYo6GS&Gko|B!` zq}JCC4;PP%x45o-kTr*xJ)j*)ZX6wN^Id^g1K!&0 zE2AOhE0fgE`nNv@$bvh311!EB@i(_St9%^7ElE6H~q9+A8AN#nft z{t?VBi}G4MERB+gE<}s_xh>Z`){Iw=dnNg-Ih&!>rX54ckV<~gob`hhngY+U%N#bZT_4Q5dtFp{mM)2`kfJ0 zR$#PCw1KMCuv-3d;k1A1L{T0)=FB5=`F@oLgu7c%Q5Qo695HdKgSm$x+=#S+enI|) z685qpoI)zmaOdk;#@CMC2#Q$UGhOGj>QTZcAJvmgirZ`NjCP&h2qj=I7mU9$JVk>- z+=PME5qbAA;rE`+pHuZ_^2kU&j@k`%Dq$OjEXb4NH|B=Xy}x22=UzGZ9k0XF`iOv| zXONAJppOPu^s%f1)V$MAXomRvTU14!JUmUjxcD1!#K0}LK0{9~+WYx=RV9tm;TcLf zme@cq5fUHYTOuvE{{Rn}%NhRw-1!=$QVBAZh6L#uzMA&1RX?0Aitc-=Y zG}2pAEQF7qaK^S?2EVq&Lzj3bZVl4$ogjbyL_{m`6y@j6*udaf zTqIgnO4^Rm+R>3EMt)aRs?iKgADOLz^?g`FQ_F0N=!mbtGfsK`0JzT%mjT)N&_DUs zZ0j>8(C21VP_HPq>YNx4FyN^PW8=%Q5nrpoaPcM%^W*W!&wu+CNP)=x$cryE z;9BM4&PD$K=UM%tuuI}ZUZs)Z;z8oGVSk^m4YGf7BA3bcGgS50y!TkUzE-Y3<;Uvg zu}1E#8)PL!XG4Jr0}&>EEF_4hEc^=dX5pQUt$x^$ezsV$3=J|if-BoEP;=VeZXL*r zOolvPml)LyZD`s(Gpp@yFU6|7xM+yEUo)+8Fk=4znULH)LH^`c)MJ;au3k>b4G|Wp zmmdQ+&xeVUt1_bk4MI`oV<0}YdZY+D;VAn-A6g>nyiQi|D=$Oww(eYC=v1~f7@t;K zV%)$nZ{~dw6n+I+@yq&y9paBi^CUJmH|}QI77{4=D#S}wv~8B8?QIUN$q{1QayGMB zk&7Be14cqo-7hc%2Qx3Ha@N8XdRJ~N1%bN{k|>Hl42rySF;X!;YF?2Bf;_{~+Kja*~$lw)2=E+Z zbH*^!_x#PcoOQ=+>G@)BS1egq85q-Cj;0YXEPEa+9(*xpi74A(sOG$qKd=cVUH~({{RD`ELK0q!cMlI^YMbw zAGaeR>Pk|N^LoEv3$Ib5<*WHn)G+k;{65`L7h2@~J{g?t>RzuB>UBg}V9_zpFvtc> zOoyU%(0c;Qc!0zoGF|{j)H>_-qAWk^P4e&hO?V!b!J|;@+~4_%q>Tbpip6Km#ycO| z{b-A-{U#skH8YGgU>i;we#OqZ$q8Aqksv_b=8l7x6h+Y-pA#53a)b=y2v1$n1Q~na z>n7ftDExNT!}-WRG3ev3=<>iStySOA7Z@g+D6Mn{6wODqKL5b{^5U6QqXq|@@aB#{{V8RK2YFyaCAf? zFu+ynX)Rlq(zYTh?bopOdr=g>iIL{qIrcLR6>cs7A&+S6=fM$0nWCd$_;r3gfGvzl zx__zp_eJ$_0WY>FirQiNzf2u=qY2Uf0Q8vo9SQ~?tsg+d%n`SD5?_|(wSTL;n?5dJ za711^{;$-19jv~O&{A8US9kne9aO%q;J&}mA}k#Vm%umnXtn0epz`uWT{g^a7{Ra& z1CD*aN(ngg!H10iH^P{hoXNw)I0XG z?c4Z~7Acy@>3p7ia_%)S#z?kbGF}a*!Zm*7u{Jf>YTPW0Po@L3q~k;EL|tv_`c8G7 zFV*5|#(hZ43oB^o@^J0oQw!GhG6Y*maXir$z8(e~{{WYXnsS!A;RCN|_A{!9V`;hp z+eRZ#aw2>Q@hkq6pEl^nyMn2PFX6GfTzz^XC>CaVA#jwwTG<_Z+ah?kJ-YkWYa`Ue z4*9$}`iuv+ctlF(^YArpA9>4{qrB)0y=a+jBmCY#Gil(qGOmc28FW@>x5{@AcD|nk zyHOVF>f@1{`W|0#sgiN0OuY!2cp06>LuQUOipkUBBN8l@@aMl$|fWY!?Yu@KVj zlj`eNHaG^k`jP;#*yCN!5fg7N8SpcIQ_RO~dSh!ifu|z1t%pb7AZ(7_M1%GOMXLU< z0zMv}6I!Y|Ik(M{V+V_QWPgiy;P6}VhwbrEYaVSbB2NDRhms<@ygzfmn_r;Kiw=%9 z3TGKlP0}JjzMHg=Tis+~`cw2}7h=XO!*Jd7A}=NX0Cv8wSJj#7+w@y6PXkvA^wF76 z8rQgato`}v8psaN zT%s+mKX~A>{U?y?Mkg|20wOVB@!>A<*tB3GUu>9mF+^UvdKYo?eb4UOzlp8+uF)Ly zA#D@+QQsDc;izzXkrzww$n%Mj=ncU7Z^yax??``^K;g*q9f*rOJVA<4t8bb|e`6H_ zCC8bEX?;-^mRvzMCPv&kB?tg03wh#a=NMi08t$e!Z>($6h$8c zODlI8F=vkuADi|E_`8>)hro!h1Df@C7S+Qk4v2<A#r&4c1qFsTJ06w~K z(P;i>qVi)?Xf*mczltK9%{-TJvC}M+V=+xqo6NkP0fP?pzhvel0IpK)lZN_rL`ULc zxjy&y{iWl@bGo0&8yZ?<5#=!W$73)=Qu!OMd`=!%){3WUsyU8m;~mvJ-;w6 z|2(~1QS2df78DYitJPBC+xe`^s1>1XD)+YMe)R3nEwTAoB)^oT$oK2yAL)GbP0q314>R>Ni;gs+R`Z9F-bJR^ zf0;aI*T?b});*0@&%sHLU413}B)BmiA+Yc|;DGV;LBsF>0~0gL4!NQi@t>Mq+ImWw zdyg-*32x{%LHD_d$O!RBs6_YO@u?{W{{jJ;q`ubx0A-pMM8zZkiAKIQp}S=rJb3_x zFJ3L6P*sVj|Ea`83IXjHLV+f5F_^s=%rC?;UE9t#G91zg59u;raDXQTkDvQYC`8XI zu~I#*_YB2kEv|z8QrvY3Anz{Frqz~?Kj68urBnKl1liRfxlN($iDOXI4hAE(@l}Wf zaQ}4Tx0u>2AEVLTm46vaqT07WwOiqgKUwlOr7S=Hp6m$80S@mvuD1xK8@^hZ!JAmMPiQ*%(^+Q34xAnz&w7LgtA3hP=RPB4{;LYF+f*g8=!KU z918%KGl%s*M}pW7+(X%C{J%$>e8>mL^Euc+g9w$+>ktWTda)~#^E4PXkB^LyC(Hw$oa$1%Z?)lXDkx zcDf$MSgfnqsNVE{FaVV9Dz4_u^_moo+YANuLY=VR^@ox)Rso`-=;y{>Pj;o#@1KFP zVc5%TJj=Y78WTMzF1dUJLLj|vDcAT$=xuWe>UCF!Zvv#fV7UL7uTVq^Ix^j4N9W#W zU?%B$k?A$wxA&n0;gJd;$-GCgB)ph_@Y|>foNUqiye992?}c&O@}2d+4c_?c5NG?o zq@(AC7oI{LB|qD62X0_G@8*(y^-ye9wG=@m)4K=#hP8X~w5cWJ9hLg6_QAzloImpy z-H&dA(u#8h4x9K#VjXx4i%J=b!ApZnYLaN^*|u)v!-BJB-oRiXIrjDAWa@Qy3&P@q zhQ0=CdjlR%M~wCM_~7kSJAaLD520sq@HE~kg~80qM{y~@4-AWse>ePFArU~HUKxNE zoKhrb{;@i?in{CHXy?iUgA%}?#FuVykF*7aqMx3FaFaH z4URg7c~G&vk%>(sQ25VXTA=N^Y0WLAs^|B4v|G{fdEK5d!dk(J?|{{H&UXPCsCsQt zBYE@H62)=!Sq8IuHN%ETVNm(~&!f=%BFm=LmyS*NB__7_*&tS4m^ z54TH*C?~)I6|f4C$_$xnX~aC%6nr-(TKU?dWN^{_B!bk{zOkd5Fpj{=$AY9w7h}*( zTz|PL82}Phwhpwnr4=+WXPBg6SQJP}{A$2JZCJ}t;Z*a#5Yb{eY_;ngaWt1$;-dcJP zkDK3m53QzY?3bm@Ex&82jnfrSdq?)u@Cz9iX=r$|qwMz+uwfP#Qye7fA$w0jZEX3Z z-BU;+$6kFQ*Ei3m`ZN@k&g4+aQ_3I(M;dSPEWtp8uXXgN!G%Da z{C(YJI@lX36ngLQa&%}jf`VpNji&2;zq^sD1{`c*22k6H<&&qOb@2!H2ts4(jz}o@ ziyP!-Dr?PK4aFr12BP}U@Hx}C0}|dSlNWX?un*8Z^?^StX5stXkks;IGV@qnB7`0E zB~Uv&S+ocV0XjO}uBrR#I6rAC3)ZbwU_J;8{#9ygmMYJ>XM#VP(c)5##mNy-Xm7cUJ8WPM2r<4ke?ZFNTk%@m;~Ni2^r z;q|X-E@^;uU}IbA^osQP2AWfNqz57DWG0yyPC2UsOE`>ha$`1pz{K^7XUc1LB#x)& zX^LoRtGygAs@h>e*gdVK8BUoF#L{u_GJ>C+GbThHLP>^R~M!F5m-G?kRP@Bex*DNSSQ@7$+ay;0rN}xN;TY z(X3M1m@uxTSM<)ULtt|hSU$<+?ALPo>^?XycVWEDft+Y-)}^aTkOA+X)HkU>UxAzv zibhyxBt?I>fftcjlxb3v%4G&Q!6`eeNjdivOEVp$aNRW%a-TLNSbG!<*EH1SzRn}E z)B2Dl1CrlYw(x8Z`HH2Fx5Uay_xnt5lJh`n*tbO1kCcafz-`3BmjYtAmwe3?BF^!K zp{K6rw!cA|&z*I$Ej1X8vS>S?;k!hn3op1Mt7LS42!~z=Z^mtjO4J7QGi-G$lcPj# z;Eu87cv@NWR>fd^#X#KDBhkD7z0KRl-mh(K$!Rh=rvAk8yz-|k8pC*T8u~4idcb2N z@Tdw(hw}ffKl6VW^Z}*WYmqXr#TUDu(s!}%0%itXQo~I`Ayl;&44#cKa$zv-;+3ba zddp*>?H%fS=$aET5hTsnIHAvXEn6?nI%!=AMH$NjaWRbsv&Kc2lp+a^65~a5a7?iA zHbUJZvVvNiA^az2|3VP6{0HHv1R9V2WsT&oJ9)fU{G`>NQ8~I+Pc)@1{Bexnwr4P* zb}o90xG0FnK+v%)Kgoi&lk$8Q(Zc=b?+DCdv6V`Bscw%()7>X8!=;xp`M{2e59UFiNj{{J zWeY^ewigndv(ql~wJR81A6Ny0+DNUtnDU>4a|nD#JVI5MWf+HLRCFuJWChv_76icJ(^t+-O5U$uKiMT#pmL9u;lH=k7wc5_ zyFR9(@({Zk;~SYDQv+h-=V{7{0$&}zf>X$ba2*{wd2M&mT91Wynz!NSGL4uylJa4x z{Ij%{qU)B3so^y5fG)|qi^#X6WEr}Kf6TikoJA#ATTjejZ3R}b!5IWRNiobl-ID7t z5tY0Inu~x>U&vH?n~0DXg$|8WoJad=jF(Yyi)@cG5k4-`50W{k$F>f5?sv4$nU5K` z5t*=Zl@Y12#NZw=hKC#WFM7&;Ys3+Yz$k0f&Cv|RnA~J_H=Ndme}1~`R(nSi*A`1y z;KhCuhj7WNuG7{sPkbqhhdxKipA(R8iaotf!37QzyalxTvaC5@lfY^4s{Q2irI6i` za61C=r!{^~|K#NL7T-stVn_w13Y%D?WaI04y>Ns$NTs4w8MR@O^esdJ-_;R)|t|*_*zQPuNMIbxYM=6&V#vGvmc^U(PHJI}61BP;|A#k7j6HRk550^_26)&y9xOme zK$@YQDwkBQt!GkS75`);Y?S0F-I4`I8t-t*A#sMS(T35pI3G9oJ|X-1?vH>^#CqAf zL=O2ezG@`n%QZg2Ag>l&w?5JNcvUlliN=07_uDa6D?bYZZe^LX?T9Ty<%1q-NTpS? zV8ab^Y>1%yD1w9G%1=(g)`(Fed`4e@BY?a5N*%ckkmM=i^sJRXvnl)GW0A=^>A)%~ zq(^8g+5!Aj%e*z;>2@>oIE=Zw`6l0#H2&Sj9?hNc8^deIN+5|m17X>w+kv%7v}e^D zrZ2>6s`-qebq-gJj#U*Juh#zNnN9joY1dLk+D7Q(^fNbrQ}--D>NA~FoG~E;qPquo z4Y$9^CpwR@e)1?gjf$&e+lx5 zb;c?_QVi)hhe3iTGdG8BZmX#%_&qd^FfaDnOmHn7qRkv^=_Sf}e-TrLGctO*nG}hu z$Q^V1!Bve6l*0VqUbM);S;*Fm`?Qi!`{-rKcBrhQ(F%06#ryWqBm&ENPrx%bXC+L^T<4M9f~gcmkIcmzz^Xy~tmPUw=TvJqFg +<%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 199/264] 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 200/264] 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 201/264] 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 202/264] 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 203/264] 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 204/264] 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 205/264] 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 206/264] 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 207/264] 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 208/264] 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 209/264] 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 210/264] 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 211/264] 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 212/264] 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 213/264] 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 214/264] 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 215/264] 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 216/264] 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 217/264] 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 218/264] 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 29731ec40b1fe843502640c3472d5d5f342aad16 Mon Sep 17 00:00:00 2001 From: Brian Wilson Date: Fri, 29 Mar 2013 15:20:34 -0400 Subject: [PATCH 219/264] management command to remove excess input_state entries --- .../management/commands/remove_input_state.py | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 lms/djangoapps/courseware/management/commands/remove_input_state.py diff --git a/lms/djangoapps/courseware/management/commands/remove_input_state.py b/lms/djangoapps/courseware/management/commands/remove_input_state.py new file mode 100644 index 0000000000..722fa90fdd --- /dev/null +++ b/lms/djangoapps/courseware/management/commands/remove_input_state.py @@ -0,0 +1,129 @@ +''' +This is a one-off command aimed at fixing a temporary problem encountered where input_state was added to +the same dict object in capa problems, so was accumulating. The fix is simply to remove input_state entry +from state for all problems in the affected date range. +''' + +import json +import logging +from optparse import make_option + +from django.core.management.base import BaseCommand +from django.db import transaction + +from courseware.models import StudentModule, StudentModuleHistory + +LOG = logging.getLogger(__name__) + + +class Command(BaseCommand): + ''' + The fix here is to remove the "input_state" entry in the StudentModule objects of any problems that + contain them. No problem is yet making use of this, and the code should do the right thing if it's + missing (by recreating an empty dict for its value). + + To narrow down the set of problems that might need fixing, the StudentModule + objects to be checked is filtered down to those: + + created < '2013-03-29 16:30:00' (the problem must have been answered before the buggy code was reverted, + on Prod and Edge) + modified > '2013-03-28 22:00:00' (the problem must have been visited after the bug was introduced + on Prod and Edge) + state like '%input_state%' (the problem must have "input_state" set). + ''' + + num_visited = 0 + num_changed = 0 + num_hist_visited = 0 + num_hist_changed = 0 + + option_list = BaseCommand.option_list + ( + make_option('--save', + action='store_true', + dest='save_changes', + default=False, + help='Persist the changes that were encountered. If not set, no changes are saved.'), ) + + def fix_studentmodules(self, save_changes): + '''Identify the list of StudentModule objects that might need fixing, and then fix each one''' + modules = StudentModule.objects.filter(modified__gt='2013-03-28 22:00:00', + created__lt='2013-03-29 16:30:00', + state__contains='input_state') + + for module in modules: + self.remove_studentmodule_input_state(module, save_changes) + + LOG.info("Finished student modules: updating {0} of {1} modules".format(self.num_changed, self.num_visited)) + + hist_modules = StudentModuleHistory.objects.filter(created__gt='2013-03-28 22:00:00', + created__lt='2013-03-29 16:30:00', + state__contains='input_state') + + for hist_module in hist_modules: + self.remove_studentmodulehistory_input_state(hist_module, save_changes) + + LOG.info("Finished student history modules: updating {0} of {1} modules".format(self.num_hist_changed, self.num_hist_visited)) + + @transaction.autocommit + def remove_studentmodule_input_state(self, module, save_changes): + ''' Fix the grade assigned to a StudentModule''' + module_state = module.state + if module_state is None: + # not likely, since we filter on it. But in general... + LOG.info("No state found for {type} module {id} for student {student} in course {course_id}" + .format(type=module.module_type, id=module.module_state_key, + student=module.student.username, course_id=module.course_id)) + return + + state_dict = json.loads(module_state) + self.num_visited += 1 + + if 'input_state' not in state_dict: + pass + elif save_changes: + # make the change and persist + del state_dict['input_state'] + module.state = json.dumps(state_dict) + module.save() + self.num_changed += 1 + else: + # don't make the change, but increment the count indicating the change would be made + self.num_changed += 1 + + @transaction.autocommit + def remove_studentmodulehistory_input_state(self, module, save_changes): + ''' Fix the grade assigned to a StudentModule''' + module_state = module.state + if module_state is None: + # not likely, since we filter on it. But in general... + LOG.info("No state found for {type} module {id} for student {student} in course {course_id}" + .format(type=module.module_type, id=module.module_state_key, + student=module.student.username, course_id=module.course_id)) + return + + state_dict = json.loads(module_state) + self.num_hist_visited += 1 + + if 'input_state' not in state_dict: + pass + elif save_changes: + # make the change and persist + del state_dict['input_state'] + module.state = json.dumps(state_dict) + module.save() + self.num_hist_changed += 1 + else: + # don't make the change, but increment the count indicating the change would be made + self.num_hist_changed += 1 + + def handle(self, **options): + '''Handle management command request''' + + save_changes = options['save_changes'] + + LOG.info("Starting run: save_changes = {0}".format(save_changes)) + + self.fix_studentmodules(save_changes) + + LOG.info("Finished run: updating {0} of {1} student modules".format(self.num_changed, self.num_visited)) + LOG.info("Finished run: updating {0} of {1} student history modules".format(self.num_hist_changed, self.num_hist_visited)) From 60e295895eef2515beb5bbc450838d368ea5375d Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Fri, 29 Mar 2013 15:26:21 -0400 Subject: [PATCH 220/264] 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 221/264] 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 222/264] 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 223/264] 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 224/264] 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 225/264] 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:
  • ${section['display_name']}

    -

    ${section['format']} ${"due " + section['due'] if 'due' in section and section['due'] != '' else ''}

    +

    ${section['format']} ${"due " + get_default_time_display(section['due']) if 'due' in section else ''}

  • % endfor diff --git a/lms/templates/courseware/progress.html b/lms/templates/courseware/progress.html index 9c3df5237c..0555752699 100644 --- a/lms/templates/courseware/progress.html +++ b/lms/templates/courseware/progress.html @@ -13,6 +13,8 @@ from django.core.urlresolvers import reverse %> +<%! from xmodule.util.date_utils import get_default_time_display %> + <%block name="js_extra"> @@ -62,7 +64,7 @@ ${progress_graph.body(grade_summary, course.grade_cutoffs, "grade-detail-graph", %if 'due' in section and section['due']!="": - due ${section['due']} + due ${get_default_time_display(section['due'])} %endif

    diff --git a/lms/xmodule_namespace.py b/lms/xmodule_namespace.py index 423c0eb0ec..3ff1ed3971 100644 --- a/lms/xmodule_namespace.py +++ b/lms/xmodule_namespace.py @@ -51,7 +51,7 @@ class LmsNamespace(Namespace): ) start = Date(help="Start time when this module is visible", scope=Scope.settings) - due = String(help="Date that this problem is due by", scope=Scope.settings, default='') + due = Date(help="Date that this problem is due by", scope=Scope.settings) source_file = String(help="DO NOT USE", scope=Scope.settings) xqa_key = String(help="DO NOT USE", scope=Scope.settings) ispublic = Boolean(help="Whether this course is open to the public, or only to admins", scope=Scope.settings) From 7f0048720377141b05ee1e90cb0070ca6632cd95 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Mon, 1 Apr 2013 11:48:10 -0400 Subject: [PATCH 236/264] Reverted last commit due to concerns about database writes. --- common/lib/xmodule/xmodule/capa_module.py | 4 +--- common/lib/xmodule/xmodule/tests/test_capa_module.py | 10 ++++++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 0e6d81bdeb..da8b5b4f96 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -171,9 +171,7 @@ class CapaModule(CapaFields, XModule): # add extra info and raise raise Exception(msg), None, sys.exc_info()[2] - # Ensure that the module state matches the state of the - # capa problem - self.set_state_from_lcp() + self.set_state_from_lcp() def new_lcp(self, state, text=None): if text is None: diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py index fa6d7ce724..60e51fd725 100644 --- a/common/lib/xmodule/xmodule/tests/test_capa_module.py +++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py @@ -868,7 +868,10 @@ class CapaModuleTest(unittest.TestCase): module = CapaFactory.create(rerandomize=rerandomize) # Get the seed - seed = module.seed + # This isn't stored in the XModule until + # we check/save/reset the problem, so we + # access the lcp seed directly + seed = module.lcp.seed self.assertTrue(seed is not None) # If we're not rerandomizing, the seed is always set @@ -928,7 +931,10 @@ class CapaModuleTest(unittest.TestCase): module = CapaFactory.create(rerandomize=rerandomize) # Get the seed - seed = module.seed + # This isn't stored in the XModule until + # we check/save/reset the problem, so we + # access the lcp seed directly + seed = module.lcp.seed # We do NOT want the seed to reset if rerandomize # is set to 'never' -- it should still be 1 From fdc8a6fedf1941f60bd317d92e465f76155f4a7c Mon Sep 17 00:00:00 2001 From: James Tauber Date: Mon, 1 Apr 2013 13:06:35 -0300 Subject: [PATCH 237/264] reverted rednose version create-dev-env.sh is failing because rednose 0.3.3 seems to have fallen off the face of the Earth. 0.3 is still available so reverting to that. --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3dc732e013..1a383e6cc0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -51,7 +51,7 @@ python-memcached==1.48 python-openid==2.2.5 pytz==2012h PyYAML==3.10 -rednose==0.3.3 +rednose==0.3 requests==0.14.2 scipy==0.11.0 Shapely==1.2.16 From fb9760244262073384c90ec0de29e737455c5ce8 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Mon, 1 Apr 2013 12:08:53 -0400 Subject: [PATCH 238/264] Save randomization seed if it is not already set, so that the same problem loads when the user checks/saves/resets --- common/lib/xmodule/xmodule/capa_module.py | 10 ++++++++++ .../lib/xmodule/xmodule/tests/test_capa_module.py | 13 +++++-------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index da8b5b4f96..0869c3e484 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -150,6 +150,16 @@ class CapaModule(CapaFields, XModule): # TODO (vshnayder): move as much as possible of this work and error # checking to descriptor load time self.lcp = self.new_lcp(self.get_state_for_lcp()) + + # At this point, we need to persist the randomization seed + # so that when the problem is re-loaded (to check/view/save) + # it stays the same. + # However, we do not want to write to the database + # every time the module is loaded. + # So we set the seed ONLY when there is not one set already + if self.seed is None: + self.seed = self.lcp.seed + except Exception as err: msg = 'cannot create LoncapaProblem {loc}: {err}'.format( loc=self.location.url(), err=err) diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py index 60e51fd725..c9053be2ad 100644 --- a/common/lib/xmodule/xmodule/tests/test_capa_module.py +++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py @@ -868,10 +868,8 @@ class CapaModuleTest(unittest.TestCase): module = CapaFactory.create(rerandomize=rerandomize) # Get the seed - # This isn't stored in the XModule until - # we check/save/reset the problem, so we - # access the lcp seed directly - seed = module.lcp.seed + # By this point, the module should have persisted the seed + seed = module.seed self.assertTrue(seed is not None) # If we're not rerandomizing, the seed is always set @@ -931,10 +929,9 @@ class CapaModuleTest(unittest.TestCase): module = CapaFactory.create(rerandomize=rerandomize) # Get the seed - # This isn't stored in the XModule until - # we check/save/reset the problem, so we - # access the lcp seed directly - seed = module.lcp.seed + # By this point, the module should have persisted the seed + seed = module.seed + self.assertTrue(seed is not None) # We do NOT want the seed to reset if rerandomize # is set to 'never' -- it should still be 1 From 93b0fafea8766a66b1a4648bfcad3044796dec11 Mon Sep 17 00:00:00 2001 From: cahrens Date: Mon, 1 Apr 2013 12:41:34 -0400 Subject: [PATCH 239/264] Merge branch 'master' into bug/christina/studio Conflicts: cms/djangoapps/contentstore/views.py common/lib/xmodule/xmodule/capa_module.py common/lib/xmodule/xmodule/combined_open_ended_module.py common/lib/xmodule/xmodule/peer_grading_module.py --- common/lib/xmodule/xmodule/capa_module.py | 3 ++- common/lib/xmodule/xmodule/peer_grading_module.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 72174c1f2c..b49646cab8 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -9,7 +9,8 @@ import sys 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 diff --git a/common/lib/xmodule/xmodule/peer_grading_module.py b/common/lib/xmodule/xmodule/peer_grading_module.py index 4c0ebf4e0a..5d064378bf 100644 --- a/common/lib/xmodule/xmodule/peer_grading_module.py +++ b/common/lib/xmodule/xmodule/peer_grading_module.py @@ -12,6 +12,7 @@ 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.fields import Date from xmodule.open_ended_grading_classes.peer_grading_service import PeerGradingService, GradingServiceError, MockPeerGradingService From 87d8a56a93c397568764f8d2aee5ecfcdc518ebf Mon Sep 17 00:00:00 2001 From: Will Daly Date: Mon, 1 Apr 2013 13:27:31 -0400 Subject: [PATCH 240/264] CustomResponse now imports chem packages into the global context for scripts to access. This was removed accidentally during an earlier refactoring, which caused customresponse to raise an exception for chem problems. Unit tests verify the changes --- common/lib/capa/capa/capa_problem.py | 5 ++ common/lib/capa/capa/responsetypes.py | 70 ++++++++++++------- .../lib/capa/capa/tests/test_responsetypes.py | 60 ++++++++++++++++ 3 files changed, 111 insertions(+), 24 deletions(-) diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index 696b12377f..6580114bcc 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -32,6 +32,8 @@ from copy import deepcopy import chem import chem.miller +import chem.chemcalc +import chem.chemtools import verifiers import verifiers.draganddrop @@ -67,6 +69,9 @@ global_context = {'random': random, 'scipy': scipy, 'calc': calc, 'eia': eia, + 'chemcalc': chem.chemcalc, + 'chemtools': chem.chemtools, + 'miller': chem.miller, 'draganddrop': verifiers.draganddrop} # These should be removed from HTML output, including all subelements diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 5b1b46d858..ec1d5ffe1b 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -2,7 +2,8 @@ # File: courseware/capa/responsetypes.py # ''' -Problem response evaluation. Handles checking of student responses, of a variety of types. +Problem response evaluation. Handles checking of +student responses, of a variety of types. Used by capa_problem.py ''' @@ -10,7 +11,6 @@ Used by capa_problem.py # standard library imports import abc import cgi -import hashlib import inspect import json import logging @@ -34,7 +34,10 @@ from .correctmap import CorrectMap from datetime import datetime from .util import * from lxml import etree -from lxml.html.soupparser import fromstring as fromstring_bs # uses Beautiful Soup!!! FIXME? + +# uses Beautiful Soup!!! FIXME? +from lxml.html.soupparser import fromstring as fromstring_bs + import xqueue_interface log = logging.getLogger(__name__) @@ -243,13 +246,17 @@ class LoncapaResponse(object): # hint specified by function? hintfn = hintgroup.get('hintfn') if hintfn: - # Hint is determined by a function defined in the stanza instead cfn = xml.get('cfn') @@ -973,8 +986,8 @@ def sympy_check2(): else: msg = "%s: can't find cfn %s in context" % ( unicode(self), cfn) - msg += "\nSee XML source line %s" % getattr(self.xml, 'sourceline', - '') + msg += ("\nSee XML source line %s" % + getattr(self.xml, 'sourceline', '')) raise LoncapaProblemError(msg) if not self.code: @@ -1010,14 +1023,17 @@ def sympy_check2(): log.error(msg) raise Exception(msg) - # global variable in context which holds the Presentation MathML from dynamic math input + # global variable in context which holds the + # Presentation MathML from dynamic math input # ordered list of dynamath responses dynamath = [student_answers.get(k + '_dynamath', None) for k in idset] # if there is only one box, and it's empty, then don't evaluate if len(idset) == 1 and not submission[0]: - # default to no error message on empty answer (to be consistent with other - # responsetypes) but allow author to still have the old behavior by setting + # default to no error message on empty answer + # (to be consistent with other + # responsetypes) but allow author to still + # have the old behavior by setting # empty_answer_err attribute msg = ('No answer entered!' if self.xml.get('empty_answer_err') else '') @@ -1092,7 +1108,8 @@ def sympy_check2(): try: answer_given = submission[0] if ( len(idset) == 1) else submission - # handle variable number of arguments in check function, for backwards compatibility + # handle variable number of arguments in check function, + # for backwards compatibility # with various Tutor2 check functions args = [self.expect, answer_given, student_answers, self.answer_ids[0]] @@ -1124,7 +1141,8 @@ def sympy_check2(): msg = ret.get('msg', None) msg = self.clean_message_html(msg) - # If there is only one input, apply the message to that input + # If there is only one input, apply the message to + # that input # Otherwise, apply the message to the whole problem if len(idset) > 1: overall_message = msg @@ -1137,7 +1155,8 @@ def sympy_check2(): # 'input_list': [{ 'ok': BOOLEAN, 'msg': STRING }, ...] } # # This allows the function to return an 'overall message' - # that applies to the entire problem, as well as correct/incorrect + # that applies to the entire problem, as well as + # correct/incorrect # status and messages for individual inputs elif 'input_list' in ret: overall_message = ret.get('overall_message', '') @@ -1370,7 +1389,8 @@ class CodeResponse(LoncapaResponse): tests = self.xml.get('tests') - # Extract 'answer' and 'initial_display' from XML. Note that the code to be exec'ed here is: + # Extract 'answer' and 'initial_display' from XML. + # Note that the code to be exec'ed here is: # (1) Internal edX code, i.e. NOT student submissions, and # (2) The code should only define the strings 'initial_display', 'answer', # 'preamble', 'test_program' @@ -1391,7 +1411,8 @@ class CodeResponse(LoncapaResponse): " 'answer' and/or 'initial_display' in ..." % err) raise Exception(err) - # Finally, make the ExternalResponse input XML format conform to the generic + # Finally, make the ExternalResponse input XML format + # conform to the generic # exteral grader interface # The XML tagging of grader_payload is pyxserver-specific grader_payload = '' @@ -1500,7 +1521,8 @@ class CodeResponse(LoncapaResponse): # TODO: Find out how this is used elsewhere, if any self.context['correct'] = correctness - # Replace 'oldcmap' with new grading results if queuekey matches. If queuekey + # Replace 'oldcmap' with new grading results if queuekey matches. + # If queuekey # does not match, we keep waiting for the score_msg whose key actually # matches if oldcmap.is_right_queuekey(self.answer_id, queuekey): diff --git a/common/lib/capa/capa/tests/test_responsetypes.py b/common/lib/capa/capa/tests/test_responsetypes.py index 9ade578622..bf64d3cc69 100644 --- a/common/lib/capa/capa/tests/test_responsetypes.py +++ b/common/lib/capa/capa/tests/test_responsetypes.py @@ -905,6 +905,66 @@ class CustomResponseTest(ResponseTest): problem.grade_answers({'1_2_1': '42'}) + def test_module_imports_inline(self): + ''' + Check that the correct modules are available to custom + response scripts + ''' + + for module_name in ['random', 'numpy', 'math', 'scipy', + 'calc', 'eia', 'chemcalc', 'chemtools', + 'miller', 'draganddrop']: + + # Create a script that checks that the name is defined + # If the name is not defined, then the script + # will raise an exception + script = textwrap.dedent(''' + correct[0] = 'correct' + assert('%s' in globals())''' % module_name) + + # Create the problem + problem = self.build_problem(answer=script) + + # Expect that we can grade an answer without + # getting an exception + try: + problem.grade_answers({'1_2_1': '42'}) + + except ResponseError: + self.fail("Could not use name '%s' in custom response" + % module_name) + + def test_module_imports_function(self): + ''' + Check that the correct modules are available to custom + response scripts + ''' + + for module_name in ['random', 'numpy', 'math', 'scipy', + 'calc', 'eia', 'chemcalc', 'chemtools', + 'miller', 'draganddrop']: + + # Create a script that checks that the name is defined + # If the name is not defined, then the script + # will raise an exception + script = textwrap.dedent(''' + def check_func(expect, answer_given): + assert('%s' in globals()) + return True''' % module_name) + + # Create the problem + problem = self.build_problem(script=script, cfn="check_func") + + # Expect that we can grade an answer without + # getting an exception + try: + problem.grade_answers({'1_2_1': '42'}) + + except ResponseError: + self.fail("Could not use name '%s' in custom response" + % module_name) + + class SchematicResponseTest(ResponseTest): from response_xml_factory import SchematicResponseXMLFactory xml_factory_class = SchematicResponseXMLFactory From 42b4ffdcf9d29aad6ad9ff95940aef51d9303813 Mon Sep 17 00:00:00 2001 From: cahrens Date: Mon, 1 Apr 2013 13:40:32 -0400 Subject: [PATCH 241/264] Cleanup. --- .../contentstore/features/subsection.py | 2 +- cms/static/sass/views/_outline.scss | 2 +- cms/templates/edit_subsection.html | 17 +++++------------ cms/templates/overview.html | 11 ++++------- 4 files changed, 11 insertions(+), 21 deletions(-) diff --git a/cms/djangoapps/contentstore/features/subsection.py b/cms/djangoapps/contentstore/features/subsection.py index 234d2d81b0..0556fa48d5 100644 --- a/cms/djangoapps/contentstore/features/subsection.py +++ b/cms/djangoapps/contentstore/features/subsection.py @@ -3,7 +3,7 @@ from lettuce import world, step from common import * -from nose.tools import assert_equal, assert_true +from nose.tools import assert_equal ############### ACTIONS #################### diff --git a/cms/static/sass/views/_outline.scss b/cms/static/sass/views/_outline.scss index 0d72e2d2bf..e5a294467e 100644 --- a/cms/static/sass/views/_outline.scss +++ b/cms/static/sass/views/_outline.scss @@ -271,7 +271,7 @@ body.course.outline { .section-published-date { float: right; - width: 265px; + width: 278px; margin-right: 220px; @include border-radius(3px); background: $lightGrey; diff --git a/cms/templates/edit_subsection.html b/cms/templates/edit_subsection.html index ed316062b8..80385de829 100644 --- a/cms/templates/edit_subsection.html +++ b/cms/templates/edit_subsection.html @@ -1,9 +1,6 @@ <%inherit file="base.html" /> <%! - from time import mktime - import dateutil.parser import logging - from datetime import datetime from xmodule.util.date_utils import get_time_struct_display %> @@ -14,7 +11,6 @@ <%namespace name="units" file="widgets/units.html" /> <%namespace name='static' file='static_content.html'/> -<%namespace name='datetime' module='datetime'/> <%block name="content">
    @@ -39,18 +35,15 @@
    - <% - start_date = datetime.fromtimestamp(mktime(subsection.lms.start)) if subsection.lms.start is not None else None - parent_start_date = datetime.fromtimestamp(mktime(parent_item.lms.start)) if parent_item.lms.start is not None else None - %> - - + +
    % if subsection.lms.start != parent_item.lms.start and subsection.lms.start: - % if parent_start_date is None: + % if parent_item.lms.start is None:

    The date above differs from the release date of ${parent_item.display_name_with_default}, which is unset. % else: -

    The date above differs from the release date of ${parent_item.display_name_with_default} – ${parent_start_date.strftime('%m/%d/%Y')} at ${parent_start_date.strftime('%H:%M')}. +

    The date above differs from the release date of ${parent_item.display_name_with_default} – + ${get_time_struct_display(parent_item.lms.start, '%m/%d/%Y at %I:%M %p')}. % endif Sync to ${parent_item.display_name_with_default}.

    % endif diff --git a/cms/templates/overview.html b/cms/templates/overview.html index d45a90093e..04aae12f4a 100644 --- a/cms/templates/overview.html +++ b/cms/templates/overview.html @@ -1,9 +1,7 @@ <%inherit file="base.html" /> <%! - from time import mktime - import dateutil.parser import logging - from datetime import datetime + from xmodule.util.date_utils import get_time_struct_display %> <%! from django.core.urlresolvers import reverse %> <%block name="title">Course Outline @@ -163,11 +161,10 @@