lms.backend.canvas.model

  1import logging
  2import os
  3import re
  4import typing
  5
  6import bs4
  7import edq.net.request
  8import edq.util.dirent
  9import html2text
 10import quizcomp.question.base
 11
 12import lms.backend.canvas.common
 13import lms.model.assignments
 14import lms.model.backend
 15import lms.model.courses
 16import lms.model.groups
 17import lms.model.groupsets
 18import lms.model.quizzes
 19import lms.model.scores
 20import lms.model.users
 21import lms.util.parse
 22
 23_logger = logging.getLogger(__name__)
 24
 25ENROLLMENT_TYPE_TO_ROLE: typing.Dict[str, lms.model.users.CourseRole] = {
 26    'ObserverEnrollment': lms.model.users.CourseRole.OTHER,
 27    'StudentEnrollment': lms.model.users.CourseRole.STUDENT,
 28    'TaEnrollment': lms.model.users.CourseRole.GRADER,
 29    'DesignerEnrollment': lms.model.users.CourseRole.ADMIN,
 30    'TeacherEnrollment': lms.model.users.CourseRole.OWNER,
 31}
 32"""
 33Canvas enrollment types mapped to roles.
 34This map is ordered by priority/power.
 35The later in the dict, the more power.
 36"""
 37
 38QUESTION_TYPE_MAPPING: typing.Dict[typing.Union[str, None], quizcomp.question.base.QuestionType] = {
 39    'essay_question': quizcomp.question.base.QuestionType.ESSAY,
 40    'fill_in_multiple_blanks_question': quizcomp.question.base.QuestionType.FIMB,
 41    'matching_question': quizcomp.question.base.QuestionType.MATCHING,
 42    'multiple_answers_question': quizcomp.question.base.QuestionType.MA,
 43    'multiple_choice_question': quizcomp.question.base.QuestionType.MCQ,
 44    'multiple_dropdowns_question': quizcomp.question.base.QuestionType.MDD,
 45    'numerical_question': quizcomp.question.base.QuestionType.NUMERICAL,
 46    'short_answer_question': quizcomp.question.base.QuestionType.FITB,
 47    'text_only_question': quizcomp.question.base.QuestionType.TEXT_ONLY,
 48    'true_false_question': quizcomp.question.base.QuestionType.TF,
 49}
 50
 51_testing_override: bool = False  # pylint: disable=invalid-name
 52""" A special override to signal testing. """
 53
 54class _ParsedText:
 55    """ The result of parsing Canvas HTML into markdown. """
 56
 57    def __init__(self,
 58            html: str,
 59            text: str,
 60            resources: typing.List[str],
 61            ) -> None:
 62        self.html: str = html
 63        """ The original HTML. """
 64
 65        self.text: str = text
 66        """ The parsed text. """
 67
 68        self.resources: typing.List[str] = resources
 69        """ The path to any resources parsed from this text. """
 70
 71def assignment(data: typing.Dict[str, typing.Any]) -> lms.model.assignments.Assignment:
 72    """
 73    Create a Canvas assignment associated with a course.
 74
 75    See: https://developerdocs.instructure.com/services/canvas/resources/assignments
 76    """
 77
 78    _parse_assignment_data(data, 'assignment')
 79    return lms.model.assignments.Assignment(**data)
 80
 81def assignment_score(data: typing.Dict[str, typing.Any]) -> lms.model.scores.AssignmentScore:
 82    """
 83    Create a Canvas assignment score.
 84
 85    See: https://developerdocs.instructure.com/services/canvas/resources/scores
 86    """
 87
 88    # Check for important fields.
 89    for field in ['id', 'assignment_id', 'user_id']:
 90        if (field not in data):
 91            raise ValueError(f"Canvas assignment score is missing '{field}' field.")
 92
 93    # Modify specific arguments before creation.
 94    data['id'] = lms.util.parse.required_string(data.get('id', None), 'id')
 95    data['score'] = lms.util.parse.optional_float(data.get('score', None), 'score')
 96    data['points_possible'] = lms.util.parse.optional_float(data.get('points_possible', None), 'points_possible')
 97    data['submission_date'] = lms.backend.canvas.common.parse_timestamp(data.get('submitted_at', None))
 98    data['graded_date'] = lms.backend.canvas.common.parse_timestamp(data.get('graded_at', None))
 99
