diff --git a/ubcpi/answer_pool.py b/ubcpi/answer_pool.py index f6c32a7..76d393e 100644 --- a/ubcpi/answer_pool.py +++ b/ubcpi/answer_pool.py @@ -1,4 +1,5 @@ import random +import copy import persistence as sas_api from utils import _ # pylint: disable=unused-import @@ -172,6 +173,52 @@ def validate_seeded_answers(answers, options, algo): else: raise UnknownChooseAnswerAlgorithm() +def get_other_answers_count(pool, seeded_answers, get_student_item_dict): + """ + Count of available answers and seeds in the pool for each option + + Args: + pool (dict): answer pool, format: + { + option1_index: { + student_id: { can store algorithm specific info here } + }, + option2_index: { + student_id: { ... } + } + } + seeded_answers (list): seeded answers from instructor + [ + {'answer': 0, 'rationale': 'rationale A'}, + {'answer': 1, 'rationale': 'rationale B'}, + ] + get_student_item_dict (callable): get student item dict function to return student item dict + + Returns: + dict: count for each option + { + 0: 4, + 1: 2, + 3: 1, + ... + } + + """ + ret = {} + + # clean up answers so that all keys are int + pool = {int(k): v for k, v in pool.items()} + merged_pool = convert_seeded_answers(seeded_answers) + student_id = get_student_item_dict()['student_id'] + for key in pool: + merged_pool.setdefault(key, {}) + merged_pool[key].update(pool[key]) + # Pop student's own answer, if exists + merged_pool[key].pop(student_id, None) + + for key in merged_pool: + ret[key] = len(merged_pool.get(key, {})) + return ret def get_other_answers(pool, seeded_answers, get_student_item_dict, algo, options): """ @@ -227,48 +274,45 @@ def get_other_answers_simple(pool, seeded_answers, get_student_item_dict, num_re ret = [] # clean up answers so that all keys are int pool = {int(k): v for k, v in pool.items()} - total_in_pool = len(seeded_answers) merged_pool = convert_seeded_answers(seeded_answers) student_id = get_student_item_dict()['student_id'] # merge the dictionaries in the answer dictionary for key in pool: - total_in_pool += len(pool[key]) - # if student_id has value, we assume the student just submitted an answer. So removing it - # from total number in the pool - if student_id in pool[key].keys(): - total_in_pool -= 1 - if key in merged_pool: - merged_pool[key].update(pool[key].items()) - else: - merged_pool[key] = pool[key] + merged_pool.setdefault(key, {}) + merged_pool[key].update(pool[key]) + # Pop student's own answer, if exists + merged_pool[key].pop(student_id, None) - # remember which option+student_id is selected, so that we don't have duplicates in the result - selected = [] - - # loop until we have enough answers to return - while len(ret) < min(num_responses, total_in_pool): + # loop until we have enough answers to return or when there is nothing more to return + while len(ret) < num_responses and merged_pool: for option, students in merged_pool.items(): - student = student_id - i = 0 - while (student == student_id or i > 100) and (str(option) + student) not in selected: - # retry until we got a different one or after 100 retries - # we are suppose to get a different student answer or a seeded one in a few tries - # as we have at least one seeded answer for each option in the algo. And it is not - # suppose to overflow i order to break the loop + rationale = None + while students: student = random.choice(students.keys()) - i += 1 - selected.append(str(option)+student) - if student.startswith('seeded'): - # seeded answer, get the rationale from local - rationale = students[student] - else: - student_item = get_student_item_dict(student) - submission = sas_api.get_answers_for_student(student_item) - rationale = submission.get_rationale(0) - ret.append({'option': option, 'rationale': rationale}) + # remove the chosen answer from pool + content = students.pop(student, None) + + if student.startswith('seeded'): + # seeded answer, get the rationale from local + rationale = content + else: + student_item = get_student_item_dict(student) + submission = sas_api.get_answers_for_student(student_item) + # Make sure the answer is still the one we want. + # It may have changed (e.g. instructor deleted the student state + # and the student re-submitted a diff answer) + if submission.has_revision(0) and submission.get_vote(0) == option: + rationale = submission.get_rationale(0) + + if rationale: + ret.append({'option': option, 'rationale': rationale}) + break + + if not students: + del merged_pool[option] # check if we have enough answers - if len(ret) >= min(num_responses, total_in_pool): + if len(ret) >= num_responses: break return {"answers": ret} @@ -316,13 +360,106 @@ def get_other_answers_random(pool, seeded_answers, get_student_item_dict, num_re else: student_item = get_student_item_dict(student) submission = sas_api.get_answers_for_student(student_item) - rationale = submission.get_rationale(0) - option = submission.get_vote(0) + if submission.has_revision(0): + rationale = submission.get_rationale(0) + option = submission.get_vote(0) + else: + continue ret.append({'option': option, 'rationale': rationale}) return {"answers": ret} +def refresh_answers(answers_shown, option, pool, seeded_answers, get_student_item_dict, seeded_first=False): + """ + Refresh the answers shown for given option + + Args: + answers_shown (dict): answers being shown that need to be refreshed. Format: + {'answers': [ + {'option': 0, 'rationale': 'rationale A'}, + {'option': 1, 'rationale': 'rationale B'}, + ]} + option (int): the option to refresh + pool (dict): answer pool, format: + { + option1_index: { + student_id: { can store algorithm specific info here } + }, + option2_index: { + student_id: { ... } + } + } + seeded_answers (list): seeded answers from instructor + [ + {'answer': 0, 'rationale': 'rationale A'}, + {'answer': 1, 'rationale': 'rationale B'}, + ] + get_student_item_dict (callable): get student item dict function to return student item dict + seeded_first (boolean): refresh with answers from seeded_answers first, when exhausted, pick from pool + + Returns: + dict: refreshed answers lists + { + 'answers': + [ + {'option': 0, 'rationale': 'rationale A'}, + {'option': 1, 'rationale': 'rationale B'}, + ] + } + """ + ret = copy.deepcopy(answers_shown) + # clean up answers so that all keys are int + pool = {int(k): v for k, v in pool.items()} + seeded_pool = convert_seeded_answers(seeded_answers) + student_id = get_student_item_dict()['student_id'] + + available_students = copy.deepcopy(pool.get(option, {})) + available_students.pop(student_id, None) + # if seed answers have higher priority, fill the available seeds. + # otherwise merge them into available students + available_seeds = {} + if seeded_first and seeded_pool.get(option, {}): + available_seeds = copy.deepcopy(seeded_pool.get(option, {})) + else: + for key in seeded_pool.get(option, {}): + available_students[key] = seeded_pool.get(option, {}).get(key, None) + + for answer in ret.get('answers', []): + if answer.get('option', None) == option: + rationale = None + + while available_seeds: + key = random.choice(available_seeds.keys()) + rationale = available_seeds.pop(key, None) + if rationale is not None: + answer['rationale'] = rationale + break; + + while available_students and rationale is None: + key = random.choice(available_students.keys()) + # remove the chosen answer from pool + content = available_students.pop(key, None) + + if key.startswith('seeded'): + rationale = content + else: + student_item = get_student_item_dict(key) + submission = sas_api.get_answers_for_student(student_item) + # Make sure the answer is still the one we want. + # It may have changed (e.g. instructor deleted the student state + # and the student re-submitted a diff answer) + if submission.has_revision(0) and submission.get_vote(0) == option: + rationale = submission.get_rationale(0) + + if rationale: + answer['rationale'] = rationale + break + + # random.shuffle(ret['answers']) + return ret + + def convert_seeded_answers(answers): """ Convert seeded answers into the format that can be merged into student answers. diff --git a/ubcpi/persistence.py b/ubcpi/persistence.py index eec4063..6390dbf 100644 --- a/ubcpi/persistence.py +++ b/ubcpi/persistence.py @@ -17,6 +17,8 @@ """ ANSWER_LIST_KEY = 'answers' +DELETE_INDICATOR = 'deleted' +REQUEST_USER_ID_KEY = 'requesting_user_id' VOTE_KEY = 'vote' RATIONALE_KEY = 'rationale' @@ -39,6 +41,8 @@ def get_answers_for_student(student_item): latest_submission = submissions[0] latest_answer_item = latest_submission.get('answer', {}) + if latest_answer_item.get(DELETE_INDICATOR, False): + return Answers() return Answers(latest_answer_item.get(ANSWER_LIST_KEY, [])) @@ -59,6 +63,19 @@ def add_answer_for_student(student_item, vote, rationale): ANSWER_LIST_KEY: answers.get_answers_as_list() }) +def delete_answer_for_student(student_item, requesting_user_id): + """ + Create a new submission to indicate student's answer is deleted + + Args: + student_item (dict): The location of the problem this submission is + associated with, as defined by a course, student, and item. + requesting_user_id: The user that is requesting to delete student answer + """ + sub_api.create_submission(student_item, { + DELETE_INDICATOR: True, + REQUEST_USER_ID_KEY: requesting_user_id, + }) class Answers: """ diff --git a/ubcpi/static/css/ubcpi.css b/ubcpi/static/css/ubcpi.css index 1740057..a9710dd 100644 --- a/ubcpi/static/css/ubcpi.css +++ b/ubcpi/static/css/ubcpi.css @@ -25,11 +25,6 @@ h2.question-text { padding: 0; } -.choicegroup fieldset .ubcpi-possible-options { - padding-left: 20px; - border-left: 2px solid #e5e5e5; -} - div.course-wrapper section.course-content fieldset .ubcpi-option { margin: 0; } @@ -57,6 +52,7 @@ fieldset .ubcpi-label { border-radius: 3px; padding: 10px; width: 100%; + background-color: #fbfbfb; } fieldset .ubcpi-label:hover { @@ -77,6 +73,20 @@ fieldset .ubcpi-option:last-of-type .ubcpi-label { float: none; } +.ubcpi-no-pointer { + cursor: default; +} + +.ubcpi-answer img { + display: block; +} + +.ubcpi-label.ubcpi-explain-label { + border: none; + margin-bottom: 0; + padding: 0px; +} + .results-container .ubcpi-label { margin-bottom: 15px; } @@ -116,7 +126,8 @@ textarea.ubcpi-field { -webkit-font-smoothing: antialiased; } -.choicegroup .ubcpi_submit { +.choicegroup .ubcpi_submit, +.others-responses .ubcpi_submit { padding: 10px 40px; border: 1px solid #2b8dbb; color: #fff; @@ -140,7 +151,10 @@ textarea.ubcpi-field { .ubcpi_block .choicegroup .ubcpi_submit:hover, .ubcpi_block .choicegroup .ubcpi_submit:active, -.ubcpi_block .choicegroup .ubcpi_submit:focus { +.ubcpi_block .choicegroup .ubcpi_submit:focus, +.others-responses .ubcpi_submit:hover, +.others-responses .ubcpi_submit:active, +.others-responses .ubcpi_submit:focus { border-color: #00a7f6; box-shadow: none; background: #00a7f6 none; @@ -178,8 +192,26 @@ div.course-wrapper section.course-content .vert-mod > div ul.ubcpi-other-answers border-bottom: 1px solid #cfc6c6; } -div.course-wrapper section.course-content .vert-mod > div ul.ubcpi-other-answers li:last-of-type { - border-bottom: none; +div.course-wrapper section.course-content .vert-mod .sample-answer { + margin: 1em; + display: block; +} + +div.course-wrapper section.course-content .vert-mod .no-sample-answer { + font-style: italic; + margin: 1em; + display: block; +} + +div.course-wrapper section.course-content .vert-mod .sample-answer .other-rationale { + line-height: 140%; + padding-left: 1em; +} + +div.course-wrapper section.course-content .vert-mod .own-answer { + padding-top: 15px; + margin: 1em; + font-weight: bold; } .ubcpi-other-answers, @@ -198,8 +230,15 @@ div.course-wrapper section.course-content .vert-mod > div ul.ubcpi-other-answers } .ubcpi-other-answers h4, -.ubcpi-solution-your-initial-answer, +.ubcpi-solution-your-initial-answer { + font-weight: 900; +} + .ubcpi-solution-your-final-answer { + border-top: 1px solid #ddd; + margin-top: 10px; + padding-top: 5px; + font-size: 0.85em; font-weight: 900; } @@ -385,7 +424,6 @@ textarea.pi-options { position: relative; margin: 20px 0; border: 1px solid #d7dbdf; - border-left: 10px solid #b9c1c8; box-shadow: inset 0 1px 2px 1px rgba(2, 2, 3, 0.1); padding: 20px; background: #fdfdfd; @@ -499,8 +537,26 @@ label.ubcpi-label { fill: #50c67b; } -.ubcpibar:hover { - opacity: 1.0; +.ubcpibar.original { + opacity: 0.4; +} + +text.ubcpibar { + font-size: 12px; + font-weight: 600; + fill: #000000; +} + +text.ubcpibar.correct-answer { + font-size: 12px; + font-weight: 600; + fill: #000000; +} + +text.ubcpibar.label { + font-size: 12px; + font-weight: 600; + fill: #000000; } .ubcpi-correct-answer-option { @@ -509,6 +565,11 @@ label.ubcpi-label { } .ubcpi-show-correct { + font-size: 0.85em; +} + +.ubcpi-correct-answer-highlight { + font-weight: bold; color: #50c67b; } @@ -525,11 +586,13 @@ label.ubcpi-label { .ubcpi-correct-answer-rationale { display: block; margin-bottom: 20px; + font-size: 0.85em; } .ubcpi-solution-rationales { padding-left: 10px; word-break: break-all; + font-weight: normal; } .other-rationale { @@ -559,12 +622,30 @@ label.ubcpi-label { border: 2px solid #e5e5e5; border-radius: 3px; padding: 10px; + background-color: #fbfbfb; } .ubcpi-breakdown-answer-options .ubcpi-option .ubcpi-breakdown-answer-text { display: block; } +.ubcpi-refresh-section { + margin: 1em; + border-top: 1px solid #cfc6c6; + color: #0075b4; +} + +.ubcpi-refresh-option-button * { + font-size: 0.9em; + transition: none; + cursor: pointer; +} + +.ubcpi-refresh-option-button-disabled { + pointer-events: none; + color: #888; +} + #pi-form .list-input.settings-list .setting-label { vertical-align: top; } @@ -630,6 +711,12 @@ div.course-wrapper section.course-content .warning-notice p { font-style: italic; } +#decision-prompt { + font-weight: normal; + font-size: 120%; + padding-right: 4.8em; +} + .response-text { padding-left: 22px; } @@ -780,4 +867,6 @@ pi-barchart > svg { text-transform: capitalize; } - +#not-enough-data { + font-size: 0.85em; +} diff --git a/ubcpi/static/html/ubcpi.html b/ubcpi/static/html/ubcpi.html index 73a35d1..d016a48 100644 --- a/ubcpi/static/html/ubcpi.html +++ b/ubcpi/static/html/ubcpi.html @@ -5,59 +5,36 @@

