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.