100    assignment_id = lms.util.parse.required_string(data.get('assignment_id', None), 'assignment_id')
101    data['assignment'] = lms.model.assignments.AssignmentQuery(id = assignment_id)
102
103    user_id = lms.util.parse.required_string(data.get('user_id', None), 'user_id')
104    data['user'] = lms.model.users.UserQuery(id = user_id)
105
106    return lms.model.scores.AssignmentScore(**data)
107
108def course(data: typing.Dict[str, typing.Any]) -> lms.model.courses.Course:
109    """
110    Create a Canvas course.
111
112    See: https://developerdocs.instructure.com/services/canvas/resources/courses
113    """
114
115    # Check for important fields.
116    for field in ['id']:
117        if (field not in data):
118            raise ValueError(f"Canvas course is missing '{field}' field.")
119
120    # Modify specific arguments before creation.
121    data['id'] = lms.util.parse.required_string(data.get('id', None), 'id')
122
123    return lms.model.courses.Course(**data)
124
125def course_user(backend: lms.model.backend.APIBackend, data: typing.Dict[str, typing.Any]) -> lms.model.users.CourseUser:
126    """
127    Create a Canvas user associated with a course.
128
129    See: https://developerdocs.instructure.com/services/canvas/resources/users
130    """
131
132    # Check for important fields.
133    for field in ['id']:
134        if (field not in data):
135            raise ValueError(f"Canvas user is missing '{field}' field.")
136
137    # Modify specific arguments before sending them to super.
138    data['id'] = lms.util.parse.required_string(data.get('id', None), 'id')
139
140    # Canvas sometimes has email under different fields.
141    if ((data.get('email', None) is None) or (len(data.get('email', '')) == 0)):
142        data['email'] = data.get('login_id', None)
143
144    enrollments = data.get('enrollments', None)
145    if (enrollments is not None):
146        data['raw_role'] = _parse_role_from_enrollments(enrollments)
147        data['role'] = ENROLLMENT_TYPE_TO_ROLE.get(data['raw_role'], None)
148
149        # Canvas has a discontinuity with its default course roles.
150        # We need to patch this during testing.
151        if ((backend.is_testing() or _testing_override) and data['email'] == 'course-admin@test.edulinq.org'):
152            data['role'] = lms.model.users.CourseRole.ADMIN
153
154    return lms.model.users.CourseUser(**data)
155
156def group(data: typing.Dict[str, typing.Any]) -> lms.model.groups.Group:
157    """
158    Create a Canvas group associated with a course.
159
160    See: https://developerdocs.instructure.com/services/canvas/resources/groups
161    """
162
163    # Check for important fields.
164    for field in ['id']:
165        if (field not in data):
166            raise ValueError(f"Canvas group is missing '{field}' field.")
167
168    # Modify specific arguments before creation.
169    data['id'] = lms.util.parse.required_string(data.get('id', None), 'id')
170
171    return lms.model.groups.Group(**data)
172
173def group_set(data: typing.Dict[str, typing.Any]) -> lms.model.groupsets.GroupSet:
174    """
175    Create a Canvas group set associated with a course.
176
177    See: https://developerdocs.instructure.com/services/canvas/resources/group_categories
178    """
179
180    # Check for important fields.
181    for field in ['id']:
182        if (field not in data):
183            raise ValueError(f"Canvas group set is missing '{field}' field.")
184
185    # Modify specific arguments before creation.
186    data['id'] = lms.util.parse.required_string(data.get('id', None), 'id')
187
188    return lms.model.groupsets.GroupSet(**data)
189
190def quiz(
191        backend: lms.model.backend.APIBackend,
192        data: typing.Dict[str, typing.Any],
193        fetch_resources: bool = False,
194        ) -> lms.model.quizzes.Quiz:
195    """
196    Create a Canvas quiz associated with a course.
197
198    See: https://developerdocs.instructure.com/services/canvas/resources/quizzes
199    """
200
201    _parse_assignment_data(data, 'quiz')
202
203    parsed_text = _canvas_html_to_markdown(backend, data.get('description', None), fetch_resources)
204    data['description'] = parsed_text.text
205    data['resources'] = parsed_text.resources
206
207    return lms.model.quizzes.Quiz(**data)
208
209def quiz_question(
210        backend: lms.model.backend.APIBackend,
211        data: typing.Dict[str, typing.Any],
212        fetch_resources: bool = False,
213        ) -> lms.model.quizzes.Question:
214    """
215    Create a Canvas quiz question.
216
217    See: https://developerdocs.instructure.com/services/canvas/resources/quiz_questions
218    """
219
220    # Check for important fields.
221    for field in ['id']:
222        if (field not in data):
223            raise ValueError(f"Canvas quiz question is missing '{field}' field.")
224
225    raw_question_type = data.get('question_type', None)
226    if (raw_question_type is None):
227        raise ValueError('No question type provided.')
228
229    question_type = QUESTION_TYPE_MAPPING.get(raw_question_type, None)
230    if (question_type is None):
231        raise ValueError(f"Unknown Canvas question type: '{raw_question_type}'.")
232
233    data['question_type'] = question_type
234    data['id'] = lms.util.parse.required_string(data.get('id', None), 'id')
235    data['name'] = lms.util.parse.optional_string(data.get('question_name', None))
236    data['points'] = lms.util.parse.optional_float(data.get('points_possible', None), 'points')
237    data['raw_answers'] = data.get('answers', None)
238    data['group_id'] = lms.util.parse.optional_string(data.get('quiz_group_id', None))
239
240    all_resource_paths = []
241
242    parsed_text = _canvas_html_to_markdown(backend, data.get('question_text', None), fetch_resources)
243    data['prompt'] = parsed_text.text
244    all_resource_paths += parsed_text.resources
245
246    (answers, resources) = _parse_quiz_question_answers(
247        backend,
248        data.get('answers', None),
249        data.get('matching_answer_incorrect_matches', None),
250        question_type,
251        fetch_resources,
252    )
253    data['answers'] = answers
254    all_resource_paths += resources
255
256    data['resources'] = all_resource_paths
257
258    return lms.model.quizzes.Question(**data)
259
260def quiz_question_group(data: typing.Dict[str, typing.Any]) -> lms.model.quizzes.QuestionGroup:
261    """
262    Create a Canvas quiz question group.
263
264    See: https://developerdocs.instructure.com/services/canvas/resources/quiz_question_groups
265    """
266
267    # Check for important fields.
268    for field in ['id']:
269        if (field not in data):
270            raise ValueError(f"Canvas quiz question group is missing '{field}' field.")
271
272    data['id'] = lms.util.parse.required_string(data.get('id', None), 'id')
273    data['name'] = lms.util.parse.optional_string(data.get('name', None))
274    data['points'] = lms.util.parse.optional_float(data.get('question_points', None), 'points')
275    data['pick_count'] = lms.util.parse.required_int(data.get('pick_count', None), 'pick_count')
276
277    return lms.model.quizzes.QuestionGroup(**data)
278
279def _canvas_html_to_markdown(
280        backend: lms.model.backend.APIBackend,
281        html: typing.Union[str, None],
282        fetch_resources: bool = False,
283        ) -> _ParsedText:
284    """
285    Parse the text from a Canvas quiz question into markdown.
286    We intend for the resulting markdown to have a little HTML as possible.
287    This is an impossible task, but we want to do our best.
288    """
289
290    if (html is None):
291        return _ParsedText('', '', [])
292
293    resources: typing.List[str] = []
294    if (fetch_resources):
295        (html, resources) = _fetch_html_resources(backend, html)
296
297    converter = html2text.HTML2Text()
298
299    converter.body_width = 0
300    converter.mark_code = True
301
302    text = converter.handle(html)
303    text = text.strip()
304
305    # Replace code tags with fences.
306    text = re.sub(r'\[/?code\]', '```', text)
307
308    # Replace placeholders (e.g., for fill in the blank questions).
309    text = re.sub(r'\[(\w+?)\]', r'<placeholder>\1</placeholder>', text)
310
311    return _ParsedText(html, text, resources)
312
313def _fetch_html_resources(backend: lms.model.backend.APIBackend, html: str) -> typing.Tuple[str, typing.List[str]]:
314    """ Fetch any resources embedded in the HTML and re-write the links for these resources. """
315
316    document = bs4.BeautifulSoup(html, 'html.parser')
317
318    resources = []
319
320    # Look for Canvas-embeded images.
321    for image in document.select('img[data-api-endpoint]'):
322        path = _fetch_file(backend, str(image.get('data-api-endpoint')))
323        if (path is None):
324            continue
325
326        image['src'] = os.path.basename(path)
327
328        resources.append(path)
329
330    return str(document), resources
331
332def _fetch_file(backend: lms.model.backend.APIBackend, info_link: str) -> typing.Union[str, None]:
333    """ Fetch a file from Canvas, write it to disk, and return the path. """
334
335    headers = backend.get_standard_headers()
336    file_info = lms.backend.canvas.common.make_get_request(info_link, headers = headers)
337
338    if (file_info is None):
339        return None
340
341    response, _ = edq.net.request.make_get(file_info['url'], headers = headers)
342
343    temp_dir = edq.util.dirent.get_temp_dir('edq-lms-canvas-')
344    path = os.path.join(temp_dir, file_info['filename'])
345
346    edq.util.dirent.write_file_bytes(path, response.content)
347
348    return path
349
350def _parse_quiz_question_answers(
351        backend: lms.model.backend.APIBackend,
352        raw_answers: typing.Union[typing.List[typing.Any], None],
353        raw_distractors: typing.Union[str, None],
354        question_type: quizcomp.question.base.QuestionType,
355        fetch_resources: bool = False,
356        ) -> typing.Tuple[typing.Union[typing.List[typing.Any], typing.Dict[str, typing.Any]], typing.List[str]]:
357    """ Parse question answers from Canvas responses. """
358
359    if (raw_answers is None):
360        return [], []
361
362    answers: typing.Union[typing.List[typing.Any], typing.Dict[str, typing.Any]] = []
363    resources: typing.List[str] = []
364
365    # Parse answers based on question type.
366    if (question_type in {quizcomp.question.base.QuestionType.ESSAY, quizcomp.question.base.QuestionType.TEXT_ONLY}):
367        pass
368    elif (question_type == quizcomp.question.base.QuestionType.FIMB):
369        answers = {}
370        for raw_answer in raw_answers:
371            key = raw_answer.get('blank_id', '')
372            if (key not in answers):
373                answers[key] = []
374
375            parsed_text = _parse_quiz_question_text(backend, raw_answer, fetch_resources)
376
377            answers[key].append(parsed_text.text)
378            resources += parsed_text.resources
379    elif (question_type == quizcomp.question.base.QuestionType.FITB):
380        answers = []
381        for raw_answer in raw_answers:
382            parsed_text = _parse_quiz_question_text(backend, raw_answer, fetch_resources)
383
384            answers.append(parsed_text.text)
385            resources += parsed_text.resources
386    elif (question_type in {quizcomp.question.base.QuestionType.MA, quizcomp.question.base.QuestionType.MCQ}):
387        (answers, choice_resources) = _parse_quiz_question_choices(backend, raw_answers, fetch_resources)
388        resources += choice_resources
389    elif (question_type == quizcomp.question.base.QuestionType.MDD):
390        # Divide up sections by blank ID (find all the possibilities for each blank).
391        # {key: [raw_answer, ...]}
392        raw_sections: typing.Dict[str, typing.List[typing.Dict[str, typing.Any]]] = {}
393        for raw_answer in raw_answers:
394            key = raw_answer.get('blank_id', '')
395            if (key not in raw_sections):
396                raw_sections[key] = []
397
398            raw_sections[key].append(raw_answer)
399
400        # Parse the choices for each section/blank.
401        answers = {}
402        for (section_key, section_raw_answers) in raw_sections.items():
403            (choices, choice_resources) = _parse_quiz_question_choices(backend, section_raw_answers, fetch_resources)
404
405            answers[section_key] = {
406                'text': section_key,
407                'values': choices
408            }
409            resources += choice_resources
410    elif (question_type == quizcomp.question.base.QuestionType.MATCHING):
411        if (raw_distractors is None):
412            raw_distractors = ''
413
414        matches = []
415        distractors = [value.strip() for value in raw_distractors.split("\n")]
416
417        for raw_answer in raw_answers:
418            matches.append([raw_answer.get('left', ''), raw_answer.get('right', '')])
419
420        answers = {
421            'matches': matches,
422            'distractors': distractors,
423        }
424    elif (question_type == quizcomp.question.base.QuestionType.NUMERICAL):
425        answers = []
426        for raw_answer in raw_answers:
427            raw_type = raw_answer.get('numerical_answer_type', None)
428
429            answer_type = str(raw_type).removesuffix('_answer')
430            answer = {'type': answer_type}
431
432            if (answer_type == quizcomp.constants.NUMERICAL_ANSWER_TYPE_EXACT):
433                answer['value'] = raw_answer.get('exact', None)
434                answer['margin'] = raw_answer.get('error_margin', 0)
435            elif (answer_type == quizcomp.constants.NUMERICAL_ANSWER_TYPE_RANGE):
436                answer['min'] = raw_answer.get('range_start', None)
437                answer['max'] = raw_answer.get('range_end', None)
438            elif (answer_type == quizcomp.constants.NUMERICAL_ANSWER_TYPE_PRECISION):
439                answer['value'] = raw_answer.get('approximate', None)
440                answer['precision'] = raw_answer.get('precision', None)
441            else:
442                raise ValueError(f"Unknown numerical answer type: '{raw_type}'.")
443
444            answers.append(answer)
445    elif (question_type == quizcomp.question.base.QuestionType.TF):
446        if (len(raw_answers) != 2):
447            raise ValueError(f"Unexpected length for T/F answers. Expected 2, found {len(raw_answers)}.")
448
449        answers = [
450            {"correct": (raw_answers[0]['weight'] > 0), "text": "True"},
451            {"correct": (raw_answers[1]['weight'] > 0), "text": "False"},
452        ]
453    else:
454        _logger.warning("Cannot form question answers, unknown question type: '%s'.", question_type)
455
456    return answers, resources
457
458def _parse_quiz_question_choices(
459        backend: lms.model.backend.APIBackend,
460        choices: list[typing.Dict[str, typing.Any]],
461        fetch_resources: bool = False,
462        ) -> typing.Tuple[typing.List[typing.Dict[str, typing.Any]], typing.List[str]]:
463    """
464    Parse the quiz question choices.
465    This works for multiple types, like MCQ and MA.
466    """
467
468    results = []
469    resources = []
470
471    for choice in choices:
472        parsed_text = _parse_quiz_question_text(backend, choice, fetch_resources)
473
474        results.append({"correct": (choice['weight'] > 0), "text": parsed_text.text})
475        resources += parsed_text.resources
476
477    return results, resources
478
479def _parse_quiz_question_text(
480        backend: lms.model.backend.APIBackend,
481        choice: typing.Dict[str, typing.Any],
482        fetch_resources: bool = False,
483        ) -> _ParsedText:
484    """ Parse text out of a Canvas question choice field. """
485
486    text = choice.get('text', '').strip()
487    if (text is None):
488        text = ''
489
490    parsed_text = _ParsedText('', text, [])
491    if (len(text) == 0):
492        parsed_text = _canvas_html_to_markdown(backend, choice.get('html', None), fetch_resources)
493
494    return parsed_text
495
496def _parse_assignment_data(data: typing.Dict[str, typing.Any], label: str) -> None:
497    """
498    Parse core assignment data.
499    """
500
501    for field in ['id']:
502        if (field not in data):
503            raise ValueError(f"Canvas {label} is missing '{field}' field.")
504
505    # Modify specific arguments before creation.
506    data['id'] = lms.util.parse.required_string(data.get('id', None), 'id')
507    data['due_date'] = lms.backend.canvas.common.parse_timestamp(data.get('due_at', None))
508    data['open_date'] = lms.backend.canvas.common.parse_timestamp(data.get('unlock_at', None))
509    data['close_date'] = lms.backend.canvas.common.parse_timestamp(data.get('lock_at', None))
510
511    # If there is no name, look for a title.
512    if (data.get('name', None) is None):
513        data['name'] = lms.util.parse.optional_string(data.get('title', None))
514
515def _parse_role_from_enrollments(enrollments: typing.Any) -> typing.Union[str, None]:
516    """
517    Try to parse the user's role from their enrollments.
518    If multiple roles are discovered, take the "highest" one.
519
520    See: https://developerdocs.instructure.com/services/canvas/resources/enrollments
521    """
522
523    if (not isinstance(enrollments, list)):
524        return None
525
526    best_role = None
527    best_index = -1
528
529    enrollment_types = list(ENROLLMENT_TYPE_TO_ROLE.keys())
530
531    for enrollment in enrollments:
532        if (not isinstance(enrollment, dict)):
533            continue
534
535        if (enrollment.get('enrollment_state', None) != 'active'):
536            continue
537
538        role = enrollment.get('role', None)
539
540        role_index = -1
541        if (role in enrollment_types):
542            role_index = enrollment_types.index(role)
543
544        if ((best_role is None) or (role_index > best_index)):
545            best_role = role
546            best_index = role_index
547
548    return best_role
ENROLLMENT_TYPE_TO_ROLE: Dict[str, lms.model.users.CourseRole] = {'ObserverEnrollment': <CourseRole.OTHER: 'other'>, 'StudentEnrollment': <CourseRole.STUDENT: 'student'>, 'TaEnrollment': <CourseRole.GRADER: 'grader'>, 'DesignerEnrollment': <CourseRole.ADMIN: 'admin'>, 'TeacherEnrollment': <CourseRole.OWNER: 'owner'>}

