diff --git a/README.md b/README.md index 3e3e5097..ec26fc3c 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ It supports: next step. Provides a link to the next step to the student. * **Tables**, which allow to present answers from the student to free-form answers in a concise way. Supports custom headers. +* **Dashboards**, for displaying a summary of the student's answers + to multiple choice questions. [Details](doc/Dashboard.md) The screenshot shows an example of a problem builder block containing a free-form question, two MCQs and one MRQ. @@ -55,183 +57,7 @@ settings. Usage ----- -When you add the `Problem Builder` component to a course in the studio, the -built-in editing tools guide you through the process of configuring the -block and adding individual questions. - -### Problem Builder modes - -There are 2 mentoring modes available: - -* *standard*: Traditional mentoring. All questions are displayed on the - page and submitted at the same time. The students get some tips and - feedback about their answers. This is the default mode. - -* *assessment*: Questions are displayed and submitted one by one. The - students don't get tips or feedback, but only know if their answer was - correct. Assessment mode comes with a default `max_attempts` of `2`. - -Below are some LMS screenshots of a problem builder block in assessment mode. - -Question before submitting an answer: - -![Assessment Step 1](doc/img/assessment-1.png) - -Question after submitting the correct answer: - -![Assessment Step 2](doc/img/assessment-2.png) - -Question after submitting a wrong answer: - -![Assessment Step 3](doc/img/assessment-3.png) - -Score review and the "Try Again" button: - -![Assessment Step 4](doc/img/assessment-4.png) - -### Free-form Question - -Free-form questions are represented by a "Long Answer" component. - -Example screenshot before answering the question: - -![Answer Initial](doc/img/answer-1.png) - -Screenshot after answering the question: - -![Answer Complete](doc/img/answer-2.png) - -You can add "Long Answer Recap" components to problem builder blocks later on -in the course to provide a read-only view of any answer that the student -entered earlier. - -The read-only answer is rendered as a quote in the LMS: - -![Answer Read-Only](doc/img/answer-3.png) - -### Multiple Choice Questions (MCQ) - -Multiple Choice Questions can be added to a problem builder component and -have the following configurable options: - -* Question - The question to ask the student -* Message - A feedback message to display to the student after they - have made their choice. -* Weight - The weight is used when computing total grade/score of - the problem builder block. The larger the weight, the more influence this - question will have on the grade. Value of zero means this question - has no influence on the grade (float, defaults to `1`). -* Correct Choice - Specify which choice[s] is considered correct. If - a student selects a choice that is not indicated as correct here, - the student will get the question wrong. - -Using the Studio editor, you can add "Custom Choice" blocks to the MCQ. -Each Custom Choice represents one of the options from which students -will choose their answer. - -You can also add "Tip" entries. Each "Tip" must be configured to link -it to one or more of the choices. If the student chooses a choice, the - - -Screenshot: Before attempting to answer the questions: - -![MCQ Initial](doc/img/mcq-1.png) - -While attempting to complete the questions: - -![MCQ Attempting](doc/img/mcq-2.png) - -After successfully completing the questions: - -![MCQ Success](doc/img/mcq-3.png) - -#### Rating MCQ - -When constructing questions where the student rates some topic on the -scale from `1` to `5` (e.g. a Likert Scale), you can use the Rating -question type, which includes built-in numbered choices from 1 to 5 -The `Low` and `High` settings specify the text shown next to the -lowest and highest valued choice. - -Rating questions are a specialized type of MCQ, and the same -instructions apply. You can also still add "Custom Choice" components -if you want additional choices to be available such as "I don't know". - - -### Self-assessment Multiple Response Questions (MRQ) - -Multiple Response Questions are set up similarly to MCQs. The answers -are rendered as checkboxes. Unlike MCQs where only a single answer can -be selected, MRQs allow multiple answers to be selected at the same -time. - -MRQ questions have these configurable settings: - -* Question - The question to ask the student -* Required Choices - For any choices selected here, if the student - does *not* select that choice, they will lose marks. -* Ignored Choices - For any choices selected here, the student will - always be considered correct whether they choose this choice or not. -* Message - A feedback message to display to the student after they - have made their choice. -* Weight - The weight is used when computing total grade/score of - the problem builder block. The larger the weight, the more influence this - question will have on the grade. Value of zero means this question - has no influence on the grade (float, defaults to `1`). -* Hide Result - If set to True, the feedback icons next to each - choice will not be displayed (This is false by default). - -The "Custom Choice" and "Tip" components work the same way as they -do when used with MCQs (see above). - -Screenshot - Before attempting to answer the questions: - -![MRQ Initial](doc/img/mrq-1.png) - -While attempting to answer the questions: - -![MRQ Attempt](doc/img/mrq-2.png) - -After clicking on the feedback icon next to the "Its bugs" answer: - -![MRQ Attempt](doc/img/mrq-3.png) - -After successfully completing the questions: - -![MRQ Success](doc/img/mrq-4.png) - -### Tables - -The problem builder table allows you to present answers to multiple -free-form questions in a concise way. Once you create an "Answer -Recap Table" inside a Mentoring component in Studio, you will be -able to add columns to the table. Each column has an optional -"Header" setting that you can use to add a header to that column. -Each column can contain one or more "Answer Recap" element, as -well as HTML components. - -Screenshot: - -![Table Screenshot](doc/img/mentoring-table.png) - -### Maximum Attempts - -You can set the number of maximum attempts for the unit completion by -setting the Max. Attempts option of the Mentoring component. - -Before submitting an answer for the first time: - -![Max Attempts Before](doc/img/max-attempts-before.png) - -After submitting a wrong answer two times: - -![Max Attempts Reached](doc/img/max-attempts-reached.png) - -### Custom tip popup window size - -You can specify With and Height attributes of any Tip component to -customize the popup window size. The value of those attribute should -be valid CSS (e.g. `50px`). +See [Usage Instructions](doc/Usage.md) Workbench installation and settings ----------------------------------- diff --git a/doc/Dashboard.md b/doc/Dashboard.md new file mode 100644 index 00000000..9984d02b --- /dev/null +++ b/doc/Dashboard.md @@ -0,0 +1,82 @@ +"Dashboard" Self-Assessment Summary Block +========================================= + +A "Dashboard" XBlock provides a concise way to summarize a student's answers to +groups of multiple choice questions. + +You configure it like this, by pasting in the `url_name`s of some Problem +Builder ("Mentoring") blocks: +![Screen shot of Dashboard XBlock configuration](img/dashboard-configuration.png) + + +And it will then look like this (after a student has submitted their answers): +![Screen shot of a Dashboard XBlock](img/dashboard-example.png) + + +Color Coding Rules: +------------------- + +Authors can add a list of rules (one per line) that apply colors to the various +possible student answer values. + +Rules are entered into the dashboard configuration "Color Coding Rules", one +rule per line. The first rule to match a given value will be used to color +that value. + +The simplest rule looks like "3: red". With this line, if the student's answer +is 3, it will be shown in red in the report. Colors can be specified using any +valid CSS color value, e.g. "red", "#f00", "#ff0000", "rgb(255,0,0)", etc. + +For more advanced rules, you can use an expression in terms of x such as +"x > 3: blue" or "0 <= x < 5: green" (green if x is greater than or equal to +zero but less than five). + +You can also just specify a color on a line by itself, which will always match +any value (usually this would be the last line, as a "default" color). + + +Visual Representation +--------------------- + +The Dashboard also supports an optional visual representation. This is a +powerful feature, but setting it up is a bit involved. + +The end result is shown below. You can see a diagram, in which a colored arrow +appears as the student works through the various "Steps". Each "Step" is one +mentoring block, which contains several multiple choice questions. Based on the +average value of the student's choices, the step is given a color. + +In this example, steps that have not been attempted are grey-green, and steps +that have been attempted are colored in according to the color coding rules. + +![Screen shot of visual representation](img/dashboard-visual.png) + +To achieve the result shown above requires a set of "stacked" image files (one +for each step), as well as an overlay image (in this case, the overlay image +contains all the text). For coloring to work, the images must be white where +color is desired. + +To build this example, the images used look like this: +![Images Used](img/dashboard-visual-instructions.png) + +The block was configured to use these images as follows: +![Screen shot of visual representation rule configuration](img/dashboard-visual-config.png) + +The **Visual Representation Settings** used to define the visual representation +must be in JSON format. The supported entries are: + +* **`"images"`**: A list of image URLs, one per PB block, in the same order as + the 'blocks' list (the list of `url_name`s described above). If the images you + wish to use are on your computer, first upload them to the course's "Files and + Uploads" page. You can then find the URL for each image listed on that page + (use the "Studio" URL, not the "Web" URL). + * All images listed here will be layered on top of each other, and can be + colorized, faded, etc. based on the average value of the student's choices + for the corresponding group of MCQs. +* **`"overlay"`**: (Optional) The URL of an image to be drawn on top of the + layered images, with no effects applied. +* **`"background"`**: (Optional) The URL of an image to be drawn behind the + layered images, with no effects applied. +* **`"width"`**: (Important) The width of the images, in pixels (all images + should be the same size). +* **`"height"`**: (Important) The height of the images, in pixels diff --git a/doc/Usage.md b/doc/Usage.md new file mode 100644 index 00000000..3bf75868 --- /dev/null +++ b/doc/Usage.md @@ -0,0 +1,184 @@ +Mentoring Block Usage +===================== + +When you add the `Problem Builder` component to a course in the studio, the +built-in editing tools guide you through the process of configuring the +block and adding individual questions. + +### Problem Builder modes + +There are 2 mentoring modes available: + +* *standard*: Traditional mentoring. All questions are displayed on the + page and submitted at the same time. The students get some tips and + feedback about their answers. This is the default mode. + +* *assessment*: Questions are displayed and submitted one by one. The + students don't get tips or feedback, but only know if their answer was + correct. Assessment mode comes with a default `max_attempts` of `2`. + +Below are some LMS screenshots of a problem builder block in assessment mode. + +Question before submitting an answer: + +![Assessment Step 1](img/assessment-1.png) + +Question after submitting the correct answer: + +![Assessment Step 2](img/assessment-2.png) + +Question after submitting a wrong answer: + +![Assessment Step 3](img/assessment-3.png) + +Score review and the "Try Again" button: + +![Assessment Step 4](img/assessment-4.png) + +### Free-form Question + +Free-form questions are represented by a "Long Answer" component. + +Example screenshot before answering the question: + +![Answer Initial](img/answer-1.png) + +Screenshot after answering the question: + +![Answer Complete](img/answer-2.png) + +You can add "Long Answer Recap" components to problem builder blocks later on +in the course to provide a read-only view of any answer that the student +entered earlier. + +The read-only answer is rendered as a quote in the LMS: + +![Answer Read-Only](img/answer-3.png) + +### Multiple Choice Questions (MCQ) + +Multiple Choice Questions can be added to a problem builder component and +have the following configurable options: + +* Question - The question to ask the student +* Message - A feedback message to display to the student after they + have made their choice. +* Weight - The weight is used when computing total grade/score of + the problem builder block. The larger the weight, the more influence this + question will have on the grade. Value of zero means this question + has no influence on the grade (float, defaults to `1`). +* Correct Choice - Specify which choice[s] is considered correct. If + a student selects a choice that is not indicated as correct here, + the student will get the question wrong. + +Using the Studio editor, you can add "Custom Choice" blocks to the MCQ. +Each Custom Choice represents one of the options from which students +will choose their answer. + +You can also add "Tip" entries. Each "Tip" must be configured to link +it to one or more of the choices. If the student chooses a choice, the + + +Screenshot: Before attempting to answer the questions: + +![MCQ Initial](img/mcq-1.png) + +While attempting to complete the questions: + +![MCQ Attempting](img/mcq-2.png) + +After successfully completing the questions: + +![MCQ Success](img/mcq-3.png) + +#### Rating MCQ + +When constructing questions where the student rates some topic on the +scale from `1` to `5` (e.g. a Likert Scale), you can use the Rating +question type, which includes built-in numbered choices from 1 to 5 +The `Low` and `High` settings specify the text shown next to the +lowest and highest valued choice. + +Rating questions are a specialized type of MCQ, and the same +instructions apply. You can also still add "Custom Choice" components +if you want additional choices to be available such as "I don't know". + + +### Self-assessment Multiple Response Questions (MRQ) + +Multiple Response Questions are set up similarly to MCQs. The answers +are rendered as checkboxes. Unlike MCQs where only a single answer can +be selected, MRQs allow multiple answers to be selected at the same +time. + +MRQ questions have these configurable settings: + +* Question - The question to ask the student +* Required Choices - For any choices selected here, if the student + does *not* select that choice, they will lose marks. +* Ignored Choices - For any choices selected here, the student will + always be considered correct whether they choose this choice or not. +* Message - A feedback message to display to the student after they + have made their choice. +* Weight - The weight is used when computing total grade/score of + the problem builder block. The larger the weight, the more influence this + question will have on the grade. Value of zero means this question + has no influence on the grade (float, defaults to `1`). +* Hide Result - If set to True, the feedback icons next to each + choice will not be displayed (This is false by default). + +The "Custom Choice" and "Tip" components work the same way as they +do when used with MCQs (see above). + +Screenshot - Before attempting to answer the questions: + +![MRQ Initial](img/mrq-1.png) + +While attempting to answer the questions: + +![MRQ Attempt](img/mrq-2.png) + +After clicking on the feedback icon next to the "Its bugs" answer: + +![MRQ Attempt](img/mrq-3.png) + +After successfully completing the questions: + +![MRQ Success](img/mrq-4.png) + +### Tables + +The problem builder table allows you to present answers to multiple +free-form questions in a concise way. Once you create an "Answer +Recap Table" inside a Mentoring component in Studio, you will be +able to add columns to the table. Each column has an optional +"Header" setting that you can use to add a header to that column. +Each column can contain one or more "Answer Recap" element, as +well as HTML components. + +Screenshot: + +![Table Screenshot](img/mentoring-table.png) + +### Maximum Attempts + +You can set the number of maximum attempts for the unit completion by +setting the Max. Attempts option of the Mentoring component. + +Before submitting an answer for the first time: + +![Max Attempts Before](img/max-attempts-before.png) + +After submitting a wrong answer two times: + +![Max Attempts Reached](img/max-attempts-reached.png) + +### Custom tip popup window size + +You can specify With and Height attributes of any Tip component to +customize the popup window size. The value of those attribute should +be valid CSS (e.g. `50px`). + +### "Dashboard" Self-Assessment Summary Block + +[Instructions for using the "Dashboard" Self-Assessment Summary Block](Dashboard.md) diff --git a/doc/img/dashboard-configuration.png b/doc/img/dashboard-configuration.png new file mode 100644 index 00000000..e1f2f9cf Binary files /dev/null and b/doc/img/dashboard-configuration.png differ diff --git a/doc/img/dashboard-example.png b/doc/img/dashboard-example.png new file mode 100644 index 00000000..7e2fffdd Binary files /dev/null and b/doc/img/dashboard-example.png differ diff --git a/doc/img/dashboard-visual-config.png b/doc/img/dashboard-visual-config.png new file mode 100644 index 00000000..b3fa5184 Binary files /dev/null and b/doc/img/dashboard-visual-config.png differ diff --git a/doc/img/dashboard-visual-instructions.png b/doc/img/dashboard-visual-instructions.png new file mode 100644 index 00000000..916fc981 Binary files /dev/null and b/doc/img/dashboard-visual-instructions.png differ diff --git a/doc/img/dashboard-visual.png b/doc/img/dashboard-visual.png new file mode 100644 index 00000000..a36f0972 Binary files /dev/null and b/doc/img/dashboard-visual.png differ diff --git a/problem_builder/__init__.py b/problem_builder/__init__.py index 03781172..cf1ee771 100644 --- a/problem_builder/__init__.py +++ b/problem_builder/__init__.py @@ -1,6 +1,7 @@ from .mentoring import MentoringBlock from .answer import AnswerBlock, AnswerRecapBlock from .choice import ChoiceBlock +from .dashboard import DashboardBlock from .mcq import MCQBlock, RatingBlock from .mrq import MRQBlock from .message import MentoringMessageBlock diff --git a/problem_builder/answer.py b/problem_builder/answer.py index c4584886..f09016d5 100644 --- a/problem_builder/answer.py +++ b/problem_builder/answer.py @@ -141,13 +141,7 @@ class AnswerBlock(AnswerMixin, StepMixin, StudioEditableXBlockMixin, XBlock): enforce_type=True ) - editable_fields = ('question', 'name', 'min_characters', 'weight', 'default_from') - - @property - def display_name_with_default(self): - if not self.lonely_step: - return self._(u"Question {number}").format(number=self.step_number) - return self._(u"Question") + editable_fields = ('question', 'name', 'min_characters', 'weight', 'default_from', 'display_name', 'show_title') @lazy def student_input(self): @@ -172,6 +166,7 @@ def mentoring_view(self, context=None): """ Render this XBlock within a mentoring block. """ context = context or {} context['self'] = self + context['hide_header'] = context.get('hide_header', False) or not self.show_title html = loader.render_template('templates/html/answer_editable.html', context) fragment = Fragment(html) diff --git a/problem_builder/choice.py b/problem_builder/choice.py index 37daac69..69a3525e 100644 --- a/problem_builder/choice.py +++ b/problem_builder/choice.py @@ -44,7 +44,7 @@ class ChoiceBlock(StudioEditableXBlockMixin, XBlock): """ value = String( display_name=_("Value"), - help=_("Value of the choice when selected. Should be unique."), + help=_("Value of the choice when selected. Should be unique. Generally you do not need to edit this."), scope=Scope.content, default="", ) @@ -54,7 +54,7 @@ class ChoiceBlock(StudioEditableXBlockMixin, XBlock): scope=Scope.content, default="", ) - editable_fields = ('content', ) + editable_fields = ('content', 'value') def _(self, text): """ translate text """ diff --git a/problem_builder/dashboard.py b/problem_builder/dashboard.py new file mode 100644 index 00000000..57c36bbd --- /dev/null +++ b/problem_builder/dashboard.py @@ -0,0 +1,422 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2014-2015 Harvard, edX & OpenCraft +# +# This software's license gives you freedom; you can copy, convey, +# propagate, redistribute and/or modify this program under the terms of +# the GNU Affero General Public License (AGPL) as published by the Free +# Software Foundation (FSF), either version 3 of the License, or (at your +# option) any later version of the AGPL published by the FSF. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero +# General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program in a file in the toplevel directory called +# "AGPLv3". If not, see . +# +""" +Dashboard: Summarize the results of self-assessments done by a student with Problem Builder MCQ +blocks. + +The author of this block specifies a list of Problem Builder blocks containing MCQs. This block +will then display a table summarizing the values that the student chose for each of those MCQs. +""" +# Imports ########################################################### + +import ast +import json +import logging +import operator as op + +from .dashboard_visual import DashboardVisualData +from .mcq import MCQBlock +from .sub_api import sub_api +from lazy import lazy +from xblock.core import XBlock +from xblock.fields import Scope, List, String +from xblock.fragment import Fragment +from xblock.validation import ValidationMessage +from xblockutils.helpers import child_isinstance +from xblockutils.resources import ResourceLoader +from xblockutils.studio_editable import StudioEditableXBlockMixin + + +# Globals ########################################################### + +log = logging.getLogger(__name__) +loader = ResourceLoader(__name__) + + +def _(text): + """ A no-op to mark strings that we need to translate """ + return text + +# Classes ########################################################### + + +class ColorRule(object): + """ + A rule used to conditionally set colors + + >>> rule1 = ColorRule("3 < x <= 5", color_str="#ff0000") + >>> rule1.matches(2) + False + >>> rule1.matches(4) + True + """ + def __init__(self, rule_str, color_str): + """ + Instantiate a ColorRule with the given rule expression string and color value. + """ + try: + self._rule_parsed = ast.parse(rule_str, mode='eval').body + # Once it's been parsed, also try evaluating it with a test value: + self._safe_eval_expression(self._rule_parsed, x=0) + except (TypeError, SyntaxError) as e: + raise ValueError("Invalid Expression: {}".format(e)) + except ZeroDivisionError: + pass # This may depend on the value of 'x' which we set to zero but don't know yet. + self.color_str = color_str + + def matches(self, x): + """ Does this rule apply for the value x? """ + try: + return bool(self._safe_eval_expression(self._rule_parsed, x)) + except ZeroDivisionError: + return False + + @staticmethod + def _safe_eval_expression(expr, x=0): + """ + Safely evaluate a mathematical or boolean expression involving the value x + + >>> _safe_eval_expression('2*x', x=3) + 6 + >>> _safe_eval_expression('x >= 0 and x < 2', x=3) + False + + We use python syntax for the expressions, so the parsing and evaluation is mostly done + using Python's built-in asbstract syntax trees and operator implementations. + + The expression can only contain: numbers, mathematical operators, boolean operators, + comparisons, and a placeholder variable called "x" + """ + # supported operators: + operators = { + # Allow +, -, *, /, %, negative: + ast.Add: op.add, ast.Sub: op.sub, ast.Mult: op.mul, ast.Div: op.truediv, ast.Mod: op.mod, ast.USub: op.neg, + # Allow comparison: + ast.Eq: op.eq, ast.NotEq: op.ne, ast.Lt: op.lt, ast.LtE: op.le, ast.Gt: op.gt, ast.GtE: op.ge, + } + + def eval_(node): + """ Recursive evaluation of syntax tree node 'node' """ + if isinstance(node, ast.Num): # + return node.n + elif isinstance(node, ast.BinOp): # + return operators[type(node.op)](eval_(node.left), eval_(node.right)) + elif isinstance(node, ast.UnaryOp): # e.g., -1 + return operators[type(node.op)](eval_(node.operand)) + elif isinstance(node, ast.Name) and node.id == "x": + return x + elif isinstance(node, ast.BoolOp): # Boolean operator: either "and" or "or" with two or more values + if type(node.op) == ast.And: + return all(eval_(val) for val in node.values) + else: # Or: + for val in node.values: + result = eval_(val) + if result: + return result + return result # or returns the final value even if it's falsy + elif isinstance(node, ast.Compare): # A comparison expression, e.g. "3 > 2" or "5 < x < 10" + left = eval_(node.left) + for comparison_op, right_expr in zip(node.ops, node.comparators): + right = eval_(right_expr) + if not operators[type(comparison_op)](left, right): + return False + left = right + return True + else: + raise TypeError(node) + + if not isinstance(expr, ast.AST): + expr = ast.parse(expr, mode='eval').body + return eval_(expr) + + +class InvalidUrlName(ValueError): + """ Exception raised by DashboardBlock.get_mentoring_blocks() if a url_name is invalid """ + pass + + +@XBlock.needs("i18n") +@XBlock.wants("user") +class DashboardBlock(StudioEditableXBlockMixin, XBlock): + """ + A block to summarize self-assessment results. + """ + display_name = String( + display_name=_("Display Name"), + help=_("Display name for this module"), + scope=Scope.settings, + default=_('Self-Assessment Summary'), + ) + mentoring_ids = List( + display_name=_("Mentoring Blocks"), + help=_( + "This should be an ordered list of the url_names of each mentoring block whose multiple choice question " + "values are to be shown on this dashboard. The list should be in JSON format. Example: {example_here}" + ).format(example_here='["2754b8afc03a439693b9887b6f1d9e36", "215028f7df3d4c68b14fb5fea4da7053"]'), + scope=Scope.settings, + ) + color_rules = String( + display_name=_("Color Coding Rules"), + help=_( + "Optional rules to assign colors to possible answer values and average values. " + "One rule per line. First matching rule will be used. Light colors are recommended. " + "Examples: {examples_here}" + ).format(examples_here='"1: LightCoral", "0 <= x < 5: LightBlue", "LightGreen"'), + scope=Scope.content, + default="", + multiline_editor=True, + resettable_editor=False, + ) + visual_rules = String( + display_name=_("Visual Representation"), + default="", + help=_("Optional: Enter the JSON configuration of the visual representation desired (Advanced)."), + scope=Scope.content, + multiline_editor=True, + resettable_editor=False, + ) + visual_title = String( + display_name=_("Visual Representation Title"), + default=_("Visual Representation"), + help=_("This text is not displayed visually but is exposed to screen reader users who may not see the image."), + scope=Scope.content, + ) + visual_desc = String( + display_name=_("Visual Repr. Description"), + default=_("The data represented in this image is available in the tables below."), + help=_( + "This longer description is not displayed visually but is exposed to screen reader " + "users who may not see the image." + ), + scope=Scope.content, + ) + + editable_fields = ('display_name', 'mentoring_ids', 'color_rules', 'visual_rules', 'visual_title', 'visual_desc') + css_path = 'public/css/dashboard.css' + js_path = 'public/js/dashboard.js' + + def get_mentoring_blocks(self, mentoring_ids, ignore_errors=True): + """ + Generator returning the specified mentoring blocks, in order. + + Will yield None for every invalid mentoring block ID, or if + ignore_errors is False, will raise InvalidUrlName. + """ + for url_name in mentoring_ids: + try: + mentoring_id = self.scope_ids.usage_id.course_key.make_usage_key('problem-builder', url_name) + yield self.runtime.get_block(mentoring_id) + except Exception: # Catch-all b/c we could get XBlockNotFoundError, ItemNotFoundError, InvalidKeyError, ... + # Maybe it's using the deprecated block type "mentoring": + try: + mentoring_id = self.scope_ids.usage_id.course_key.make_usage_key('mentoring', url_name) + yield self.runtime.get_block(mentoring_id) + except Exception: + if ignore_errors: + yield None + else: + raise InvalidUrlName(url_name) + + def parse_color_rules_str(self, color_rules_str, ignore_errors=True): + """ + Parse the color rules. Returns a list of ColorRule objects. + + Color rules are like: "0 < x < 4: red" or "blue" (for a catch-all rule) + """ + rules = [] + for lineno, line in enumerate(color_rules_str.splitlines()): + line = line.strip() + if line: + try: + if ":" in line: + condition, value = line.split(':') + value = value.strip() + if condition.isnumeric(): # A condition just listed as an exact value + condition = "x == " + condition + else: + condition = "1" # Always true + value = line + rules.append(ColorRule(condition, value)) + except ValueError: + if ignore_errors: + continue + raise ValueError( + _("Invalid color rule on line {line_number}").format(line_number=lineno + 1) + ) + return rules + + @lazy + def color_rules_parsed(self): + """ + Caching property to get parsed color rules. Returns a list of ColorRule objects. + """ + return self.parse_color_rules_str(self.color_rules) if self.color_rules else [] + + def _get_submission_key(self, usage_key): + """ + Given the usage_key of an MCQ block, get the dict key needed to look it up with the + submissions API. + """ + return dict( + student_id=self.runtime.anonymous_student_id, + course_id=unicode(usage_key.course_key), + item_id=unicode(usage_key), + item_type=usage_key.block_type, + ) + + def color_for_value(self, value): + """ Given a string value, get the color rule that matches, if any """ + if isinstance(value, basestring): + if value.isnumeric(): + value = float(value) + else: + return None + for rule in self.color_rules_parsed: + if rule.matches(value): + return rule.color_str + return None + + def _get_user_full_name(self): + """ + Get the full name of the current user, for the downloadable report. + """ + user_service = self.runtime.service(self, 'user') + if user_service: + return user_service.get_current_user().full_name + return "" + + def _get_course_name(self): + """ + Get the name of the current course, for the downloadable report. + """ + try: + course_key = self.scope_ids.usage_id.course_key + except AttributeError: + return "" # We are not in an edX runtime + try: + course_root_key = course_key.make_usage_key('course', 'course') + return self.runtime.get_block(course_root_key).display_name + except Exception: # ItemNotFoundError most likely, but we can't import that exception in non-edX environments + # We may be on old mongo: + try: + course_root_key = course_key.make_usage_key('course', course_key.run) + return self.runtime.get_block(course_root_key).display_name + except Exception: + return "" + + def student_view(self, context=None): # pylint: disable=unused-argument + """ + Standard view of this XBlock. + """ + if not self.mentoring_ids: + return Fragment(u"