{{display_name}}

-
-
 ({{weight}} point possible)
-
 ({{weight}} points possible)
+
+
({{weight}} point possible)
+
({{weight}} points possible)

Question

- {{question_text.image_alt}} + {{question_text.image_alt}} - + - {{question_text.image_alt}} + {{question_text.image_alt}}
- -
- + + +
+
    -

  1. Answer, Completed
  2. +

  3. Answer, In Progress
  4. -

  5. Reflection, In Progress
  6. +

  7. Reflection, Upcoming

  8. Results, Upcoming
-
- -
- - Step 2) Read Other Students Answers -

These are samples of other student answers for this question. Read them and then compare with your answer below.

- -
    -
  • - {{options[answer.option].image_alt}} - -
    Student Answer: {{options[answer.option].text}}
    - - {{options[answer.option].image_alt}} - Student Rationale - "{{answer.rationale}}" - -
  • -
- -
- -
- - +
    -

  1. Answer, In Progress
  2. +

  3. Answer, Completed
  4. -

  5. Reflection, Upcoming
  6. +

  7. Reflection, In Progress

  8. Results, Upcoming
@@ -68,69 +45,110 @@

{{display_n
- Step 1) Your Initial Answer You can change this answer later, if you change your mind. - Step 3) Your Final Answer You now have the option to change your initial selection and explanation, if you wish. + Step 1) Give Initial Answer You can change this answer later, if you change your mind. + Step 2) Read Other Student Answers +

