lms.backend.canvas.courses.quizzes.upload
1import os 2import re 3import typing 4 5import edq.util.hash 6import quizcomp.model.answer 7import quizcomp.model.config 8import quizcomp.model.group 9import quizcomp.model.question 10import quizcomp.model.quiz 11 12import lms.backend.canvas.common 13import lms.backend.canvas.courses.quizzes.common 14import lms.model.assignments 15import lms.model.constants 16 17CREATE_FOLDER_ENDPOINT: str = "/api/v1/courses/{course_id}/folders" 18GET_FOLDER_ENDPOINT: str = "/api/v1/courses/{course_id}/folders/by_path{canvas_path}" 19HIDE_FOLDER_ENDPOINT: str = "/api/v1/folders/{folder_id}" 20LIST_ASSIGNMENT_GROUPS_ENDPOINT: str = "/api/v1/courses/{course_id}/assignment_groups?per_page={page_size}" 21UPLOAD_FILE_ENDPOINT: str = "/api/v1/courses/{course_id}/files" 22UPLOAD_GROUP_ENDPOINT: str = "/api/v1/courses/{course_id}/quizzes/{quiz_id}/groups" 23UPLOAD_QUESTION_ENDPOINT: str = "/api/v1/courses/{course_id}/quizzes/{quiz_id}/questions" 24UPLOAD_QUIZ_METADATA_ENDPOINT: str = "/api/v1/courses/{course_id}/quizzes" 25 26CANVAS_QUIZCOMP_BASEDIR: str = '/quiz-composer' 27CANVAS_QUIZCOMP_QUIZ_DIRNAME: str = 'quizzes' 28 29QUIZ_TYPE_ASSIGNMENT: str = 'assignment' 30 31QUESTION_TYPE_MAP: typing.Dict[quizcomp.model.constants.QuestionType, str] = { 32 # Direct Mappings 33 quizcomp.model.constants.QuestionType.ESSAY: 'essay_question', 34 quizcomp.model.constants.QuestionType.FIMB: 'fill_in_multiple_blanks_question', 35 quizcomp.model.constants.QuestionType.MATCHING: 'matching_question', 36 quizcomp.model.constants.QuestionType.MA: 'multiple_answers_question', 37 quizcomp.model.constants.QuestionType.MCQ: 'multiple_choice_question', 38 quizcomp.model.constants.QuestionType.MDD: 'multiple_dropdowns_question', 39 quizcomp.model.constants.QuestionType.NUMERICAL: 'numerical_question', 40 quizcomp.model.constants.QuestionType.TEXT_ONLY: 'text_only_question', 41 quizcomp.model.constants.QuestionType.TF: 'true_false_question', 42 # Indirect Mappings 43 quizcomp.model.constants.QuestionType.FITB: 'short_answer_question', 44 quizcomp.model.constants.QuestionType.SA: 'essay_question', 45} 46 47def request( 48 backend: typing.Any, 49 course_id: int, 50 quiz: quizcomp.model.quiz.Quiz, 51 ) -> lms.model.assignments.Assignment: 52 """ 53 Upload a quiz. 54 55 This is a process that takes many steps. 56 1) Upload Quiz Files 57 2) Upload Quiz Metadata 58 3) Upload Quiz Question Groups (first create question groups and then upload questions). 59 """ 60 61 _upload_quiz_images(backend, course_id, quiz) 62 63 assignment_group_id = _fetch_assignment_group(backend, course_id, quiz) 64 65 quiz_metadata = _upload_quiz_metadata(backend, course_id, quiz, assignment_group_id) 66 67 for group in quiz.get_groups(): 68 _upload_group(backend, course_id, int(quiz_metadata.id), group) 69 70 _restore_image_sources(quiz) 71 72 return quiz_metadata 73 74def _fetch_assignment_group( 75 backend: typing.Any, 76 course_id: int, 77 quiz: quizcomp.model.quiz.Quiz, 78 ) -> typing.Union[int, None]: 79 """ Get the assignment group ID for this quiz, or None if nothing is found. """ 80 81 if (quiz.assignment_group is None): 82 return None 83 84 url = backend.server + LIST_ASSIGNMENT_GROUPS_ENDPOINT.format(course_id = course_id, page_size = lms.backend.canvas.common.DEFAULT_PAGE_SIZE) 85 headers = backend.get_standard_headers() 86 87 raw_objects = lms.backend.canvas.common.make_get_request_list(url, headers = headers) 88 if (raw_objects is None): 89 identifiers = { 90 'course_id': course_id, 91 'name': quiz.assignment_group, 92 } 93 backend.not_found('assignment group', identifiers) 94 95 return None 96 97 for raw_object in raw_objects: 98 if (raw_object.get('name', None) == quiz.assignment_group): 99 return int(raw_object['id']) 100 101 return None 102 103def _upload_quiz_metadata( 104 backend: typing.Any, 105 course_id: int, 106 quiz: quizcomp.model.quiz.Quiz, 107 assignment_group_id: typing.Union[int, None], 108 ) -> lms.model.assignments.Assignment: 109 """ Upload the base quiz metadata, which we can then attach questions to. """ 110 111 quiz_type = QUIZ_TYPE_ASSIGNMENT 112 if ((quiz.practice is None) or (quiz.practice is True)): 113 quiz_type = lms.backend.canvas.courses.quizzes.common.QUIZ_TYPE_PRACTICE 114 115 description = quiz.description.to_canvas() 116 if (quiz.version is not None): 117 description = f"<p>{description}</p><br /><hr /><p>Version: {quiz.version}</p>" 118 119 raw_hide_results = None 120 if (quiz.hide_results is not None): 121 if (quiz.hide_results is not quizcomp.model.quiz.HideResultsBehavior.NEVER_HIDE): 122 raw_hide_results = quiz.hide_results.value 123 124 raw_scoring_policy = None 125 if (quiz.scoring_policy is not None): 126 raw_scoring_policy = quiz.scoring_policy.value 127 128 data = { 129 'quiz[title]': quiz.get_name(), 130 'quiz[description]': description, 131 'quiz[quiz_type]': quiz_type, 132 'quiz[published]': (quiz.publish is True), 133 'quiz[assignment_group_id]': assignment_group_id, 134 'quiz[time_limit]': quiz.time_limit_mins, 135 'quiz[allowed_attempts]': quiz.allowed_attempts, 136 # Canvas wants a string instead of a bool here (despite documentation). 137 'quiz[show_correct_answers]': str((quiz.show_correct_answers is not False)).lower(), 138 'quiz[hide_results]': raw_hide_results, 139 # Canvas wants a string instead of a bool here (despite documentation). 140 'quiz[shuffle_answers]': str(quiz.get_config(quizcomp.model.config.OPTION_SHUFFLE_ANSWERS)).lower(), 141 'quiz[scoring_policy]': raw_scoring_policy, 142 } 143 144 url = backend.server + UPLOAD_QUIZ_METADATA_ENDPOINT.format(course_id = course_id) 145 headers = backend.get_standard_headers(write = True) 146 147 raw_data = typing.cast(typing.Dict[str, typing.Any], 148 lms.backend.canvas.common.make_post_request( 149 url, headers = headers, data = data, raise_on_404 = True, 150 ) 151 ) 152 153 return lms.model.assignments.Assignment( 154 id = str(raw_data['id']), 155 name = quiz.get_name(), 156 description = quiz.description.to_md(), 157 ) 158 159def _upload_group( 160 backend: typing.Any, 161 course_id: int, 162 quiz_id: int, 163 group: quizcomp.model.group.Group, 164 ) -> None: 165 """ Upload a question group (including all questions) for an existing quiz. """ 166 167 data = { 168 'quiz_groups[][name]': group.get_name(), 169 'quiz_groups[][pick_count]': group.pick_count, 170 'quiz_groups[][question_points]': group.get_child_points(), 171 } 172 173 url = backend.server + UPLOAD_GROUP_ENDPOINT.format(course_id = course_id, quiz_id = quiz_id) 174 headers = backend.get_standard_headers(write = True) 175 176 raw_data = typing.cast(typing.Dict[str, typing.Any], 177 lms.backend.canvas.common.make_post_request( 178 url, headers = headers, data = data, raise_on_404 = True, 179 # Add additional data to the request so testing can easily identify this request. 180 additional_requests_options = {'params': {'name': group.get_name()}}, 181 ) 182 ) 183 184 group_id = raw_data['quiz_groups'][0]['id'] 185 186 for (i, question) in enumerate(group.children): 187 _upload_question(backend, course_id, quiz_id, group_id, question, i) 188 189def _upload_question( 190 backend: typing.Any, 191 course_id: int, 192 quiz_id: int, 193 group_id: int, 194 question: quizcomp.model.question.Question, 195 index: int, 196 ) -> None: 197 """ Create a question within the given quiz/group. """ 198 199 data = _create_question_json(group_id, question, index) 200 201 url = backend.server + UPLOAD_QUESTION_ENDPOINT.format(course_id = course_id, quiz_id = quiz_id) 202 headers = backend.get_standard_headers(write = True) 203 204 lms.backend.canvas.common.make_post_request( 205 url, headers = headers, data = data, raise_on_404 = True, 206 # Add additional data to the request so testing can easily identify this request. 207 additional_requests_options = {'params': { 208 'index': index, 209 'group_id': group_id, 210 'name': question.get_name(), 211 }}, 212 ) 213 214def _create_question_json( 215 group_id: int, 216 question: quizcomp.model.question.Question, 217 index: int, 218 ) -> typing.Dict[str, typing.Any]: 219 """ Create a dict that represent a question for a Canvas API request. """ 220 221 name = question.get_name() 222 223 custom_header = question.get_config(quizcomp.model.config.OPTION_CUSTOM_HEADER) 224 if (custom_header is not None): 225 name = custom_header 226 227 data = { 228 'question[question_type]': QUESTION_TYPE_MAP[question.question_type], 229 'question[question_name]': name, 230 'question[quiz_group_id]': group_id, 231 # The actual points is taken from the group, 232 # but put in a one here so people don't get scared when they see a zero. 233 'question[points_possible]': 1, 234 'question[position]': index, 235 'question[question_text]': question.prompt.to_canvas(), 236 } 237 238 if (question.feedback is not None): 239 if (question.feedback.general is not None): 240 data['question[neutral_comments_html]'] = question.feedback.general.to_canvas() 241 242 if (question.feedback.correct is not None): 243 data['question[correct_comments_html]'] = question.feedback.correct.to_canvas() 244 245 if (question.feedback.incorrect is not None): 246 data['question[incorrect_comments_html]'] = question.feedback.incorrect.to_canvas() 247 248 _serialize_answers(data, question) 249 250 return data 251 252def _serialize_answers( 253 data: typing.Dict[str, typing.Any], 254 question: quizcomp.model.question.Question, 255 ) -> None: 256 """ Convert a question's answers to Canvas JSON and insert it into the give dict. """ 257 258 if (question.question_type is quizcomp.model.constants.QuestionType.ESSAY): 259 # Text-based questions have no answers in canvas. 260 pass 261 elif (question.question_type is quizcomp.model.constants.QuestionType.FIMB): 262 _serialize_fimb_answers(data, typing.cast(quizcomp.model.answer.MultiplePartTextAnswers, question.answers)) 263 elif (question.question_type is quizcomp.model.constants.QuestionType.FITB): 264 answers = quizcomp.model.answer.MultiplePartTextAnswers(parts = {'': typing.cast(quizcomp.model.answer.TextAnswers, question.answers)}) 265 _serialize_fimb_answers(data, answers) 266 elif (question.question_type is quizcomp.model.constants.QuestionType.MATCHING): 267 _serialize_matching_answers(data, typing.cast(quizcomp.model.answer.MatchingAnswers, question.answers)) 268 elif (question.question_type is quizcomp.model.constants.QuestionType.MA): 269 _serialize_choice_answers(data, typing.cast(quizcomp.model.answer.ChoiceAnswers, question.answers), False) 270 elif (question.question_type is quizcomp.model.constants.QuestionType.MCQ): 271 _serialize_choice_answers(data, typing.cast(quizcomp.model.answer.ChoiceAnswers, question.answers), False) 272 elif (question.question_type is quizcomp.model.constants.QuestionType.MDD): 273 _serialize_mdd_answers(data, typing.cast(quizcomp.model.answer.MultiplePartChoiceAnswers, question.answers)) 274 elif (question.question_type is quizcomp.model.constants.QuestionType.NUMERICAL): 275 _serialize_numeric_answers(data, typing.cast(quizcomp.model.answer.NumericAnswers, question.answers)) 276 elif (question.question_type is quizcomp.model.constants.QuestionType.SA): 277 # Text-based questions have no answers in canvas. 278 pass 279 elif (question.question_type is quizcomp.model.constants.QuestionType.TEXT_ONLY): 280 # Text-based questions have no answers in canvas. 281 pass 282 elif (question.question_type is quizcomp.model.constants.QuestionType.TF): 283 _serialize_choice_answers(data, typing.cast(quizcomp.model.answer.ChoiceAnswers, question.answers), True) 284 else: 285 raise ValueError(f"Unknown question type: '{question.question_type.value}'.") 286 287def _serialize_choice_answers( 288 data: typing.Dict[str, typing.Any], 289 answers: quizcomp.model.answer.ChoiceAnswers, 290 use_text: bool, 291 blank_id: typing.Union[str, None] = None, 292 start_index: int = 0, 293 ) -> None: 294 """ Serialize choice-based answers. """ 295 296 for (i, choice) in enumerate(answers.choices): 297 index = start_index + i 298 299 weight = 0 300 if (choice.correct): 301 weight = 100 302 303 data[f"question[answers][{index}][answer_weight]"] = weight 304 305 if (use_text): 306 text = choice.text.to_text(text_allow_special_text = True, text_allow_all_characters = True) 307 data[f"question[answers][{index}][answer_text]"] = text 308 else: 309 html = choice.text.to_canvas() 310 data[f"question[answers][{index}][answer_html]"] = html 311 312 if (blank_id is not None): 313 data[f"question[answers][{index}][blank_id]"] = blank_id 314 315 if ((choice.feedback is not None) and (choice.feedback.general is not None)): 316 data[f"question[answers][{index}][answer_comment_html]"] = choice.feedback.general.to_canvas() 317 318def _serialize_fimb_answers( 319 data: typing.Dict[str, typing.Any], 320 answers: quizcomp.model.answer.MultiplePartTextAnswers, 321 ) -> None: 322 """ Serialize FIMB-like answers. """ 323 324 index = 0 325 326 for (key, answer) in answers.parts.items(): 327 for option in answer.options: 328 data[f"question[answers][{index}][blank_id]"] = key 329 data[f"question[answers][{index}][answer_weight]"] = 100 330 data[f"question[answers][{index}][answer_text]"] = option.text.to_text(text_allow_special_text = True, text_allow_all_characters = True) 331 332 if ((option.feedback is not None) and (option.feedback.general is not None)): 333 data[f"question[answers][{index}][answer_comment_html]"] = option.feedback.general.to_canvas() 334 335 index += 1 336 337def _serialize_matching_answers( 338 data: typing.Dict[str, typing.Any], 339 answers: quizcomp.model.answer.MatchingAnswers, 340 ) -> None: 341 """ Serialize matching answers. """ 342 343 for (i, (left, right)) in enumerate(answers.pairs): 344 data[f"question[answers][{i}][answer_match_left]"] = left.text.to_text(text_allow_special_text = True, text_allow_all_characters = True) 345 data[f"question[answers][{i}][answer_match_right]"] = right.text.to_text(text_allow_special_text = True, text_allow_all_characters = True) 346 347 if ((left.feedback is not None) and (left.feedback.general is not None)): 348 data[f"question[answers][{i}][answer_comment_html]"] = left.feedback.general.to_canvas() 349 350 if (len(answers.distractors) > 0): 351 distractors = [ 352 distractor.text.to_text(text_allow_special_text = True, text_allow_all_characters = True) 353 for distractor 354 in answers.distractors 355 ] 356 data["question[matching_answer_incorrect_matches]"] = "\n".join(distractors) 357 358def _serialize_mdd_answers( 359 data: typing.Dict[str, typing.Any], 360 answers: quizcomp.model.answer.MultiplePartChoiceAnswers, 361 ) -> None: 362 """ Serialize MDD answers. """ 363 364 index = 0 365 366 for (key, choices) in answers.parts.items(): 367 _serialize_choice_answers(data, choices, True, blank_id = key, start_index = index) 368 index += len(choices.choices) 369 370def _serialize_numeric_answers( 371 data: typing.Dict[str, typing.Any], 372 answers: quizcomp.model.answer.NumericAnswers, 373 ) -> None: 374 """ Serialize numeric answers. """ 375 376 # Note that the keys/constants for numerical answers are different than what the documentation says: 377 # https://canvas.instructure.com/doc/api/quiz_questions.html#QuizQuestion 378 379 for (i, option) in enumerate(answers.options): 380 data[f"question[answers][{i}][answer_weight]"] = 100 381 data[f"question[answers][{i}][numerical_answer_type]"] = option.type.value + '_answer' 382 383 if (option.type is quizcomp.model.answer.NumericAnswerType.EXACT): 384 data[f"question[answers][{i}][answer_exact]"] = option.value 385 data[f"question[answers][{i}][answer_error_margin]"] = option.margin 386 elif (option.type is quizcomp.model.answer.NumericAnswerType.RANGE): 387 data[f"question[answers][{i}][answer_range_start]"] = option.min 388 data[f"question[answers][{i}][answer_range_end]"] = option.max 389 elif (option.type is quizcomp.model.answer.NumericAnswerType.PRECISION): 390 data[f"question[answers][{i}][answer_approximate]"] = option.value 391 data[f"question[answers][{i}][answer_precision]"] = option.precision 392 else: 393 raise ValueError(f"Unknown numerical option type: '{option.type.value}'.") 394 395 if ((option.feedback is not None) and (option.feedback.general is not None)): 396 data[f"question[answers][{i}][answer_comment_html]"] = option.feedback.general.to_canvas() 397 398def _upload_quiz_images( 399 backend: typing.Any, 400 course_id: int, 401 quiz: quizcomp.model.quiz.Quiz, 402 ) -> None: 403 """ Upload quiz images. """ 404 405 parent_dir_id = None 406 407 for document in quiz.collect_all_documents(): 408 for image_token in document.collect_images(): 409 source = image_token.attrGet('src') 410 if (source is None): 411 continue 412 413 # Skip remote images. 414 if (re.match(r'^http(s)?://', source)): 415 continue 416 417 path = source 418 if (not os.path.isabs(path)): 419 path = os.path.join(document.context.base_dir, path) 420 421 path = os.path.abspath(path) 422 if (not os.path.isfile(path)): 423 raise ValueError(f"Found an image within the quiz that does not exist on disk: '{path}' ('{source}').") 424 425 # Get a hash of the original source to avoid name conflicts. 426 source_hash = edq.util.hash.sha256_hex(source) 427 428 (basename, ext) = os.path.splitext(os.path.basename(path)) 429 canvas_filename = f"{basename}_{source_hash}{ext}" 430 431 # For a specific path for this object within the canvas course. 432 canvas_path = '/'.join([ 433 CANVAS_QUIZCOMP_BASEDIR, 434 CANVAS_QUIZCOMP_QUIZ_DIRNAME, 435 quiz.name, 436 canvas_filename, 437 ]) 438 439 # Ensure that a parent directory exists for these quiz resources. 440 if (parent_dir_id is None): 441 parent_dir_id = _ensure_folder(backend, course_id, os.path.dirname(canvas_path)) 442 443 file_id = _upload_file(backend, course_id, path, parent_dir_id, canvas_path) 444 445 new_source = f"/courses/{course_id}/files/{file_id}/preview" 446 447 image_token.attrSet('original_src', source) 448 image_token.attrSet('src', new_source) 449 450def _restore_image_sources(quiz: quizcomp.model.quiz.Quiz) -> None: 451 """ Replace any modified image sources with their original source. """ 452 453 for document in quiz.collect_all_documents(): 454 for image_token in document.collect_images(): 455 original_source = image_token.attrGet('original_src') 456 if (original_source is None): 457 continue 458 459 image_token.attrSet('src', str(original_source)) 460 image_token.attrs.pop('original_src', None) 461 462def _ensure_folder( 463 backend: typing.Any, 464 course_id: int, 465 canvas_path: str, 466 ) -> int: 467 """ Ensure that a Canvas folder exists and fetch its ID. """ 468 469 folder_id = _get_folder(backend, course_id, canvas_path) 470 if (folder_id is not None): 471 return folder_id 472 473 folder_id = _create_folder(backend, course_id, canvas_path) 474 475 # Canvas will not hide created parents. 476 _hide_folder(backend, course_id, CANVAS_QUIZCOMP_BASEDIR) 477 478 return folder_id 479 480def _get_folder( 481 backend: typing.Any, 482 course_id: int, 483 canvas_path: str, 484 ) -> typing.Union[int, None]: 485 """ Get a Canvas folder ID (if it exists). """ 486 487 url = backend.server + GET_FOLDER_ENDPOINT.format(course_id = course_id, canvas_path = canvas_path) 488 headers = backend.get_standard_headers() 489 490 raw_object = lms.backend.canvas.common.make_get_request(url, headers = headers) 491 if ((raw_object is None) or (len(raw_object) == 0)): 492 return None 493 494 return int(raw_object[-1]['id']) 495 496def _create_folder( 497 backend: typing.Any, 498 course_id: int, 499 canvas_path: str, 500 ) -> int: 501 """ Create a folder in Canvas. """ 502 503 name = os.path.basename(canvas_path) 504 parent_path = os.path.dirname(canvas_path) 505 506 data = { 507 'name': name, 508 'parent_folder_path': parent_path, 509 # Canvas wants a string here despite the documentation saying it is a bool. 510 'hidden': 'true', 511 } 512 513 url = backend.server + CREATE_FOLDER_ENDPOINT.format(course_id = course_id) 514 headers = backend.get_standard_headers(write = True) 515 516 raw_object = typing.cast(typing.Dict[str, typing.Any], 517 lms.backend.canvas.common.make_post_request( 518 url, headers = headers, data = data, raise_on_404 = True, 519 # Add additional data to the request so testing can easily identify this request. 520 additional_requests_options = {'params': data} 521 ) 522 ) 523 return int(raw_object['id']) 524 525def _hide_folder( 526 backend: typing.Any, 527 course_id: int, 528 canvas_path: str, 529 ) -> None: 530 """ Ensure that a Canvas folder (specified by path) is hidden. """ 531 532 folder_id = _get_folder(backend, course_id, canvas_path) 533 if (folder_id is None): 534 raise ValueError(f"Could not find Canvas folder to hide: '{canvas_path}'.") 535 536 data = { 537 # Canvas wants a string here despite the documentation saying it is a bool. 538 'hidden': 'true', 539 } 540 541 url = backend.server + HIDE_FOLDER_ENDPOINT.format(folder_id = folder_id) 542 headers = backend.get_standard_headers(write = True) 543 544 lms.backend.canvas.common.make_put_request( 545 url, headers = headers, data = data, raise_on_404 = True, 546 # Add additional data to the request so testing can easily identify this request. 547 additional_requests_options = {'params': data}, 548 ) 549 550def _upload_file( 551 backend: typing.Any, 552 course_id: int, 553 path: str, 554 parent_dir_id: int, 555 canvas_path: str, 556 ) -> int: 557 """ Upload a file to the specified Canvas path. """ 558 559 upload_url, upload_params = _init_file_upload(backend, course_id, path, parent_dir_id, canvas_path) 560 561 # The upload URL may have a slug in it (if read from test data). 562 upload_url = upload_url.replace(lms.model.constants.SERVER_SLUG, backend.server) 563 564 return _upload_file_contents(backend, path, upload_url, upload_params) 565 566def _init_file_upload( 567 backend: typing.Any, 568 course_id: int, 569 path: str, 570 parent_dir_id: int, 571 canvas_path: str, 572 ) -> typing.Tuple[str, typing.Dict[str, typing.Any]]: 573 """ 574 Prepare to upload a file to Canvas. 575 Return the Canvas-returned upload URL and upload params. 576 """ 577 578 data = { 579 'name': os.path.basename(canvas_path), 580 'size': os.stat(path).st_size, 581 'parent_folder_id': parent_dir_id, 582 'on_duplicate': 'overwrite', 583 } 584 585 url = backend.server + UPLOAD_FILE_ENDPOINT.format(course_id = course_id) 586 headers = backend.get_standard_headers(write = True) 587 588 raw_object = typing.cast(typing.Dict[str, typing.Any], 589 lms.backend.canvas.common.make_post_request( 590 url, headers = headers, data = data, raise_on_404 = True, 591 # Add additional data to the request so testing can easily identify this request. 592 additional_requests_options = {'params': data}, 593 ) 594 ) 595 596 return (raw_object['upload_url'], raw_object['upload_params']) 597 598def _upload_file_contents( 599 backend: typing.Any, 600 path: str, 601 upload_url: str, 602 upload_params: typing.Dict[str, typing.Any], 603 ) -> int: 604 """ Upload the actual file contents to Canvas. """ 605 606 files = { 607 'file': open(path, 'rb'), # pylint: disable=consider-using-with 608 } 609 610 headers = backend.get_standard_headers(write = True) 611 612 filename = upload_params.get('filename', upload_params.get('Filename', None)) 613 614 raw_object = typing.cast(typing.Dict[str, typing.Any], 615 lms.backend.canvas.common.make_post_request( 616 upload_url, headers = headers, data = upload_params, files = files, raise_on_404 = True, 617 # Add additional data to the request so testing can easily identify this request. 618 additional_requests_options = {'params': {'filename': filename}}, 619 ) 620 ) 621 622 return int(raw_object['id'])
CREATE_FOLDER_ENDPOINT: str =
'/api/v1/courses/{course_id}/folders'
GET_FOLDER_ENDPOINT: str =
'/api/v1/courses/{course_id}/folders/by_path{canvas_path}'
HIDE_FOLDER_ENDPOINT: str =
'/api/v1/folders/{folder_id}'
LIST_ASSIGNMENT_GROUPS_ENDPOINT: str =
'/api/v1/courses/{course_id}/assignment_groups?per_page={page_size}'
UPLOAD_FILE_ENDPOINT: str =
'/api/v1/courses/{course_id}/files'
UPLOAD_GROUP_ENDPOINT: str =
'/api/v1/courses/{course_id}/quizzes/{quiz_id}/groups'
UPLOAD_QUESTION_ENDPOINT: str =
'/api/v1/courses/{course_id}/quizzes/{quiz_id}/questions'
UPLOAD_QUIZ_METADATA_ENDPOINT: str =
'/api/v1/courses/{course_id}/quizzes'
CANVAS_QUIZCOMP_BASEDIR: str =
'/quiz-composer'
CANVAS_QUIZCOMP_QUIZ_DIRNAME: str =
'quizzes'
QUIZ_TYPE_ASSIGNMENT: str =
'assignment'
QUESTION_TYPE_MAP: Dict[quizcomp.model.constants.QuestionType, str] =
{<QuestionType.ESSAY: 'essay'>: 'essay_question', <QuestionType.FIMB: 'fill_in_multiple_blanks'>: 'fill_in_multiple_blanks_question', <QuestionType.MATCHING: 'matching'>: 'matching_question', <QuestionType.MA: 'multiple_answers'>: 'multiple_answers_question', <QuestionType.MCQ: 'multiple_choice'>: 'multiple_choice_question', <QuestionType.MDD: 'multiple_dropdowns'>: 'multiple_dropdowns_question', <QuestionType.NUMERICAL: 'numerical'>: 'numerical_question', <QuestionType.TEXT_ONLY: 'text_only'>: 'text_only_question', <QuestionType.TF: 'true_false'>: 'true_false_question', <QuestionType.FITB: 'fill_in_the_blank'>: 'short_answer_question', <QuestionType.SA: 'short_answer'>: 'essay_question'}
def
request( backend: Any, course_id: int, quiz: quizcomp.model.quiz.Quiz) -> lms.model.assignments.Assignment:
48def request( 49 backend: typing.Any, 50 course_id: int, 51 quiz: quizcomp.model.quiz.Quiz, 52 ) -> lms.model.assignments.Assignment: 53 """ 54 Upload a quiz. 55 56 This is a process that takes many steps. 57 1) Upload Quiz Files 58 2) Upload Quiz Metadata 59 3) Upload Quiz Question Groups (first create question groups and then upload questions). 60 """ 61 62 _upload_quiz_images(backend, course_id, quiz) 63 64 assignment_group_id = _fetch_assignment_group(backend, course_id, quiz) 65 66 quiz_metadata = _upload_quiz_metadata(backend, course_id, quiz, assignment_group_id) 67 68 for group in quiz.get_groups(): 69 _upload_group(backend, course_id, int(quiz_metadata.id), group) 70 71 _restore_image_sources(quiz) 72 73 return quiz_metadata
Upload a quiz.
This is a process that takes many steps. 1) Upload Quiz Files 2) Upload Quiz Metadata 3) Upload Quiz Question Groups (first create question groups and then upload questions).