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 @@
These are samples of other student answers for this question. Read them and then compare with your answer below.
- -