{}

{}

".format(self.display_name, _("Not configured."))) + + blocks = [] + for mentoring_block in self.get_mentoring_blocks(self.mentoring_ids): + if mentoring_block is None: + continue + block = { + 'display_name': mentoring_block.display_name, + 'mcqs': [] + } + for child_id in mentoring_block.children: + if child_isinstance(mentoring_block, child_id, MCQBlock): + # Get the student's submitted answer to this MCQ from the submissions API: + mcq_block = self.runtime.get_block(child_id) + mcq_submission_key = self._get_submission_key(child_id) + try: + value = sub_api.get_submissions(mcq_submission_key, limit=1)[0]["answer"] + except IndexError: + value = None + block['mcqs'].append({ + "display_name": mcq_block.display_name_with_default, + "value": value, + "color": self.color_for_value(value) if value is not None else None, + }) + # If the values are numeric, display an average: + numeric_values = [ + float(mcq['value']) for mcq in block['mcqs'] + if mcq['value'] is not None and mcq['value'].isnumeric() + ] + if numeric_values: + average_value = sum(numeric_values) / len(numeric_values) + block['average'] = average_value + block['has_average'] = True + block['average_color'] = self.color_for_value(average_value) + blocks.append(block) + + visual_repr = None + if self.visual_rules: + try: + rules_parsed = json.loads(self.visual_rules) + except ValueError: + pass # JSON errors should be shown as part of validation + else: + visual_repr = DashboardVisualData( + blocks, rules_parsed, self.color_for_value, self.visual_title, self.visual_desc + ) + + report_template = loader.render_template('templates/html/dashboard_report.html', { + 'title': self.display_name, + 'css': loader.load_unicode(self.css_path), + 'student_name': self._get_user_full_name(), + 'course_name': self._get_course_name(), + }) + + html = loader.render_template('templates/html/dashboard.html', { + 'blocks': blocks, + 'display_name': self.display_name, + 'visual_repr': visual_repr, + }) + + fragment = Fragment(html) + fragment.add_css_url(self.runtime.local_resource_url(self, self.css_path)) + fragment.add_javascript_url(self.runtime.local_resource_url(self, self.js_path)) + fragment.initialize_js('PBDashboardBlock', {'reportTemplate': report_template}) + return fragment + + def validate_field_data(self, validation, data): + """ + Validate this block's field data. + """ + super(DashboardBlock, self).validate_field_data(validation, data) + + def add_error(msg): + validation.add(ValidationMessage(ValidationMessage.ERROR, msg)) + + try: + list(self.get_mentoring_blocks(data.mentoring_ids, ignore_errors=False)) + except InvalidUrlName as e: + add_error(_(u'Invalid block url_name given: "{bad_url_name}"').format(bad_url_name=unicode(e))) + + if data.color_rules: + try: + self.parse_color_rules_str(data.color_rules, ignore_errors=False) + except ValueError as e: + add_error(unicode(e)) + + if data.visual_rules: + try: + rules = json.loads(data.visual_rules) + except ValueError as e: + add_error(_(u"Visual rules contains an error: {error}").format(error=e)) + else: + if not isinstance(rules, dict): + add_error(_(u"Visual rules should be a JSON dictionary/object: {...}")) diff --git a/problem_builder/dashboard_visual.py b/problem_builder/dashboard_visual.py new file mode 100644 index 00000000..4e5e51b7 --- /dev/null +++ b/problem_builder/dashboard_visual.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2014-2015 Harvard, edX & OpenCraft +# +# This software's license gives you freedom; you can copy, convey, +# propagate, redistribute and/or modify this program under the terms of +# the GNU Affero General Public License (AGPL) as published by the Free +# Software Foundation (FSF), either version 3 of the License, or (at your +# option) any later version of the AGPL published by the FSF. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero +# General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program in a file in the toplevel directory called +# "AGPLv3". If not, see . +# +""" +Visual Representation of Dashboard State. + +Consists of a series of images, layered on top of each other, where the appearance of each layer +can be tied to the average value of the student's response to a particular Problem Builder +block. + +For example, each layer can have its color turn green if the student's average value on MCQs in +a specific Problem Builder block was at least 3. +""" + + +class DashboardVisualData(object): + """ + Data about the visual representation of a dashboard. + """ + def __init__(self, blocks, rules, color_for_value, title, desc): + """ + Construct the data required for the optional visual representation of the dashboard. + + Data format accepted for rules is like: + { + "images": [ + "/static/step1.png", + "/static/step2.png", + "/static/step3.png", + "/static/step4.png", + "/static/step5.png", + "/static/step6.png", + "/static/step7.png" + ], + "background": "/static/background.png", + "overlay": "/static/overlay.png", + "width": "500", + "height": "500" + } + + color_for_value is a method that, given a value, returns a color string or None + """ + # Images is a list of images, one per PB block, in the same order as 'blocks' + # All images are rendered layered on top of each other, and can be hidden, + # shown, colorized, faded, etc. based on the average answer value for that PB block. + images = rules.get("images", []) + # Overlay is an optional images drawn on top, with no effects applied + overlay = rules.get("overlay") + # Background is an optional images drawn on the bottom, with no effects applied + background = rules.get("background") + # Width and height of the image: + self.width = int(rules.get("width", 400)) + self.height = int(rules.get("height", 400)) + # A unique ID used by the HTML template: + self.unique_id = id(self) + # Alternate dsescription for screen reader users: + self.title = title + self.desc = desc + + self.layers = [] + if background: + self.layers.append({"url": background}) + for idx, block in enumerate(blocks): + if not block.get("has_average"): + continue # We only use blocks with numeric averages for the visual representation + # Now we build the 'layer_data' information to pass on to the template: + try: + layer_data = {"url": images[idx], "id": "layer{}".format(idx)} + except IndexError: + break + + # Check if a color rule applies: + layer_data["color"] = color_for_value(block["average"]) + self.layers.append(layer_data) + + if overlay: + self.layers.append({"url": overlay}) diff --git a/problem_builder/mcq.py b/problem_builder/mcq.py index f0ea16cf..8782744a 100644 --- a/problem_builder/mcq.py +++ b/problem_builder/mcq.py @@ -28,6 +28,7 @@ from xblockutils.resources import ResourceLoader from .questionnaire import QuestionnaireAbstractBlock +from .sub_api import sub_api, SubmittingXBlockMixin # Globals ########################################################### @@ -43,7 +44,7 @@ def _(text): # Classes ########################################################### -class MCQBlock(QuestionnaireAbstractBlock): +class MCQBlock(SubmittingXBlockMixin, QuestionnaireAbstractBlock): """ An XBlock used to ask multiple-choice questions """ @@ -88,6 +89,11 @@ def submit(self, submission): }) self.student_choice = submission + + if sub_api: + # Also send to the submissions API: + sub_api.create_submission(self.student_item_key, submission) + result = { 'submission': submission, 'status': 'correct' if correct else 'incorrect', diff --git a/problem_builder/mrq.py b/problem_builder/mrq.py index 44ff13c4..0fec59ca 100644 --- a/problem_builder/mrq.py +++ b/problem_builder/mrq.py @@ -69,7 +69,10 @@ class MRQBlock(QuestionnaireAbstractBlock): default=[], ) hide_results = Boolean(display_name="Hide results", scope=Scope.content, default=False) - editable_fields = ('question', 'required_choices', 'ignored_choices', 'message', 'weight', 'hide_results', ) + editable_fields = ( + 'question', 'required_choices', 'ignored_choices', 'message', 'display_name', + 'show_title', 'weight', 'hide_results', + ) def describe_choice_correctness(self, choice_value): if choice_value in self.required_choices: diff --git a/problem_builder/public/css/dashboard.css b/problem_builder/public/css/dashboard.css new file mode 100644 index 00000000..ba6b068e --- /dev/null +++ b/problem_builder/public/css/dashboard.css @@ -0,0 +1,32 @@ +.pb-dashboard table { + max-width: 800px; + border-collapse: collapse; + margin-bottom: 15px; +} + +.pb-dashboard table thead th { + padding-top: 0.6em; + font-weight: bold; +} + +.pb-dashboard table td, .pb-dashboard table tbody th { + border-top: 1px solid #ddd; + border-bottom: 1px solid #ddd; + padding: 0.2em; + font-weight: normal; +} + +.pb-dashboard table .desc { + min-width: 350px; +} + +.pb-dashboard table td.value { + min-width: 4em; + text-align: right; + padding-right: 5px; + border-right: 0.6em solid transparent; +} + +.pb-dashboard table .avg-row td.desc { + font-style: italic; +} diff --git a/problem_builder/public/js/dashboard.js b/problem_builder/public/js/dashboard.js new file mode 100644 index 00000000..fdd4ef99 --- /dev/null +++ b/problem_builder/public/js/dashboard.js @@ -0,0 +1,51 @@ +// Client side code for the Problem Builder Dashboard XBlock +// So far, this code is only used to generate a downloadable report. +function PBDashboardBlock(runtime, element, initData) { + "use strict"; + + var reportTemplate = initData.reportTemplate; + + var generateDataUriFromImageURL = function(imgURL) { + // Given the URL to an image, IF the image has already been cached by the browser, + // returns a data: URI with the contents of the image (image will be converted to PNG) + var img = new Image(); + img.src = imgURL; + if (!img.complete) + return imgURL; + + // Create an in-memory canvas from which we can extract a data URL: + var canvas = document.createElement("canvas"); + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + // Draw the image onto our temporary canvas: + canvas.getContext('2d').drawImage(img, 0, 0); + return canvas.toDataURL("image/png"); + }; + + var unicodeStringToBase64 = function(str) { + // Convert string to base64. A bit weird in order to support unicode, per + // https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/btoa + return window.btoa(unescape(encodeURIComponent(str))); + }; + + var downloadReport = function(ev) { + // Download Report: + // Change the URL to a data: URI before continuing with the click event. + if ($(this).attr('href').charAt(0) == '#') { + var $report = $('.dashboard-report', element).clone(); + // Convert all images in $report to data URIs: + $report.find('image').each(function() { + var origURL = $(this).attr('xlink:href'); + $(this).attr('xlink:href', generateDataUriFromImageURL(origURL)); + }); + // Take the resulting HTML and put it into the template we have: + var wrapperHTML = reportTemplate.replace('REPORT_GOES_HERE', $report.html()); + //console.log(wrapperHTML); + var dataURI = "data:text/html;base64," + unicodeStringToBase64(wrapperHTML); + $(this).attr('href', dataURI); + } + }; + + var $downloadLink = $('.report-download-link', element); + $downloadLink.on('click', downloadReport); +} diff --git a/problem_builder/questionnaire.py b/problem_builder/questionnaire.py index 34feed39..f4f36f84 100644 --- a/problem_builder/questionnaire.py +++ b/problem_builder/questionnaire.py @@ -81,7 +81,7 @@ class QuestionnaireAbstractBlock(StudioEditableXBlockMixin, StudioContainerXBloc scope=Scope.content, enforce_type=True ) - editable_fields = ('question', 'message', 'weight') + editable_fields = ('question', 'message', 'weight', 'display_name', 'show_title') has_children = True def _(self, text): @@ -113,12 +113,6 @@ def parse_xml(cls, node, runtime, keys, id_generator): return block - @property - def display_name_with_default(self): - if not self.lonely_step: - return self._(u"Question {number}").format(number=self.step_number) - return self._(u"Question") - def student_view(self, context=None): name = getattr(self, "unmixed_class", self.__class__).__name__ @@ -127,6 +121,7 @@ def student_view(self, context=None): context = context or {} context['self'] = self context['custom_choices'] = self.custom_choices + context['hide_header'] = context.get('hide_header', False) or not self.show_title fragment = Fragment(loader.render_template(template_path, context)) # If we use local_resource_url(self, ...) the runtime may insert many identical copies diff --git a/problem_builder/step.py b/problem_builder/step.py index 49ff2f77..1b386d97 100644 --- a/problem_builder/step.py +++ b/problem_builder/step.py @@ -19,9 +19,15 @@ # from lazy import lazy +from xblock.fields import String, Boolean, Scope from xblockutils.helpers import child_isinstance +# Make '_' a no-op so we can scrape strings +def _(text): + return text + + def _normalize_id(key): """ Helper method to normalize a key to avoid issues where some keys have version/branch and others don't. @@ -49,10 +55,26 @@ def steps(self): class StepMixin(object): """ - An XBlock mixin for a child block that is a "Step" + An XBlock mixin for a child block that is a "Step". + + A step is a question that the user can answer (as opposed to a read-only child). """ has_author_view = True + # Fields: + display_name = String( + display_name=_("Question title"), + help=_('Leave blank to use the default ("Question 1", "Question 2", etc.)'), + default="", # Blank will use 'Question x' - see display_name_with_default + scope=Scope.content + ) + show_title = Boolean( + display_name=_("Show title"), + help=_("Display the title?"), + default=True, + scope=Scope.content + ) + @lazy def step_number(self): return list(self.get_parent().steps).index(_normalize_id(self.scope_ids.usage_id)) + 1 @@ -63,6 +85,15 @@ def lonely_step(self): raise ValueError("Step's parent should contain Step", self, self.get_parent().steps) return len(self.get_parent().steps) == 1 + @property + def display_name_with_default(self): + """ Get the title/display_name of this question. """ + if self.display_name: + return self.display_name + if not self.lonely_step: + return self._(u"Question {number}").format(number=self.step_number) + return self._(u"Question") + def author_view(self, context): context = context or {} context['hide_header'] = True diff --git a/problem_builder/sub_api.py b/problem_builder/sub_api.py new file mode 100644 index 00000000..e63d1af7 --- /dev/null +++ b/problem_builder/sub_api.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2014-2015 Harvard, edX & OpenCraft +# +# This software's license gives you freedom; you can copy, convey, +# propagate, redistribute and/or modify this program under the terms of +# the GNU Affero General Public License (AGPL) as published by the Free +# Software Foundation (FSF), either version 3 of the License, or (at your +# option) any later version of the AGPL published by the FSF. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero +# General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program in a file in the toplevel directory called +# "AGPLv3". If not, see . +# +""" +Integrations between these XBlocks and the edX Submissions API +""" + +try: + from submissions import api as sub_api +except ImportError: + sub_api = None # We are probably in the workbench. Don't use the submissions API + + +class SubmittingXBlockMixin(object): + """ Simplifies use of the submissions API by an XBlock """ + @property + def student_item_key(self): + """ Get the student_item_dict required for the submissions API """ + assert sub_api is not None + location = self.location.replace(branch=None, version=None) # Standardize the key in case it isn't already + return dict( + student_id=self.runtime.anonymous_student_id, + course_id=unicode(location.course_key), + item_id=unicode(location), + item_type=self.scope_ids.block_type, + ) diff --git a/problem_builder/templates/html/dashboard.html b/problem_builder/templates/html/dashboard.html new file mode 100644 index 00000000..6640176c --- /dev/null +++ b/problem_builder/templates/html/dashboard.html @@ -0,0 +1,67 @@ +{% load i18n %} +
+
+