These are randomly chosen samples of other student answers for this question. Read them and compare with your answer below. Then you may revise your answer, if you wish.

+
-
-

-

+
+ -

+
- - -

You are approaching the limit of {{rationale_size.max}} characters for this answer. Characters left: {{rationale_size.max - rc.rationale.length}}

-

Error: Please edit your explanation so that it is less than {{rationale_size.max}} characters.

- -

- - Hint In the next step, you will be shown a selection of other responses that may help you refine your answer. - -

- -
-
- -
-
-
- View Question Statistics -
- -
- -

Original Answer

-
- -
- -

Revised Answer

-
+
+ View Question Statistics +
+ +
+
+ + + +
-
- +
+
@@ -139,24 +157,22 @@

{{display_n

{{display_name}}

-
- -
({{weight}} point possible)
- -
({{weight}} points possible)
+
+
({{weight}} point possible)
+
({{weight}} points possible)

Question

- {{question_text.image_alt}} + {{question_text.image_alt}} - + - {{question_text.image_alt}} + {{question_text.image_alt}}
-
- +
+

  1. Answer, Completed
  2. @@ -167,100 +183,50 @@

    {{display_n

+
-
Correct Answer
-
-
Instructor's Explanation
+ + Step 3) View Course-Wide Results +

This is a look at how your classmates answered the question initially and after revision.

+ +
-
- {{options[rc.correct_answer].image_alt}} + {{rc.correct_rationale.text}} -

{{options[rc.correct_answer].text}}

+
+
+
+ Correct Answer: + {{option.text}} - {{rc.correct_rationale.text}} - -
-
    -
  • -

    - Your initial answer: - {{options[rc.answer_original].text}} - {{options[rc.answer_original].text}} - {{options[rc.answer_original].text}} - ({{'Option' | translate }} {{rc.answer_original + 1}}) -

    - Student Rationale"{{rc.rationale_original}}" -
  • -
  • + {{option.image_alt}} + + + {{rc.correct_rationale.text}} + + + +

    - Your final answer: - {{options[rc.answer_revised].text}} - {{options[rc.answer_revised].text}} - {{options[rc.answer_revised].text}} - ({{'Option' | translate }} {{rc.answer_revised + 1}}) + Your final explanation: + "{{rc.rationale_revised}}"

    - Student Rationale"{{rc.rationale_revised}}" -
  • -
-

See how your classmates answered below

+
+
+
-
- - -
Class Breakdown
-

This is a look at how your classmates answered the question during the initial and final rounds.

-
Answer Options
-
- -

- {{option.image_alt}} - - Option {{$index + 1}} (correct) - {{option.text}} - {{rc.calc($index)}} - {{option.text}} - {{rc.calc($index)}} - - {{option.image_alt}} - - (You chose this option) -

- -
- -
-

-

  • - Option {{$index + 1}}: {{rc.calc($index)}} - (correct) -
  • -
    - -
    Initial Answer Selection
    - -
    - -
    Final Answer Selection
    - - -
    -
    diff --git a/ubcpi/static/html/ubcpi_edit.html b/ubcpi/static/html/ubcpi_edit.html index 7930226..a66b8e0 100644 --- a/ubcpi/static/html/ubcpi_edit.html +++ b/ubcpi/static/html/ubcpi_edit.html @@ -138,7 +138,7 @@
    - This is the number of examples shown to the students after they answer. Enter the # symbol to use the same number as the answer possibilities you've set. + This is the number of examples shown on screen to the students after they answer. Students can choose to refresh for some other samples. Enter the # symbol to use the same number as the answer possibilities you've set.
    diff --git a/ubcpi/static/js/spec/d3-pibar_spec.js b/ubcpi/static/js/spec/d3-pibar_spec.js index 16fab60..9452f6a 100644 --- a/ubcpi/static/js/spec/d3-pibar_spec.js +++ b/ubcpi/static/js/spec/d3-pibar_spec.js @@ -105,3 +105,96 @@ describe('D3 bar chart', function () { // expect(line.empty()).not.toBe(true); //}); }); + + +describe('D3 per answer chart', function () { + var chart; + var chartContainer; + + describe('instructor view', function(){ + + beforeEach(function() { + var scope = {role:'instructor'}; + chart = d3.custom.perAnswerChart(scope); + chartContainer = d3.select('body') + .append('div') + .attr('class', 'testContainer'); + }); + + afterEach(function() { + // clean up + chartContainer.remove(); + }); + + it('should provide getters and setters', function() { + var defaultChartWidth = chart.width(); + var defaultChartHeight = chart.height(); + var defaultMinTotalFrequency = chart.minTotalFrequency(); + + chart.width(100) + .height(50) + .minTotalFrequency(20); + + var newChartWidth = chart.width(); + var newChartHeight = chart.height(); + var newMinTotalFrequency = chart.minTotalFrequency(); + + + expect(defaultChartWidth).not.toBe(100); + expect(defaultChartHeight).not.toBe(50); + expect(defaultMinTotalFrequency).not.toBe(20); + expect(newChartWidth).toBe(100); + expect(newChartHeight).toBe(50); + expect(newMinTotalFrequency).toBe(20); + }); + + + it('should have a minTotalFrequency of 1', function(){ + var defaultMinTotalFrequency = chart.minTotalFrequency(); + expect(defaultMinTotalFrequency).toBe(1); + }); + }); + + describe('student view', function(){ + + beforeEach(function() { + var scope = {role:'student'}; + chart = d3.custom.barChart(scope); + chartContainer = d3.select('body') + .append('div') + .attr('class', 'testContainer'); + }); + + afterEach(function() { + // clean up + chartContainer.remove(); + }); + + it('should provide getters and setters', function() { + var defaultChartWidth = chart.width(); + var defaultChartHeight = chart.height(); + var defaultMinTotalFrequency = chart.minTotalFrequency(); + + chart.width(100) + .height(50) + .minTotalFrequency(20); + + var newChartWidth = chart.width(); + var newChartHeight = chart.height(); + var newMinTotalFrequency = chart.minTotalFrequency(); + + + expect(defaultChartWidth).not.toBe(100); + expect(defaultChartHeight).not.toBe(50); + expect(defaultMinTotalFrequency).not.toBe(20); + expect(newChartWidth).toBe(100); + expect(newChartHeight).toBe(50); + expect(newMinTotalFrequency).toBe(20); + }); + + it('should have a minTotalFrequency of 10', function(){ + var defaultMinTotalFrequency = chart.minTotalFrequency(); + expect(defaultMinTotalFrequency).toBe(10); + }); + }); +}); diff --git a/ubcpi/static/js/spec/ubcpi-barchar-directive_spec.js b/ubcpi/static/js/spec/ubcpi-barchar-directive_spec.js index 5039542..ac4cb06 100644 --- a/ubcpi/static/js/spec/ubcpi-barchar-directive_spec.js +++ b/ubcpi/static/js/spec/ubcpi-barchar-directive_spec.js @@ -115,4 +115,151 @@ describe('UBCPI', function () { }) }) }); + + + describe('pi-per-answer-chart', function () { + var element, scope; + + beforeEach(inject(function ($compile, $rootScope) { + scope = $rootScope; + + element = angular.element( + '' + ); + $compile(element)(scope); + scope.$digest(); + })); + + it('should not render anything when stats is empty', function () { + expect(element.find('svg').length).toBe(0); + expect(element.find('g').length).toBe(0); + }); + + describe('directive', function () { + var options = [{ + "text": "21", + "image_alt": "", + "image_url": "", + "image_position": "below", + "image_show_fields": 0 + }, { + "text": "42", + "image_alt": "", + "image_url": "", + "image_position": "below", + "image_show_fields": 0 + }, { + "text": "63", + "image_alt": "", + "image_url": "", + "image_position": "below", + "image_show_fields": 0 + }]; + var answers = {"original": 2, "revised": 1}; + var correct = 0; + + describe('with enough submissions', function() { + var stats = { + "original": {0: 4, 1: 5, 2: 1}, + "revised": {0: 1, 1: 8, 2: 1}, + }; + beforeEach(function() { + scope.$apply(function () { + scope.options = options; + scope.stats = stats; + scope.answers = answers; + scope.correct = correct; + scope.per_answer_stats = 1; + }); + }); + + it('should render the template with given data', function() { + // one graph for given answer per_answer_stats. two bars: one for initial choice, one for revision + expect(element.find('> svg').length).toBe(1); + expect(element.find('> svg > g').length).toBe(2); + }); + + it('should calculate percentage correctly for incorrect answer', function() { + expect(element.find('> svg > g').eq(0).find('> rect').length).toBe(2); + expect( + element.find('> svg > g').eq(0).find('> rect.ubcpibar').attr('width') / + element.find('> svg > g').eq(0).find('> rect:not(.ubcpibar)').attr('width')).toBe(0.5); + expect(element.find('> svg > g').eq(0).find('> svg > text').text()).toContain('50%'); + expect( + element.find('> svg > g').eq(1).find('> rect.ubcpibar').attr('width') / + element.find('> svg > g').eq(1).find('> rect:not(.ubcpibar)').attr('width')).toBe(0.8); + expect(element.find('> svg > g').eq(1).find('> svg > text').text()).toContain('80%'); + }); + }); + + describe('with enough submissions', function() { + var stats = { + "original": {0: 4, 1: 5, 2: 1}, + "revised": {0: 1, 1: 8, 2: 1}, + }; + beforeEach(function() { + scope.$apply(function () { + scope.options = options; + scope.stats = stats; + scope.answers = answers; + scope.correct = correct; + scope.per_answer_stats = correct; + }); + }); + + it('should calculate percentage correctly for correct answer', function() { + expect(element.find('> svg > g').eq(0).find('> rect').length).toBe(2); + expect( + element.find('> svg > g').eq(0).find('> rect.correct-answer').attr('width') / + element.find('> svg > g').eq(0).find('> rect:not(.correct-answer)').attr('width')).toBe(0.4); + expect(element.find('> svg > g').eq(0).find('> svg > text').text()).toContain('40%'); + expect( + element.find('> svg > g').eq(1).find('> rect.correct-answer').attr('width') / + element.find('> svg > g').eq(1).find('> rect:not(.correct-answer)').attr('width')).toBe(0.1); + expect(element.find('> svg > g').eq(1).find('> svg > text').text()).toContain('10%'); + }); + + it('should update when stats changed', function() { + scope.$apply(function() { + scope.stats = { + "original": {0: 10, 1: 6, 2: 4}, + "revised": {0: 4, 1: 14, 2: 2}, + }; + }); + expect(element.find('> svg > g').eq(0).find('> rect').length).toBe(2); + expect( + element.find('> svg > g').eq(0).find('> rect.correct-answer').attr('width') / + element.find('> svg > g').eq(0).find('> rect:not(.correct-answer)').attr('width')).toBe(0.5); + expect(element.find('> svg > g').eq(0).find('> svg > text').text()).toContain('50%'); + expect( + element.find('> svg > g').eq(1).find('> rect.correct-answer').attr('width') / + element.find('> svg > g').eq(1).find('> rect:not(.correct-answer)').attr('width')).toBe(0.2); + expect(element.find('> svg > g').eq(1).find('> svg > text').text()).toContain('20%'); + }); + }); + + describe('with enough submissions and showing stats for user\'s revision', function() { + var stats = { + "original": {0: 4, 1: 5, 2: 1}, + "revised": {0: 1, 1: 8, 2: 1}, + }; + beforeEach(function() { + scope.$apply(function () { + scope.options = options; + scope.stats = stats; + scope.answers = answers; + scope.correct = correct; + scope.per_answer_stats = answers['revised']; + }); + }); + + it('should not indicate it as user\'s initial answer', function() { + expect(element.find('> svg > g').eq(0).find('> svg > text').text()).not.toContain('including you'); + }); + it('should indicate it as user\'s revision', function() { + expect(element.find('> svg > g').eq(1).find('> svg > text').text()).toContain('including you'); + }); + }); + }) + }); }); diff --git a/ubcpi/static/js/spec/ubcpi_spec.js b/ubcpi/static/js/spec/ubcpi_spec.js index cf368ae..31f1296 100644 --- a/ubcpi/static/js/spec/ubcpi_spec.js +++ b/ubcpi/static/js/spec/ubcpi_spec.js @@ -41,6 +41,69 @@ describe('UBCPI module', function () { }); }); + describe('ubcpi-refresh-rationale directive', function() { + var $compile, + $rootScope, + backendService, + $httpBackend; + + // Store references to $rootScope and $compile + // so they are available to all tests in this describe block + beforeEach(inject(function (_$compile_, _$rootScope_, _$httpBackend_) { + // The injector unwraps the underscores (_) from around the parameter names when matching + $compile = _$compile_; + $rootScope = _$rootScope_; + $httpBackend = _$httpBackend_; + + mockConfig.urls.refresh_other_answers = '/handler/refresh_other_answers'; + })); + + afterEach(function() { + $httpBackend.verifyNoOutstandingExpectation(); + $httpBackend.verifyNoOutstandingRequest(); + }); + + it('should show button to refresh answers shown', function () { + var scope = $rootScope.$new(true); + scope.rc = { + other_answers: { answer: [] } + }; + // Compile a piece of HTML containing the directive + var element = $compile("
    ")(scope); + scope.$digest(); + expect(element.html()).toContain('Show other samples'); + }); + + it('should allow refreshing of answers shown', function () { + var scope = $rootScope.$new(true); + scope.rc = { + other_answers: { answers: [] } + }; + var param = {"option": "1"}; + var exp = { + "other_answers": { + "answers": [ + {"option": 0, "rationale": "Tree gets carbon from air."}, + {"option": 1, "rationale": "Tree gets minerals from soil."}, + {"option": 2, "rationale": "Tree drinks water."}] + }, + "rationale_revised": null, + "answer_original": 1, + "rationale_original": "testing", + "answer_revised": null + }; + var data = undefined; + $httpBackend.expectPOST('/handler/refresh_other_answers', JSON.stringify(param)).respond(200, exp); + + // Compile a piece of HTML containing the directive + var element = $compile("
    ")(scope); + scope.$digest(); + $(element).click(); + $httpBackend.flush(); + expect(scope.rc.other_answers).toEqual(exp.other_answers); + }); + }); + describe('backendService', function() { var backendService, $httpBackend; @@ -128,6 +191,38 @@ describe('UBCPI module', function () { }); }); + describe('refresh_other_answers', function() { + + beforeEach(function() { + mockConfig.urls.refresh_other_answers = '/handler/refresh_other_answers'; + }); + + it('should be able to refresh other answers', function() { + var param = {"option": 1}; + var exp = { + "other_answers": { + "answers": [ + {"option": 0, "rationale": "Tree gets carbon from air."}, + {"option": 1, "rationale": "Tree gets minerals from soil."}, + {"option": 2, "rationale": "Tree drinks water."}] + }, + "rationale_revised": null, + "answer_original": 1, + "rationale_original": "testing", + "answer_revised": null + }; + var data = undefined; + $httpBackend.expectPOST('/handler/refresh_other_answers', JSON.stringify(param)).respond(200, exp); + + backendService.refreshOtherAnswers(1).then(function(d) { + data = d; + }); + $httpBackend.flush(); + + expect(data).toEqual(exp); + }); + }); + describe('submit', function() { var post = { "q": 0, @@ -400,7 +495,6 @@ describe('UBCPI module', function () { }); controller.getStats(); expect(controller.stats).toEqual(response); - expect(controller.calc(0)).toBe(" Initial Answer Selection: 100% Final Answer Selection: 0%"); }); it('should call notify with error when backend errors', function() { @@ -451,8 +545,8 @@ describe('PeerInstructionXBlock function', function() { }); it('should generate URLs using runtime', function() { - expect(mockRuntime.handlerUrl.calls.count()).toBe(4); + expect(mockRuntime.handlerUrl.calls.count()).toBe(5); expect(mockRuntime.handlerUrl.calls.allArgs()).toEqual( - [[mockElement, 'get_stats'], [mockElement, 'submit_answer'], [mockElement, 'get_asset'], [mockElement, 'get_data']]); + [[mockElement, 'get_stats'], [mockElement, 'submit_answer'], [mockElement, 'get_asset'], [mockElement, 'get_data'], [mockElement, 'refresh_other_answers']]); }); }); diff --git a/ubcpi/static/js/src/d3-pibar.js b/ubcpi/static/js/src/d3-pibar.js index d76bb17..f12d5d5 100644 --- a/ubcpi/static/js/src/d3-pibar.js +++ b/ubcpi/static/js/src/d3-pibar.js @@ -156,3 +156,140 @@ d3.custom.barChart = function(scope, gettext) { return chart; }; +/** + * Data format: + * [ + * {percentage: 40, order: 0, label: '(including you) initial choise', class: 'ubcpibar'}, + * {percentage: 75, order: 1, label: 'after revision', class: 'ubcpibar'}, + * ] + */ +d3.custom.perAnswerChart = function(scope, gettext, allAnswerCount) { + // Private Variables + var chartWidth = 750; + var chartHeight = 56; + var labelWidth = 150; + var minTotalFrequency = 10; + + if(scope.role == 'instructor' || scope.role == 'staff'){ minTotalFrequency = 1} + + function chart(selection) { + selection.each(function(data) { + var totalFreq = allAnswerCount; + + // sort bars based on given order + data = data.sort(function(a, b) { + return d3.ascending(a.order, b.order); + }); + + if (totalFreq < minTotalFrequency) { + d3.select(this).selectAll("svg > *").remove(); + d3.select(this) + .append("span") + .attr("id", 'not-enough-data') + .text(gettext("Not enough data to generate the chart. Please check back later.")); + return; + } else { + var notEnoughDataSpan = d3.select(this).select('#not-enough-data'); + if (typeof notEnoughDataSpan !== 'undefined') { + notEnoughDataSpan.remove(); + } + } + + var svg = d3.select(this) + .classed("svg-container", true) + .append("svg") + .attr("preserveAspectRatio", "xMaxYMax meet") + .attr("viewBox", "0 0 " + chartWidth + " " + chartHeight) + .classed("svg-content-responsive", true); + + // x and y scale + var x = d3.scale.linear() + .range([0, chartWidth-labelWidth]) + .domain([0, 100]); + var y = d3.scale.ordinal() + .rangeRoundBands([0, chartHeight], 0.2) + .domain(data.map(function (d) { + return d.type; + })); + + var bars = svg.selectAll("g.bar") + .data(data) + .enter() + .append("g") + .attr("transform", function(d) { + return "translate(0," + y(d.type) + ")"; + }); + + // label of each bar + bars.append("text") + .attr("x", "0em") + .attr("dy", "1.2em") + .attr("text-anchor", "left") + .attr("class", function(d) { + return d.label_class; + }) + .text(function(d) { return d.type; }); + + // background of each bar at 100% full length + bars.append("rect") + .style("fill", "#dddddd") + .attr("x", labelWidth) + .attr("rx", 3) + .attr("ry", 3) + .attr("height", y.rangeBand()) + .attr("width", function(d) { + return x(100); + }); + + // the actual bar based on given percentage + bars.append("rect") + .attr("class", function (d) { + return d.class; + }) + .attr("x", labelWidth) + .attr("rx", 3) + .attr("ry", 3) + .attr("height", y.rangeBand()) + .attr("width", function (d) { + return x(d.percentage); + }); + + // text on each bar + bars.append("svg") + .attr({ height: y.rangeBand() }) + .append("text") + .attr("x", labelWidth+10) + .attr("dy", "1.2em") + .attr("text-anchor", "left") + .attr("class", function(d) { + return d.class; + }) + .text(function(d) { return Math.round(d.percentage) + "% " + d.text; }); + }); + + } + + // Public Variables/ (Getters and Setters) + chart.width = function(width) { + if (!arguments.length) return chartWidth; + chartWidth = width; + + return this; + }; + + chart.height = function(height) { + if (!arguments.length) return chartHeight; + chartHeight = height; + + return this; + }; + + chart.minTotalFrequency = function(min) { + if (!arguments.length) return minTotalFrequency; + minTotalFrequency = min; + + return this; + }; + + return chart; +}; diff --git a/ubcpi/static/js/src/ubcpi-barchart-directive.js b/ubcpi/static/js/src/ubcpi-barchart-directive.js index c88325b..6fb860f 100644 --- a/ubcpi/static/js/src/ubcpi-barchart-directive.js +++ b/ubcpi/static/js/src/ubcpi-barchart-directive.js @@ -37,3 +37,58 @@ angular.module('UBCPI'). } } }]); + + +angular.module('UBCPI'). + directive('piPerAnswerChart', ['gettext', function(gettext){ + return { + restrict: 'E', + scope: { + options: '=', + stats: '=', + correct: '=', + role: '=', + answers: '=', + perAnswerStats: '=' + }, + // no overwrite template + replace: false, + link: function(scope, element) { + // watch the stats as it could be async populated + scope.$watch('stats', function(stats) { + if(!stats) { + return; + } + + var data = []; + var allAnswerCount = 0; + for (var k in stats) { + var total = 0; + for (var op in stats[k]) { + total += stats[k][op]; + } + if (total > allAnswerCount) { + allAnswerCount = total; + } + data.push({ + percentage: (total > 0 && typeof stats[k][scope.perAnswerStats] !== 'undefined'? + (stats[k][scope.perAnswerStats] / total) * 100 : 0), + order: k === 'original'? 0 : 1, + text: (scope.answers[k] == scope.perAnswerStats? gettext('(including you) ') : ''), + class: 'ubcpibar' + (scope.correct == scope.perAnswerStats ? ' correct-answer' : '') + + (k === 'original'? ' original' : ''), + label_class: 'ubcpibar label', + type: k === 'original'? gettext('Chose initially') : gettext('Chose after revision') + }); + } + + d3.select(element[0]).select("svg").remove(); // remove old chart + // generate the chart + var chartLayout = d3.custom.perAnswerChart(scope, gettext, allAnswerCount); + d3.select(element[0]) + .datum(data) + .call(chartLayout); + }, true) + } + } + }]); diff --git a/ubcpi/static/js/src/ubcpi.js b/ubcpi/static/js/src/ubcpi.js index 6087b65..7f06fd2 100644 --- a/ubcpi/static/js/src/ubcpi.js +++ b/ubcpi/static/js/src/ubcpi.js @@ -69,17 +69,78 @@ angular.module('UBCPI', ['ngSanitize', 'ngCookies', 'gettext']) if (!target) { target = ele; } - $('html,body').animate({scrollTop: target.offset().top}, "slow"); + if (target && target.offset()) { + $('html,body').animate({scrollTop: target.offset().top}, "slow"); + } }); } }; }) + /** + * Scroll to the xblock progress bar + */ + .directive('scrollToProgressBar', function() { + return { + restrict: 'A', + scope: { + scrollToProgressBar: '@' + }, + link: function(scope, ele, attr, ctrl) { + ele.on(scope.scrollToProgressBar? scope.scrollToProgressBar : 'click', function() { + var target; + target = ele.parents('.ubcpi_block').find('.ubcpi_progress_bar'); + if (!target) { + target = ele; + } + if (target && target.offset()) { + $('html,body').animate({scrollTop: target.offset().top}, "slow"); + } + }); + } + }; + }) + + .directive('ubcpiRefreshRationale', ['backendService', 'notify', 'gettext', function(backendService, notify, gettext) { + return { + retrict: 'A', + replace: false, + scope: { + ubcpiRefreshModel: '=', + }, + link: function(scope, ele, attr, ctrl) { + var option = attr.ubcpiOption; + var desc = '' + gettext('Show other samples') + ''; + var desc_loading = '' + gettext('Refreshing...') + ''; + + function call_refresh() { + ele.empty().append(' ' + desc_loading); + backendService.refreshOtherAnswers(option).then(function(data) { + if (data && data.other_answers && data.other_answers.answers) { + scope.ubcpiRefreshModel = data.other_answers.answers + } + }, function(error) { + notify('error', { + 'title': gettext('Error refreshing answers from other students'), + 'message': gettext('Please refresh the page and try again!') + }); + }).finally(function() { + ele.empty().append(' ' + desc); + }); + } + + ele.on('click', call_refresh); + ele.empty().append(' ' + desc); + } + } + }]) + .factory('backendService', ['$http', '$q', '$rootScope', function ($http, $q, $rootScope) { return { getStats: getStats, submit: submit, get_data: get_data, + refreshOtherAnswers: refreshOtherAnswers, }; function getStats() { @@ -121,6 +182,21 @@ angular.module('UBCPI', ['ngSanitize', 'ngCookies', 'gettext']) } ); } + + function refreshOtherAnswers(option) { + var refreshUrl = $rootScope.config.urls.refresh_other_answers; + var refreshParam = JSON.stringify({ + "option": option, + }); + return $http.post(refreshUrl, refreshParam).then( + function(response) { + return response.data; + }, + function(error) { + return $q.reject(error); + } + ); + } }]) .controller('ReviseController', ['$scope', 'notify', 'backendService', '$q', 'gettext', '$location', @@ -151,6 +227,9 @@ angular.module('UBCPI', ['ngSanitize', 'ngCookies', 'gettext']) // By default, we're not submitting, this changes when someone presses the submit button self.submitting = false; + // Whether user is revising the answer + self.revising = false; + /** * Determine if the submit button should be disabled * If we have an answer selected, a rationale that is large @@ -191,6 +270,14 @@ angular.module('UBCPI', ['ngSanitize', 'ngCookies', 'gettext']) self.getStats = function () { return backendService.getStats().then(function(data) { self.stats = data; + + self.perAnswerStats = {}; + for (var i = 0; i < $scope.options.length; i++) { + self.perAnswerStats[i] = { + 'original': (typeof self.stats.original[i] !== 'undefined'? self.stats.original[i] : 0), + 'revised' : (typeof self.stats.revised[i] !== 'undefined'? self.stats.revised[i] : 0) + } + } }, function(error) { notify('error', { 'title': gettext('Error retrieving statistics!'), @@ -200,34 +287,6 @@ angular.module('UBCPI', ['ngSanitize', 'ngCookies', 'gettext']) }); }; - self.calc = function(s) { - var originalPercentage = gettext(" Initial Answer Selection: "); - var revisedPercentage = gettext(" Final Answer Selection: "); - if (typeof self.stats !== 'undefined' && typeof s !== 'undefined' && typeof self.stats.original[s] !== 'undefined') { - var totalCounts = 0; - for (var i = 0; i < data.options.length; i++) { - if (typeof self.stats.original[i] !== 'undefined') - totalCounts += self.stats.original[i]; - } - originalPercentage += self.stats.original[s] / totalCounts * 100 + "%"; - } - else - originalPercentage += "0%"; - - if (typeof self.stats !== 'undefined' && typeof s !== 'undefined' && typeof self.stats.revised[s] !== 'undefined') { - var totalCounts = 0; - for (var i = 0; i < data.options.length; i++) { - if (typeof self.stats.revised[i] !== 'undefined') - totalCounts += self.stats.revised[i]; - } - revisedPercentage += self.stats.revised[s] / totalCounts * 100 + "%"; - } - else - revisedPercentage += "0%"; - - return originalPercentage + " " + revisedPercentage; - }; - function get_data() { return backendService.get_data().then(function(data) { return data; @@ -255,8 +314,17 @@ angular.module('UBCPI', ['ngSanitize', 'ngCookies', 'gettext']) self.rationale = data.rationale_revised || data.rationale_original; self.weight = data.weight; self.options = data.options; + self.alt_answers_available = data.alt_answers_available; } + self.hasSampleExplanationForOption = function (option) { + for (var index in self.other_answers.answers) { + if (option == self.other_answers.answers[index].option) { + return true; + } + } + return false; + }; }]); /** @@ -279,7 +347,8 @@ function PeerInstructionXBlock(runtime, element, data) { 'get_stats': runtime.handlerUrl(element, 'get_stats'), 'submit_answer': runtime.handlerUrl(element, 'submit_answer'), 'get_asset': runtime.handlerUrl(element, 'get_asset'), - 'get_data': runtime.handlerUrl(element, 'get_data') + 'get_data': runtime.handlerUrl(element, 'get_data'), + 'refresh_other_answers': runtime.handlerUrl(element, 'refresh_other_answers'), }; // in order to support multiple same apps on the same page but diff --git a/ubcpi/test/data/refresh_other_answers.json b/ubcpi/test/data/refresh_other_answers.json new file mode 100644 index 0000000..cb698b2 --- /dev/null +++ b/ubcpi/test/data/refresh_other_answers.json @@ -0,0 +1,35 @@ +{ + "valid": { + "submit_answer_param": { + "q": 0, + "rationale": "This is my answer.", + "status": 0 + }, + "refresh_params": { + "option": "2" + }, + "expect": { + "other_answers": { + "answers": [ + { + "option": 0, + "rationale": "This is answer shown for option 0" + }, + { + "option": 1, + "rationale": "This is answer shown for option 1" + }, + { + "option": 2, + "rationale": "This is answer shown for option 2" + } + ] + }, + "answer_original": 0, + "rationale_original": "This is my answer.", + "answer_revised": null, + "rationale_revised": null, + "alt_answers_available": {"1": false, "0": true, "2": false} + } + } +} \ No newline at end of file diff --git a/ubcpi/test/data/submit_answer.json b/ubcpi/test/data/submit_answer.json index 9076c83..ce4dbda 100644 --- a/ubcpi/test/data/submit_answer.json +++ b/ubcpi/test/data/submit_answer.json @@ -25,7 +25,8 @@ "answer_original": 0, "rationale_original": "This is my answer.", "answer_revised": null, - "rationale_revised": null + "rationale_revised": null, + "alt_answers_available": {"1": false, "0": true, "2": false} }, "post2": { "q": 1, diff --git a/ubcpi/test/test_answer_pool.py b/ubcpi/test/test_answer_pool.py index 2d6547b..6b80f2a 100644 --- a/ubcpi/test/test_answer_pool.py +++ b/ubcpi/test/test_answer_pool.py @@ -4,7 +4,7 @@ from mock import patch, call, MagicMock from ubcpi.answer_pool import offer_answer, validate_seeded_answers_simple, UnknownChooseAnswerAlgorithm, \ validate_seeded_answers_random, validate_seeded_answers, get_other_answers, get_other_answers_simple, \ - get_other_answers_random, get_max_size, POOL_ITEM_LENGTH_SIMPLE + get_other_answers_random, get_max_size, POOL_ITEM_LENGTH_SIMPLE, refresh_answers from ubcpi.persistence import Answers, VOTE_KEY, RATIONALE_KEY @@ -154,3 +154,69 @@ def test_get_max_size(self): self.assertEqual(get_max_size({}, 3, POOL_ITEM_LENGTH_SIMPLE), 102) self.assertEqual(get_max_size({}, 10, POOL_ITEM_LENGTH_SIMPLE), 67) self.assertEqual(get_max_size({1: {i: {} for i in xrange(10)}}, 10, POOL_ITEM_LENGTH_SIMPLE), 62) + + @patch( + 'ubcpi.persistence.get_answers_for_student', + return_value=Answers([{VOTE_KEY: 0, RATIONALE_KEY: 'my rationale'}]) + ) + @file_data('data/get_other_answers_simple.json') + def test_refresh_answers_normal(self, data, mock): + answer_shown = {'answers': [ + {'option': 0, 'rationale': 'rationale to be refreshed'}, + {'option': 1, 'rationale': 'rationale should stay'}, + ]} + option = 0 + pool = data['pool'] + student_item_dict_func = MagicMock(return_value={'student_id': data['user_id']}) + seeded_answers = data['seeds'] + refreshed = refresh_answers(answer_shown, option, pool, seeded_answers, student_item_dict_func) + for answer in refreshed['answers']: + if answer['option'] == 0: + self.assertNotEqual(answer['rationale'], 'rationale to be refreshed') + else: + self.assertEqual(answer['rationale'], 'rationale should stay') + + @patch( + 'ubcpi.persistence.get_answers_for_student', + return_value=Answers([{VOTE_KEY: 0, RATIONALE_KEY: 'my rationale'}]) + ) + @file_data('data/get_other_answers_simple.json') + def test_refresh_answers_no_seed(self, data, mock): + answer_shown = {'answers': [ + {'option': 0, 'rationale': 'rationale to be refreshed if pool not empty'}, + {'option': 1, 'rationale': 'rationale should stay'}, + ]} + option = 0 + pool = data['pool'] + student_item_dict_func = MagicMock(return_value={'student_id': data['user_id']}) + seeded_answers = [] + refreshed = refresh_answers(answer_shown, option, pool, seeded_answers, student_item_dict_func) + for answer in refreshed['answers']: + if answer['option'] == 0: + if len(pool) > 0: + self.assertNotEqual(answer['rationale'], 'rationale to be refreshed if pool not empty') + else: + self.assertEqual(answer['rationale'], 'rationale to be refreshed if pool not empty') + else: + self.assertEqual(answer['rationale'], 'rationale should stay') + + @patch( + 'ubcpi.persistence.get_answers_for_student', + return_value=Answers([{VOTE_KEY: 0, RATIONALE_KEY: 'my rationale'}]) + ) + @file_data('data/get_other_answers_simple.json') + def test_refresh_answers_seeded_first(self, data, mock): + answer_shown = {'answers': [ + {'option': 0, 'rationale': 'rationale to be refreshed'}, + {'option': 1, 'rationale': 'rationale should stay'}, + ]} + option = 0 + pool = data['pool'] + student_item_dict_func = MagicMock(return_value={'student_id': data['user_id']}) + seeded_answers = data['seeds'] + refreshed = refresh_answers(answer_shown, option, pool, seeded_answers, student_item_dict_func, True) + for answer in refreshed['answers']: + if answer['option'] == 0: + self.assertNotEqual(answer['rationale'], 'rationale to be refreshed') + else: + self.assertEqual(answer['rationale'], 'rationale should stay') diff --git a/ubcpi/test/test_lms.py b/ubcpi/test/test_lms.py index 7a86129..c615f3c 100644 --- a/ubcpi/test/test_lms.py +++ b/ubcpi/test/test_lms.py @@ -211,3 +211,31 @@ def check_fields(self, xblock, data): for key, value in data.iteritems(): self.assertIsNotNone(getattr(xblock, key)) self.assertEqual(getattr(xblock, key), value) + + @patch('ubcpi.ubcpi.get_other_answers') + @file_data('data/refresh_other_answers.json') + @scenario(os.path.join(os.path.dirname(__file__), 'data/basic_scenario.xml'), user_id='Bob') + def test_refresh_other_answers(self, xblock, data, mock): + # patch get_other_answers to avoid randomness + mock.return_value = data['expect']['other_answers'] + resp = self.request(xblock, 'submit_answer', json.dumps(data['submit_answer_param']), response_format='json') + self.assertEqual(resp, data['expect']) + + original_other_ans = [ans['rationale'] for ans in xblock.other_answers_shown['answers'] if ans['option'] == int(data['refresh_params']['option'])] + + resp = self.request(xblock, 'refresh_other_answers', json.dumps({}), response_format='json') + self.assertEqual(resp, {'error': 'Missing option'}) + resp = self.request(xblock, 'refresh_other_answers', json.dumps({'option': -1}), response_format='json') + self.assertEqual(resp, {'error': 'Invalid option'}) + + resp = self.request(xblock, 'refresh_other_answers', json.dumps(data['refresh_params']), response_format='json') + self.assertEqual(xblock.other_answers_refresh_count[data['refresh_params']['option']] , 1) + self.assertEqual(len(xblock.other_answers_shown_history), 3) + refreshed_other_ans = [ans['rationale'] for ans in xblock.other_answers_shown['answers'] if ans['option'] == int(data['refresh_params']['option'])] + # rationale of the refreshed option should be chanaged + self.assertNotEqual(original_other_ans, refreshed_other_ans) + + resp = self.request(xblock, 'refresh_other_answers', json.dumps(data['refresh_params']), response_format='json') + # refresh count of the option should be increased + self.assertEqual(xblock.other_answers_refresh_count[data['refresh_params']['option']] , 2) + self.assertEqual(len(xblock.other_answers_shown_history), 3) diff --git a/ubcpi/ubcpi.py b/ubcpi/ubcpi.py index a630a85..ae246eb 100644 --- a/ubcpi/ubcpi.py +++ b/ubcpi/ubcpi.py @@ -11,12 +11,14 @@ from webob import Response from xblock.core import XBlock from xblock.exceptions import JsonHandlerError +# For supporting manual revision of scores. Commented out for now. +# from xblock.scorable import ScorableXBlockMixin, Score from xblock.fields import Scope, String, List, Dict, Integer, DateTime, Float from xblock.fragment import Fragment from xblockutils.publish_event import PublishEventMixin from .utils import _ # pylint: disable=unused-import -from answer_pool import offer_answer, validate_seeded_answers, get_other_answers +from answer_pool import offer_answer, validate_seeded_answers, get_other_answers, get_other_answers_count, refresh_answers import persistence as sas_api from serialize import parse_from_xml, serialize_to_xml @@ -30,6 +32,10 @@ MAX_RATIONALE_SIZE = 32000 MAX_RATIONALE_SIZE_IN_EVENT = settings.TRACK_MAX_EVENT / 4 +# max number of times the student can refresh to see other student answers shown to them. +# afterward, will fallback to only return seeded answers +MAX_REFRESH_PER_OPTION = 5 + def truncate_rationale(rationale, max_length=MAX_RATIONALE_SIZE_IN_EVENT): """ Truncates the rationale for analytics event emission if necessary @@ -251,13 +257,21 @@ class PeerInstructionXBlock(XBlock, MissingDataFetcherMixin, PublishEventMixin): help=_("Stores the specific answers of other students shown, for a given student."), ) + other_answers_shown_history = Dict( + default={}, scope=Scope.user_state, + help=_("In case the student requested to see more answers, these are the answers previously shown."), + ) + + other_answers_refresh_count = Dict( + default={}, scope=Scope.user_state, + help=_("Keep track of the number of refreshes for each option") + ) + algo = Dict( default={'name': 'simple', 'num_responses': '#'}, scope=Scope.content, help=_("The algorithm for selecting which answers to be presented to students"), ) - # Declare that we are not part of the grading System. Disabled for now as for the concern about the loading - # speed of the progress page. has_score = True start = DateTime( @@ -293,6 +307,57 @@ def max_score(self): """ return 1 + # def calculate_score(self): + # answers = self.get_answers_for_student() + # if answers.has_revision(0) and answers.has_revision(1): + # return Score(1, 1) + # return Score(0, 1) + # + # def set_score(self, score): + # # TODO persisting score + # pass + # + # def get_score(self): + # # TODO Since we are not persisting score, always return 1. + # # That means the Overriding Score function will always set the score to 1 + # # Instructors can reset the score to 0 by deleting learner's state + # return Score(1, 1) + + def clear_student_state(self, user_id, course_id, item_id, requesting_user_id): + """ + Being notified that student state is going to be deleted. Mark student's + submissions as deleted + """ + student_item = dict( + student_id=user_id, + item_id=item_id, + course_id=course_id, + item_type='ubcpi' + ) + + # TODO currently not possible to revise the stats as they are defined with scope Scope.user_state_summary. + # The stats are not available when clear_student_state is called + # answers = sas_api.get_answers_for_student(student_item) + # stats = self.get_current_stats() + # if answers.has_revision(0): + # num_resp = stats['original'].setdefault(answers.get_vote(0), 0) + # if num_resp > 0: + # stats['original'][answers.get_vote(0)] = num_resp - 1 + # if answers.has_revision(1): + # num_resp = stats['revised'].setdefault(answers.get_vote(1), 0) + # if num_resp > 0: + # stats['revised'][answers.get_vote(1)] = num_resp - 1 + + # mark existing submission as deleted + sas_api.delete_answer_for_student(student_item, requesting_user_id) + + # def has_submitted_answer(self): + # answers = self.get_answers_for_student() + # return answers.has_revision(0) and answers.has_revision(1) + # + # def publish_grade(self): + # self._publish_grade(self.get_score()) + def studio_view(self, context=None): """ view function for studio edit @@ -538,6 +603,10 @@ def record_response(self, answer, rationale, status): student_item['student_id'], self.algo, self.options) self.other_answers_shown = get_other_answers( self.sys_selected_answers, self.seeds, self.get_student_item_dict, self.algo, self.options) + # reset the shown answers history and clear the refresh counts + self._reset_answers_shown_history() + self._record_answers_shown(self.other_answers_shown) + event_dict['other_student_responses'] = self.other_answers_shown self.publish_event_from_dict( self.event_namespace + '.original_submitted', @@ -611,6 +680,7 @@ def get_persisted_data(self, other_answers): Adds the other answers and correct answer/rationale when needed """ answers = self.get_answers_for_student() + pool_count = get_other_answers_count(self.sys_selected_answers, self.seeds, self.get_student_item_dict) ret = { "answer_original": answers.get_vote(0), "rationale_original": answers.get_rationale(0), @@ -618,7 +688,22 @@ def get_persisted_data(self, other_answers): "rationale_revised": answers.get_rationale(1), } if answers.has_revision(0) and not answers.has_revision(1): - ret['other_answers'] = other_answers + # If no persisted peer answers, generate new ones. + # Could happen if a student completed Step 1 before ubcpi upgraded to persist peer answers. + if not other_answers: + ret['other_answers'] = get_other_answers( + self.sys_selected_answers, self.seeds, self.get_student_item_dict, self.algo, self.options) + else: + ret['other_answers'] = other_answers + + ret['alt_answers_available'] = {} + # count how many answers we are showing for each option + showing_count = {} + for ans in ret['other_answers'].get('answers', []): + if ans.get('option', None) is not None: + showing_count[ans['option']] = showing_count.get(ans['option'], 0) + 1 + for key in pool_count: + ret['alt_answers_available'][key] = True if pool_count[key] > showing_count.get(key, 0) else False # reveal the correct answer in the end if answers.has_revision(1): @@ -701,3 +786,59 @@ def add_xml_to_node(self, node): Serialize the XBlock to XML for exporting. """ serialize_to_xml(node, self) + + @XBlock.json_handler + def refresh_other_answers(self, data, suffix=''): + """ + Refresh other answers shown, if possible + """ + if 'option' not in data: + raise JsonHandlerError(400, 'Missing option') + the_option = int(data['option']) + if the_option < 0 or the_option >= len(self.options): + raise JsonHandlerError(400, 'Invalid option') + + student_item = self.get_student_item_dict() + if not student_item or not student_item['student_id']: + raise JsonHandlerError(400, 'Missing student info') + + # can refresh only after intial submission and before final submission + answers = self.get_answers_for_student() + if not(answers.has_revision(0) and not answers.has_revision(1)): + return self.get_persisted_data(self.other_answers_shown) + + seeded_first = self._over_answers_shown_refresh_limit(the_option) + self.other_answers_shown = \ + refresh_answers(self.other_answers_shown, the_option, self.sys_selected_answers, self.seeds, self.get_student_item_dict, seeded_first) + + self._incr_answers_shown_refresh_count(the_option) + self._record_answers_shown(self.other_answers_shown) + + return self.get_persisted_data(self.other_answers_shown) + + def _reset_answers_shown_history(self): + self.other_answers_shown_history = {} + self.other_answers_refresh_count = {} + + def _record_answers_shown(self, answers_shown): + import time + answers = answers_shown.get("answers", None) + + for option in range(len(self.options)): + key = str(option) + answers_shown_for_option = self.other_answers_shown_history.setdefault(key, {}) + # see if we are showing new answers for this option + for ans in answers: + if ans.get('option', None) is not None and str(ans['option']) == key: + answers_shown_for_option[ans.get('rationale', None)] = time.time() + + def _incr_answers_shown_refresh_count(self, option): + key = str(option) + count = self.other_answers_refresh_count.get(key, 0) + 1 + self.other_answers_refresh_count[key] = count + + def _over_answers_shown_refresh_limit(self, option): + key = str(option) + count = self.other_answers_refresh_count.get(key, 0) + return count > MAX_REFRESH_PER_OPTION +