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).