{{display_name}}

+ + {% if visual_repr %} +
+ + {{visual_repr.title}} + {{visual_repr.desc}} + + {% for layer in visual_repr.layers %} + {% if layer.color %} + + + + + + + + {% endif %} + {% endfor %} + + {% for layer in visual_repr.layers %} + {% if layer.color %} + + {% else %} + + {% endif %} + {% endfor %} + +
+ {% endif %} + + + {% for block in blocks %} + + + + + + {% for mcq in block.mcqs %} + + + + + {% endfor %} + {% if block.has_average %} + + + + + {% endif %} + +
{{ block.display_name }}
{{ mcq.display_name }} + {% if mcq.value %}{{ mcq.value }}{% endif %} +
{% trans "Average" %} + {{ block.average|floatformat }} +
+ {% endfor %} +
+ + {% if blocks %} +
+

{% trans "Download report" %}

+ {% endif %} +
diff --git a/problem_builder/templates/html/dashboard_report.html b/problem_builder/templates/html/dashboard_report.html new file mode 100644 index 00000000..cf29a6eb --- /dev/null +++ b/problem_builder/templates/html/dashboard_report.html @@ -0,0 +1,25 @@ +{% load i18n %} + + + + + + {{ title }} + + + +
+
+ {% if student_name %}{% trans "Student" %}: {{student_name}}
{% endif %} + {% if course_name %}{% trans "Course" %}: {{course_name}}
{% endif %} + {% trans "Date" %}: {% now "DATE_FORMAT" %}
+
+ REPORT_GOES_HERE +
+ + diff --git a/problem_builder/tests/integration/test_dashboard.py b/problem_builder/tests/integration/test_dashboard.py new file mode 100644 index 00000000..a1bc9044 --- /dev/null +++ b/problem_builder/tests/integration/test_dashboard.py @@ -0,0 +1,199 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2014-2015 Harvard, edX & OpenCraft +# +# This software's license gives you freedom; you can copy, convey, +# propagate, redistribute and/or modify this program under the terms of +# the GNU Affero General Public License (AGPL) as published by the Free +# Software Foundation (FSF), either version 3 of the License, or (at your +# option) any later version of the AGPL published by the FSF. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero +# General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program in a file in the toplevel directory called +# "AGPLv3". If not, see . +# +from mock import Mock, patch +from xblockutils.base_test import SeleniumXBlockTest + + +class MockSubmissionsAPI(object): + """ + Mock the submissions API, since it's not available in the test environment. + """ + def __init__(self): + self.submissions = {} + + def dict_to_key(self, dict_key): + return (dict_key['student_id'], dict_key['course_id'], dict_key['item_id'], dict_key['item_type']) + + def create_submission(self, dict_key, submission): + record = dict( + student_item=dict_key, + attempt_number=Mock(), + submitted_at=Mock(), + created_at=Mock(), + answer=submission, + ) + self.submissions[self.dict_to_key(dict_key)] = record + return record + + def get_submissions(self, key, limit=1): + assert limit == 1 + key = self.dict_to_key(key) + if key in self.submissions: + return [self.submissions[key]] + return [] + + +class TestDashboardBlock(SeleniumXBlockTest): + """ + Test the Student View of a dashboard XBlock linked to some problem builder blocks + """ + def setUp(self): + super(TestDashboardBlock, self).setUp() + # Set up our scenario: + self.set_scenario_xml(""" + + + + Option 1 + Option 2 + Option 3 + Option 4 + + + Option 1 + Option 2 + Option 3 + Option 4 + + + Option 1 + Option 2 + Option 3 + Option 4 + + This message here should be ignored. + + + + Option 4 + Option 5 + Option 6 + + + Option 1 + Option 2 + Option 3 + Option 4 + + + Option 1 + Option 2 + Option 3 + Option 4 + + + + + Option 1 + Option 2 + Option 3 + Option 4 + + + Option A + Option B + Option C + + + + + + """) + + # Apply a whole bunch of patches that are needed in lieu of the LMS/CMS runtime and edx-submissions: + def get_mentoring_blocks(dashboard_block, mentoring_ids, ignore_errors=True): + return [dashboard_block.runtime.get_block(key) for key in dashboard_block.get_parent().children[:-1]] + mock_submisisons_api = MockSubmissionsAPI() + patches = ( + ( + "problem_builder.dashboard.DashboardBlock._get_submission_key", + lambda _, child_id: dict(student_id="student", course_id="course", item_id=child_id, item_type="pb-mcq") + ), + ( + "problem_builder.sub_api.SubmittingXBlockMixin.student_item_key", + property(lambda block: dict( + student_id="student", course_id="course", item_id=block.scope_ids.usage_id, item_type="pb-mcq" + )) + ), + ("problem_builder.dashboard.DashboardBlock.get_mentoring_blocks", get_mentoring_blocks), + ("problem_builder.dashboard.sub_api", mock_submisisons_api), + ("problem_builder.mcq.sub_api", mock_submisisons_api) + ) + for p in patches: + patcher = patch(*p) + patcher.start() + self.addCleanup(patcher.stop) + # All the patches are installed; now we can proceed with using the XBlocks for tests: + self.go_to_view("student_view") + self.vertical = self.load_root_xblock() + + def test_empty_dashboard(self): + """ + Test that when the student has not submitted any question answers, we still see + the dashboard, and its lists all the MCQ questions in the way we expect. + """ + dashboard = self.browser.find_element_by_css_selector('.pb-dashboard') + step_headers = dashboard.find_elements_by_css_selector('thead') + self.assertEqual(len(step_headers), 3) + self.assertEqual([hdr.text for hdr in step_headers], ["Step 1", "Step 2", "Step 3"]) + steps = dashboard.find_elements_by_css_selector('tbody') + self.assertEqual(len(steps), 3) + + for step in steps: + mcq_rows = step.find_elements_by_css_selector('tr') + self.assertTrue(2 <= len(mcq_rows) <= 3) + for mcq in mcq_rows: + value = mcq.find_element_by_css_selector('td:last-child') + self.assertEqual(value.text, '') + + def test_dashboard(self): + """ + Submit an answer to each MCQ, then check that the dashboard reflects those answers. + """ + pbs = self.browser.find_elements_by_css_selector('.mentoring') + for pb in pbs: + mcqs = pb.find_elements_by_css_selector('fieldset.choices') + for idx, mcq in enumerate(mcqs): + choices = mcq.find_elements_by_css_selector('.choices .choice label') + choices[idx].click() + submit = pb.find_element_by_css_selector('.submit input.input-main') + submit.click() + self.wait_until_disabled(submit) + + # Reload the page: + self.go_to_view("student_view") + dashboard = self.browser.find_element_by_css_selector('.pb-dashboard') + steps = dashboard.find_elements_by_css_selector('tbody') + self.assertEqual(len(steps), 3) + + for step_num, step in enumerate(steps): + mcq_rows = step.find_elements_by_css_selector('tr:not(.avg-row)') + self.assertTrue(2 <= len(mcq_rows) <= 3) + for mcq in mcq_rows: + value = mcq.find_element_by_css_selector('td.value') + self.assertIn(value.text, ('1', '2', '3', '4', 'B')) + # Check the average: + avg_row = step.find_element_by_css_selector('tr.avg-row') + left_col = avg_row.find_element_by_css_selector('.desc') + self.assertEqual(left_col.text, "Average") + right_col = avg_row.find_element_by_css_selector('.value') + expected_average = {0: "2", 1: "3", 2: "1"}[step_num] + self.assertEqual(right_col.text, expected_average) diff --git a/problem_builder/tests/integration/test_titles.py b/problem_builder/tests/integration/test_titles.py new file mode 100644 index 00000000..498d6a73 --- /dev/null +++ b/problem_builder/tests/integration/test_titles.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2014-2015 Harvard, edX & OpenCraft +# +# This software's license gives you freedom; you can copy, convey, +# propagate, redistribute and/or modify this program under the terms of +# the GNU Affero General Public License (AGPL) as published by the Free +# Software Foundation (FSF), either version 3 of the License, or (at your +# option) any later version of the AGPL published by the FSF. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero +# General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program in a file in the toplevel directory called +# "AGPLv3". If not, see . +# +""" +Test that the various title/display_name options for Answer and MCQ/MRQ/Ratings work. +""" + +# Imports ########################################################### +from mock import patch +from xblockutils.base_test import SeleniumXBlockTest + + +# Classes ########################################################### + + +class StepTitlesTest(SeleniumXBlockTest): + """ + Test that the various title/display_name options for Answer and MCQ/MRQ/Ratings work. + """ + + test_parameters = ( + # display_name, show_title?, expected_title: (None means default value) + ("Custom Title", None, "Custom Title",), + ("Custom Title", True, "Custom Title",), + ("Custom Title", False, None), + ("", None, "Question"), + ("", True, "Question"), + ("", False, None), + ) + + mcq_template = """ + + + Gaius Baltar + Admiral William Adama + Starbuck + Laura Roslin + Number Six + Lee Adama + + + """ + + mrq_template = """ + + + Lots of choices + Funny choices + Not sure + + + """ + + rating_template = """ + + + More than 5 stars + + + """ + + long_answer_template = """ + + + + """ + + def setUp(self): + super(StepTitlesTest, self).setUp() + # Disable asides for this test since the acid aside seems to cause Database errors + # When we test multiple scenarios in one test method. + patcher = patch( + 'workbench.runtime.WorkbenchRuntime.applicable_aside_types', + lambda self, block: [], create=True + ) + patcher.start() + self.addCleanup(patcher.stop) + + def test_all_the_things(self): + """ Test various permutations of our problem-builder components and title options. """ + # We use a loop within the test rather than DDT, because this is WAY faster + # since we can bypass the Selenium set-up and teardown + for display_name, show_title, expected_title in self.test_parameters: + for mode in ("standard", "assessment"): + for qtype in ("mcq", "mrq", "rating", "long_answer"): + template = getattr(self, qtype + "_template") + xml = template.format( + mode=mode, + display_name_attr='display_name="{}"'.format(display_name) if display_name is not None else "", + show_title_attr='show_title="{}"'.format(show_title) if show_title is not None else "", + ) + self.set_scenario_xml(xml) + pb_element = self.go_to_view() + if expected_title: + h3 = pb_element.find_element_by_css_selector('h3') + self.assertEqual(h3.text, expected_title) + else: + # No

