lms.backend.canvas.courses.quizzes.download
1import logging 2import math 3import typing 4 5import quizcomp.model.answer 6import quizcomp.model.constants 7import quizcomp.model.group 8import quizcomp.model.question 9import quizcomp.model.quiz 10import quizcomp.parser.document 11 12import lms.backend.canvas.common 13import lms.backend.canvas.courses.quizzes.common 14import lms.model.constants 15 16_logger = logging.getLogger(__name__) 17 18LIST_GROUPS_ENDPOINT = "/api/v1/courses/{course_id}/quizzes/{quiz_id}/groups" 19LIST_QUESTIONS_ENDPOINT = "/api/v1/courses/{course_id}/quizzes/{quiz_id}/questions?per_page={page_size}" 20 21QUESTION_TYPE_MAP: typing.Dict[str, quizcomp.model.constants.QuestionType] = { 22 'essay_question': quizcomp.model.constants.QuestionType.ESSAY, 23 'fill_in_multiple_blanks_question': quizcomp.model.constants.QuestionType.FIMB, 24 'matching_question': quizcomp.model.constants.QuestionType.MATCHING, 25 'multiple_answers_question': quizcomp.model.constants.QuestionType.MA, 26 'multiple_choice_question': quizcomp.model.constants.QuestionType.MCQ, 27 'multiple_dropdowns_question': quizcomp.model.constants.QuestionType.MDD, 28 'numerical_question': quizcomp.model.constants.QuestionType.NUMERICAL, 29 'short_answer_question': quizcomp.model.constants.QuestionType.FITB, 30 'text_only_question': quizcomp.model.constants.QuestionType.TEXT_ONLY, 31 'true_false_question': quizcomp.model.constants.QuestionType.TF, 32} 33 34DISALLOWED_QUESTION_TYPES: typing.Set[str] = { 35 'calculated_question', 36 'file_upload_question', 37} 38 39DEFAULT_BLANK_ID: str = '' 40 41def request( 42 backend: typing.Any, 43 course_id: int, 44 quiz_id: int, 45 ) -> quizcomp.model.quiz.Quiz: 46 """ Download a quiz. """ 47 48 quiz_metadata = backend.courses_quizzes_fetch(str(course_id), str(quiz_id)) 49 if (quiz_metadata is None): 50 raise ValueError(f"Unable to fetch quiz metadata for quiz ID '{quiz_id}'.") 51 52 questions = _list_questions(backend, course_id, quiz_id) 53 groups = _list_groups(backend, course_id, quiz_id, questions) 54 55 return quizcomp.model.quiz.Quiz( 56 name = quiz_metadata.name, 57 children = groups, 58 description = quiz_metadata.description, 59 time_limit_mins = quiz_metadata.extra_fields.get('time_limit', None), 60 practice = (quiz_metadata.extra_fields.get('quiz_type', None) == lms.backend.canvas.courses.quizzes.common.QUIZ_TYPE_PRACTICE), 61 publish = quiz_metadata.extra_fields.get('published', None), 62 allowed_attempts = quiz_metadata.extra_fields.get('allowed_attempts', None), 63 show_correct_answers = quiz_metadata.extra_fields.get('show_correct_answers', None), 64 hide_results = quiz_metadata.extra_fields.get('hide_results', None), 65 scoring_policy = quiz_metadata.extra_fields.get('scoring_policy', None), 66 ) 67 68def _list_groups( 69 backend: typing.Any, 70 course_id: int, 71 quiz_id: int, 72 questions: typing.Dict[int, quizcomp.model.question.Question], 73 ) -> typing.List[quizcomp.model.group.Group]: 74 """ List quiz question groups. """ 75 76 url = backend.server + LIST_GROUPS_ENDPOINT.format(course_id = course_id, quiz_id = quiz_id) 77 headers = backend.get_standard_headers() 78 79 raw_objects = lms.backend.canvas.common.make_get_request(url, headers = headers) 80 if (raw_objects is None): 81 identifiers = { 82 'course_id': course_id, 83 'quiz_id': quiz_id, 84 } 85 backend.not_found('list quiz question groups', identifiers) 86 87 return [] 88 89 groups = [] 90 for raw_group in raw_objects.get('quiz_groups', []): 91 group = quizcomp.model.group.Group( 92 name = raw_group['name'], 93 children = questions.get(raw_group['id'], []), 94 pick_count = raw_group['pick_count'], 95 points = raw_group['pick_count'] * raw_group['question_points'], 96 ) 97 98 shuffle_answers = raw_group.get('shuffle_answers', None) 99 if (shuffle_answers is not None): 100 group.attributes['shuffle_answers'] = shuffle_answers 101 102 groups.append(group) 103 104 return groups 105 106def _list_questions( 107 backend: typing.Any, 108 course_id: int, 109 quiz_id: int, 110 ) -> typing.Dict[int, quizcomp.model.question.Question]: 111 """ List quiz questions grouped by group id. """ 112 113 url = backend.server + LIST_QUESTIONS_ENDPOINT.format( 114 course_id = course_id, quiz_id = quiz_id, page_size = lms.backend.canvas.common.DEFAULT_PAGE_SIZE) 115 headers = backend.get_standard_headers() 116 117 raw_objects = lms.backend.canvas.common.make_get_request_list(url, headers = headers) 118 if (raw_objects is None): 119 identifiers = { 120 'course_id': course_id, 121 'quiz_id': quiz_id, 122 } 123 backend.not_found('list quiz questions', identifiers) 124 125 return {} 126 127 questions: typing.Dict[int, quizcomp.model.question.Question] = {} 128 for raw_question in raw_objects: 129 raw_question_type = raw_question['question_type'] 130 if (raw_question_type in DISALLOWED_QUESTION_TYPES): 131 _logger.warning("Found question ('%s') with disallowed question type '%s', skipping download of that question.", 132 raw_question['question_name'], raw_question_type) 133 continue 134 135 if (raw_question_type not in QUESTION_TYPE_MAP): 136 _logger.warning("Found question ('%s') with unknown question type '%s', skipping download of that question.", 137 raw_question['question_name'], raw_question_type) 138 continue 139 140 question_type = QUESTION_TYPE_MAP[raw_question_type] 141 142 prompt_text = lms.backend.canvas.common.html_to_markdown(raw_question['question_text']) 143 144 question = quizcomp.model.question.Question( 145 name = raw_question['question_name'], 146 question_type = question_type, 147 prompt = quizcomp.parser.document.ParsedDocument.parse_text(prompt_text), 148 points = raw_question['points_possible'], 149 feedback = _parse_raw_feedback(raw_question), 150 answers = _parse_answers(question_type, raw_question), 151 ) 152 153 group_id = raw_question['quiz_group_id'] 154 if (group_id not in questions): 155 questions[group_id] = [] 156 157 questions[group_id].append(question) 158 159 return questions 160 161def _parse_raw_feedback(raw_question: typing.Dict[str, typing.Any]) -> typing.Union[quizcomp.model.feedback.Feedback, None]: 162 """ Try to parse feedback our of a raw question. """ 163 164 parts = { 165 'general': ('neutral_comments', 'neutral_comments_html'), 166 'correct': ('correct_comments', 'correct_comments_html'), 167 'incorrect': ('incorrect_comments', 'incorrect_comments_html'), 168 } 169 170 feedback_kwargs = {} 171 for (feedback_type, (text_key, html_key)) in parts.items(): 172 feedback_kwargs[feedback_type] = _parse_text(raw_question, text_key, html_key) 173 174 feedback = quizcomp.model.feedback.Feedback(**feedback_kwargs) 175 if (feedback.is_empty()): 176 return None 177 178 return feedback 179 180def _parse_text( 181 raw_data: typing.Dict[str, typing.Any], 182 text_key: str, 183 html_key: str, 184 default_text: typing.Union[str, None] = None, 185 ) -> typing.Union[quizcomp.parser.document.ParsedDocument, None]: 186 """ 187 Parse text from raw Canvas data. 188 Canvas will often have both text and HTML fields (but only one will usually be filled). 189 If both keys are provided, the text one will be check first (and returned if it has content). 190 191 If no text is found and the default text is not None, 192 then it will be parsed and returned. 193 """ 194 195 text_value = raw_data.get(text_key, None) 196 if (text_value is None): 197 text_value = '' 198 199 text_value = str(text_value).strip() 200 if (len(text_value) != 0): 201 return quizcomp.parser.document.ParsedDocument.parse_text(text_value) 202 203 html_value = raw_data.get(html_key, None) 204 if (html_value is None): 205 html_value = '' 206 207 html_value = str(html_value).strip() 208 if (len(html_value) != 0): 209 text = lms.backend.canvas.common.html_to_markdown(html_value) 210 return quizcomp.parser.document.ParsedDocument.parse_text(text) 211 212 if (default_text is not None): 213 return quizcomp.parser.document.ParsedDocument.parse_text(default_text) 214 215 return None 216 217def _parse_answers( 218 question_type: quizcomp.model.constants.QuestionType, 219 raw_question: typing.Dict[str, typing.Any], 220 ) -> quizcomp.model.answer.QuestionAnswers: 221 """ Parse a question's answer from the raw question. """ 222 223 if (question_type is quizcomp.model.constants.QuestionType.ESSAY): 224 # Canvas does not store potential answers (e.g., a rubric) for essay questions. 225 return quizcomp.model.answer.TextAnswers() 226 elif (question_type is quizcomp.model.constants.QuestionType.FIMB): 227 parts = {} 228 for (blank_id, choices) in _parse_choice_answers(raw_question['answers']).parts.items(): 229 options = [quizcomp.model.answer.TextOption(choice.text, choice.feedback) for choice in choices.choices] 230 parts[blank_id] = quizcomp.model.answer.TextAnswers(options) 231 232 return quizcomp.model.answer.MultiplePartTextAnswers(parts) 233 elif (question_type is quizcomp.model.constants.QuestionType.FITB): 234 choices = _parse_choice_answers(raw_question['answers']).parts[DEFAULT_BLANK_ID] 235 options = [quizcomp.model.answer.TextOption(choice.text, choice.feedback) for choice in choices.choices] 236 return quizcomp.model.answer.TextAnswers(options) 237 elif (question_type is quizcomp.model.constants.QuestionType.MATCHING): 238 return _parse_matching_answers(raw_question) 239 elif (question_type is quizcomp.model.constants.QuestionType.MA): 240 return _parse_choice_answers(raw_question['answers']).parts[DEFAULT_BLANK_ID] 241 elif (question_type is quizcomp.model.constants.QuestionType.MCQ): 242 return _parse_choice_answers(raw_question['answers']).parts[DEFAULT_BLANK_ID] 243 elif (question_type is quizcomp.model.constants.QuestionType.MDD): 244 return _parse_choice_answers(raw_question['answers']) 245 elif (question_type is quizcomp.model.constants.QuestionType.NUMERICAL): 246 return _parse_numerical_answers(raw_question['answers']) 247 elif (question_type is quizcomp.model.constants.QuestionType.TEXT_ONLY): 248 return quizcomp.model.answer.TextAnswers() 249 elif (question_type is quizcomp.model.constants.QuestionType.TF): 250 choices = _parse_choice_answers(raw_question['answers']).parts[DEFAULT_BLANK_ID] 251 return quizcomp.model.answer.TFAnswers(choices.choices) 252 else: 253 raise ValueError(f"Unknown question type: '{question_type.value}'.") 254 255def _parse_choice_answers( 256 raw_choices: typing.List[typing.Dict[str, typing.Any]], 257 ) -> quizcomp.model.answer.MultiplePartChoiceAnswers: 258 """ 259 Parse choice answers. 260 The choices will be keyed by the "blank id", which is used in multipart questions. 261 If there is no blank id, then DEFAULT_BLANK_ID will be used. 262 """ 263 264 all_choices: typing.Dict[str, typing.List[quizcomp.model.answer.Choice]] = {} 265 266 for raw_choice in raw_choices: 267 blank_id = raw_choice.get('blank_id', DEFAULT_BLANK_ID) 268 if (blank_id not in all_choices): 269 all_choices[blank_id] = [] 270 271 all_choices[blank_id].append(quizcomp.model.answer.Choice( 272 text = _parse_text(raw_choice, 'text', 'html', ''), 273 correct = math.isclose(raw_choice['weight'], 100.0), 274 feedback = quizcomp.model.feedback.Feedback(general = _parse_text(raw_choice, 'comments', 'comments_html')), 275 )) 276 277 parts = {blank_id: quizcomp.model.answer.ChoiceAnswers(choices) for (blank_id, choices) in all_choices.items()} 278 return quizcomp.model.answer.MultiplePartChoiceAnswers(parts) 279 280def _parse_numerical_answers( 281 raw_options: typing.List[typing.Dict[str, typing.Any]], 282 ) -> quizcomp.model.answer.NumericAnswers: 283 """ 284 Parse numerical answers. 285 See: https://developerdocs.instructure.com/services/canvas/resources/quiz_questions#answer 286 """ 287 288 options = [] 289 for raw_option in raw_options: 290 raw_type = raw_option.get('numerical_answer_type', None) 291 if (raw_type is None): 292 raise ValueError("Numeric answer has no answer type.") 293 294 feedback = quizcomp.model.feedback.Feedback(general = _parse_text(raw_option, 'comments', 'comments_html')) 295 296 if (raw_type == 'exact_answer'): 297 options.append(quizcomp.model.answer.NumericOptionExact( 298 raw_option['exact'], 299 raw_option.get('error_margin', 0.0), 300 feedback = feedback, 301 )) 302 elif (raw_type == 'range_answer'): 303 options.append(quizcomp.model.answer.NumericOptionRange( 304 raw_option['range_start'], 305 raw_option['range_end'], 306 feedback = feedback, 307 )) 308 elif (raw_type == 'precision_answer'): 309 options.append(quizcomp.model.answer.NumericOptionPrecision( 310 raw_option['approximate'], 311 raw_option['precision'], 312 feedback = feedback, 313 )) 314 else: 315 raise ValueError(f"Unknown numerical answer type: '{raw_type}'.") 316 317 return quizcomp.model.answer.NumericAnswers(options) 318 319def _parse_matching_answers( 320 raw_question: typing.Dict[str, typing.Any], 321 ) -> quizcomp.model.answer.NumericAnswers: 322 """ 323 Parse matching answers. 324 The Canvas API documentation is not accurate for this question type. 325 """ 326 327 # {id: text document, ...}. 328 rights = {} 329 for raw_right in raw_question['matches']: 330 rights[raw_right['match_id']] = quizcomp.parser.document.ParsedDocument.parse_text(raw_right['text']) 331 332 # Match each left to a right, removing each matched right. 333 pairs = [] 334 for raw_left in raw_question['answers']: 335 feedback = quizcomp.model.feedback.Feedback(general = _parse_text(raw_left, 'comments', 'comments_html')) 336 left_document = quizcomp.parser.document.ParsedDocument.parse_text(raw_left['text']) 337 338 right_document = rights.get(raw_left['match_id'], None) 339 if (right_document is None): 340 raise ValueError("Unable to find matching right-hand component of a matching pair.") 341 342 del rights[raw_left['match_id']] 343 344 pairs.append((quizcomp.model.answer.TextOption(left_document, feedback), quizcomp.model.answer.TextOption(right_document))) 345 346 # For the remaining rights into distractors. 347 distractors = [quizcomp.model.answer.TextOption(document) for document in rights.values()] 348 349 return quizcomp.model.answer.MatchingAnswers(pairs, distractors)
LIST_GROUPS_ENDPOINT =
'/api/v1/courses/{course_id}/quizzes/{quiz_id}/groups'
LIST_QUESTIONS_ENDPOINT =
'/api/v1/courses/{course_id}/quizzes/{quiz_id}/questions?per_page={page_size}'
QUESTION_TYPE_MAP: Dict[str, quizcomp.model.constants.QuestionType] =
{'essay_question': <QuestionType.ESSAY: 'essay'>, 'fill_in_multiple_blanks_question': <QuestionType.FIMB: 'fill_in_multiple_blanks'>, 'matching_question': <QuestionType.MATCHING: 'matching'>, 'multiple_answers_question': <QuestionType.MA: 'multiple_answers'>, 'multiple_choice_question': <QuestionType.MCQ: 'multiple_choice'>, 'multiple_dropdowns_question': <QuestionType.MDD: 'multiple_dropdowns'>, 'numerical_question': <QuestionType.NUMERICAL: 'numerical'>, 'short_answer_question': <QuestionType.FITB: 'fill_in_the_blank'>, 'text_only_question': <QuestionType.TEXT_ONLY: 'text_only'>, 'true_false_question': <QuestionType.TF: 'true_false'>}
DISALLOWED_QUESTION_TYPES: Set[str] =
{'calculated_question', 'file_upload_question'}
DEFAULT_BLANK_ID: str =
''
def
request(backend: Any, course_id: int, quiz_id: int) -> quizcomp.model.quiz.Quiz:
42def request( 43 backend: typing.Any, 44 course_id: int, 45 quiz_id: int, 46 ) -> quizcomp.model.quiz.Quiz: 47 """ Download a quiz. """ 48 49 quiz_metadata = backend.courses_quizzes_fetch(str(course_id), str(quiz_id)) 50 if (quiz_metadata is None): 51 raise ValueError(f"Unable to fetch quiz metadata for quiz ID '{quiz_id}'.") 52 53 questions = _list_questions(backend, course_id, quiz_id) 54 groups = _list_groups(backend, course_id, quiz_id, questions) 55 56 return quizcomp.model.quiz.Quiz( 57 name = quiz_metadata.name, 58 children = groups, 59 description = quiz_metadata.description, 60 time_limit_mins = quiz_metadata.extra_fields.get('time_limit', None), 61 practice = (quiz_metadata.extra_fields.get('quiz_type', None) == lms.backend.canvas.courses.quizzes.common.QUIZ_TYPE_PRACTICE), 62 publish = quiz_metadata.extra_fields.get('published', None), 63 allowed_attempts = quiz_metadata.extra_fields.get('allowed_attempts', None), 64 show_correct_answers = quiz_metadata.extra_fields.get('show_correct_answers', None), 65 hide_results = quiz_metadata.extra_fields.get('hide_results', None), 66 scoring_policy = quiz_metadata.extra_fields.get('scoring_policy', None), 67 )
Download a quiz.