Canvas enrollment types mapped to roles. This map is ordered by priority/power. The later in the dict, the more power.

QUESTION_TYPE_MAPPING: Dict[Optional[str], quizcomp.question.base.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'>}
def assignment(data: Dict[str, Any]) -> lms.model.assignments.Assignment:
72def assignment(data: typing.Dict[str, typing.Any]) -> lms.model.assignments.Assignment:
73    """
74    Create a Canvas assignment associated with a course.
75
76    See: https://developerdocs.instructure.com/services/canvas/resources/assignments
77    """
78
79    _parse_assignment_data(data, 'assignment')
80    return lms.model.assignments.Assignment(**data)

Create a Canvas assignment associated with a course.

See: https://developerdocs.instructure.com/services/canvas/resources/assignments

def assignment_score(data: Dict[str, Any]) -> lms.model.scores.AssignmentScore:
 82def assignment_score(data: typing.Dict[str, typing.Any]) -> lms.model.scores.AssignmentScore:
 83    """
 84    Create a Canvas assignment score.
 85
 86    See: https://developerdocs.instructure.com/services/canvas/resources/scores
 87    """
 88
 89    # Check for important fields.
 90    for field in ['id', 'assignment_id', 'user_id']:
 91        if (field not in data):
 92            raise ValueError(f"Canvas assignment score is missing '{field}' field.")
 93
 94    # Modify specific arguments before creation.
 95    data['id'] = lms.util.parse.required_string(data.get('id', None), 'id')
 96    data['score'] = lms.util.parse.optional_float(data.get('score', None), 'score')
 97    data['points_possible'] = lms.util.parse.optional_float(data.get('points_possible', None), 'points_possible')
 98    data['submission_date'] = lms.backend.canvas.common.parse_timestamp(data.get('submitted_at', None))
 99    data['graded_date'] = lms.backend.canvas.common.parse_timestamp(data.get('graded_at', None))
100
101    assignment_id = lms.util.parse.required_string(data.get('assignment_id', None), 'assignment_id')
102    data['assignment'] = lms.model.assignments.AssignmentQuery(id = assignment_id)
103
104    user_id = lms.util.parse.required_string(data.get('user_id', None), 'user_id')
105    data['user'] = lms.model.users.UserQuery(id = user_id)
106
107    return lms.model.scores.AssignmentScore(**data)
def course(data: Dict[str, Any]) -> lms.model.courses.Course:
109def course(data: typing.Dict[str, typing.Any]) -> lms.model.courses.Course:
110    """
111    Create a Canvas course.
112
113    See: https://developerdocs.instructure.com/services/canvas/resources/courses
114    """
115
116    # Check for important fields.
117    for field in ['id']:
118        if (field not in data):
119            raise ValueError(f"Canvas course is missing '{field}' field.")
120
121    # Modify specific arguments before creation.
122    data['id'] = lms.util.parse.required_string(data.get('id', None), 'id')
123
124    return lms.model.courses.Course(**data)
def course_user( backend: lms.model.backend.APIBackend, data: Dict[str, Any]) -> lms.model.users.CourseUser:
126def course_user(backend: lms.model.backend.APIBackend, data: typing.Dict[str, typing.Any]) -> lms.model.users.CourseUser:
127    """
128    Create a Canvas user associated with a course.
129
130    See: https://developerdocs.instructure.com/services/canvas/resources/users
131    """
132
133    # Check for important fields.
134    for field in ['id']:
135        if (field not in data):
136            raise ValueError(f"Canvas user is missing '{field}' field.")
137
138    # Modify specific arguments before sending them to super.
139    data['id'] = lms.util.parse.required_string(data.get('id', None), 'id')
140
141    # Canvas sometimes has email under different fields.
142    if ((data.get('email', None) is None) or (len(data.get('email', '')) == 0)):
143        data['email'] = data.get('login_id', None)
144
145    enrollments = data.get('enrollments', None)
146    if (enrollments is not None):
147        data['raw_role'] = _parse_role_from_enrollments(enrollments)
148        data['role'] = ENROLLMENT_TYPE_TO_ROLE.get(data['raw_role'], None)
149
150        # Canvas has a discontinuity with its default course roles.
151        # We need to patch this during testing.
152        if ((backend.is_testing() or _testing_override) and data['email'] == 'course-admin@test.edulinq.org'):
153            data['role'] = lms.model.users.CourseRole.ADMIN
154
155    return lms.model.users.CourseUser(**data)

Create a Canvas user associated with a course.

See: https://developerdocs.instructure.com/services/canvas/resources/users

def group(data: Dict[str, Any]) -> lms.model.groups.Group:
157def group(data: typing.Dict[str, typing.Any]) -> lms.model.groups.Group:
158    """
159    Create a Canvas group associated with a course.
160
161    See: https://developerdocs.instructure.com/services/canvas/resources/groups
162    """
163
164    # Check for important fields.
165    for field in ['id']:
166        if (field not in data):
167            raise ValueError(f"Canvas group is missing '{field}' field.")
168
169    # Modify specific arguments before creation.
170    data['id'] = lms.util.parse.required_string(data.get('id', None), 'id')
171
172    return lms.model.groups.Group(**data)

Create a Canvas group associated with a course.

See: https://developerdocs.instructure.com/services/canvas/resources/groups

def group_set(data: Dict[str, Any]) -> lms.model.groupsets.GroupSet:
174def group_set(data: typing.Dict[str, typing.Any]) -> lms.model.groupsets.GroupSet:
175    """
176    Create a Canvas group set associated with a course.
177
178    See: https://developerdocs.instructure.com/services/canvas/resources/group_categories
179    """
180
181    # Check for important fields.
182    for field in ['id']:
183        if (field not in data):
184            raise ValueError(f"Canvas group set is missing '{field}' field.")
185
186    # Modify specific arguments before creation.
187    data['id'] = lms.util.parse.required_string(data.get('id', None), 'id')
188
189    return lms.model.groupsets.GroupSet(**data)

Create a Canvas group set associated with a course.

See: https://developerdocs.instructure.com/services/canvas/resources/group_categories

def quiz( backend: lms.model.backend.APIBackend, data: Dict[str, Any], fetch_resources: bool = False) -> lms.model.quizzes.Quiz:
191def quiz(
192        backend: lms.model.backend.APIBackend,
193        data: typing.Dict[str, typing.Any],
194        fetch_resources: bool = False,
195        ) -> lms.model.quizzes.Quiz:
196    """
197    Create a Canvas quiz associated with a course.
198
199    See: https://developerdocs.instructure.com/services/canvas/resources/quizzes
200    """
201
202    _parse_assignment_data(data, 'quiz')
203
204    parsed_text = _canvas_html_to_markdown(backend, data.get('description', None), fetch_resources)
205    data['description'] = parsed_text.text
206    data['resources'] = parsed_text.resources
207
208    return lms.model.quizzes.Quiz(**data)

Create a Canvas quiz associated with a course.

See: https://developerdocs.instructure.com/services/canvas/resources/quizzes

def quiz_question( backend: lms.model.backend.APIBackend, data: Dict[str, Any], fetch_resources: bool = False) -> lms.model.quizzes.Question:
210def quiz_question(
211        backend: lms.model.backend.APIBackend,
212        data: typing.Dict[str, typing.Any],
213        fetch_resources: bool = False,
214        ) -> lms.model.quizzes.Question:
215    """
216    Create a Canvas quiz question.
217
218    See: https://developerdocs.instructure.com/services/canvas/resources/quiz_questions
219    """
220
221    # Check for important fields.
222    for field in ['id']:
223        if (field not in data):
224            raise ValueError(f"Canvas quiz question is missing '{field}' field.")
225
226    raw_question_type = data.get('question_type', None)
227    if (raw_question_type is None):
228        raise ValueError('No question type provided.')
229
230    question_type = QUESTION_TYPE_MAPPING.get(raw_question_type, None)
231    if (question_type is None):
232        raise ValueError(f"Unknown Canvas question type: '{raw_question_type}'.")
233
234    data['question_type'] = question_type
235    data['id'] = lms.util.parse.required_string(data.get('id', None), 'id')
236    data['name'] = lms.util.parse.optional_string(data.get('question_name', None))
237    data['points'] = lms.util.parse.optional_float(data.get('points_possible', None), 'points')
238    data['raw_answers'] = data.get('answers', None)
239    data['group_id'] = lms.util.parse.optional_string(data.get('quiz_group_id', None))
240
241    all_resource_paths = []
242
243    parsed_text = _canvas_html_to_markdown(backend, data.get('question_text', None), fetch_resources)
244    data['prompt'] = parsed_text.text
245    all_resource_paths += parsed_text.resources
246
247    (answers, resources) = _parse_quiz_question_answers(
248        backend,
249        data.get('answers', None),
250        data.get('matching_answer_incorrect_matches', None),
251        question_type,
252        fetch_resources,
253    )
254    data['answers'] = answers
255    all_resource_paths += resources
256
257    data['resources'] = all_resource_paths
258
259    return lms.model.quizzes.Question(**data)
def quiz_question_group(data: Dict[str, Any]) -> lms.model.quizzes.QuestionGroup:
261def quiz_question_group(data: typing.Dict[str, typing.Any]) -> lms.model.quizzes.QuestionGroup:
262    """
263    Create a Canvas quiz question group.
264
265    See: https://developerdocs.instructure.com/services/canvas/resources/quiz_question_groups
266    """
267
268    # Check for important fields.
269    for field in ['id']:
270        if (field not in data):
271            raise ValueError(f"Canvas quiz question group is missing '{field}' field.")
272
273    data['id'] = lms.util.parse.required_string(data.get('id', None), 'id')
274    data['name'] = lms.util.parse.optional_string(data.get('name', None))
275    data['points'] = lms.util.parse.optional_float(data.get('question_points', None), 'points')
276    data['pick_count'] = lms.util.parse.required_int(data.get('pick_count', None), 'pick_count')
277
278    return lms.model.quizzes.QuestionGroup(**data)