element should be present: + all_h3s = pb_element.find_elements_by_css_selector('h3') + self.assertEqual(len(all_h3s), 0) diff --git a/problem_builder/tests/unit/test_dashboard_visual.py b/problem_builder/tests/unit/test_dashboard_visual.py new file mode 100644 index 00000000..7d7a005d --- /dev/null +++ b/problem_builder/tests/unit/test_dashboard_visual.py @@ -0,0 +1,68 @@ +""" +Unit tests for DashboardVisualData +""" +from problem_builder.dashboard_visual import DashboardVisualData +from mock import MagicMock, Mock +import unittest +from xblock.field_data import DictFieldData + + +class TestDashboardVisualData(unittest.TestCase): + """ + Test DashboardVisualData with some mocked data + """ + def test_construct_data(self): + """ + Test parsing of data and creation of SVG filter data. + """ + blocks = [ + { + 'display_name': 'Block 1', + 'mcqs': [], + 'has_average': True, + 'average': 0, + }, + { + 'display_name': 'Block 2', + 'mcqs': [], + 'has_average': True, + 'average': 1.3, + }, + { + 'display_name': 'Block 3', + 'mcqs': [], + 'has_average': True, + 'average': 30.8, + }, + ] + rules = { + "images": [ + "step1.png", + "step2.png", + "step3.png", + ], + "background": "background.png", + "overlay": "overlay.png", + "width": "500", + "height": "500" + } + + def color_for_value(value): + """ Mock color_for_value """ + return "red" if value > 1 else None + + data = DashboardVisualData(blocks, rules, color_for_value, "Visual Repr", "Description here") + self.assertEqual(len(data.layers), 5) + self.assertEqual(data.layers[0]["url"], "background.png") + self.assertEqual(data.layers[4]["url"], "overlay.png") + self.assertEqual(data.width, 500) + self.assertEqual(data.height, 500) + # Check the three middle layers built from the average values: + self.assertEqual(data.layers[1]["url"], "step1.png") + self.assertEqual(data.layers[1].get("color"), None) + + self.assertEqual(data.layers[2]["url"], "step2.png") + self.assertEqual(data.layers[2]["color"], "red") + + self.assertEqual(data.layers[3]["url"], "step3.png") + self.assertEqual(data.layers[3]["color"], "red") diff --git a/setup.py b/setup.py index 2af34c62..86f792ef 100644 --- a/setup.py +++ b/setup.py @@ -52,6 +52,8 @@ def package_data(pkg, root_list): 'pb-message = problem_builder:MentoringMessageBlock', 'pb-tip = problem_builder:TipBlock', 'pb-choice = problem_builder:ChoiceBlock', + + 'pb-dashboard = problem_builder:DashboardBlock', # Deprecated. You can temporarily uncomment and run 'python setup.py develop' if you have these blocks # installed from testing mentoring v2 and need to get past an error message. #'mentoring = problem_builder:MentoringBlock', # Deprecated alias for problem-builder