ams_migration 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +9 -0
  3. data/README.md +33 -0
  4. data/lib/ams_migration/canvas/converter/classic_quiz/calculated.rb +33 -0
  5. data/lib/ams_migration/canvas/converter/classic_quiz/classic_question.rb +114 -0
  6. data/lib/ams_migration/canvas/converter/classic_quiz/essay.rb +36 -0
  7. data/lib/ams_migration/canvas/converter/classic_quiz/file_upload.rb +35 -0
  8. data/lib/ams_migration/canvas/converter/classic_quiz/fill_in_blank.rb +63 -0
  9. data/lib/ams_migration/canvas/converter/classic_quiz/fill_in_multiple_blanks.rb +111 -0
  10. data/lib/ams_migration/canvas/converter/classic_quiz/matching.rb +66 -0
  11. data/lib/ams_migration/canvas/converter/classic_quiz/multiple_answer.rb +27 -0
  12. data/lib/ams_migration/canvas/converter/classic_quiz/multiple_choice.rb +57 -0
  13. data/lib/ams_migration/canvas/converter/classic_quiz/multiple_dropdown.rb +126 -0
  14. data/lib/ams_migration/canvas/converter/classic_quiz/numerical.rb +89 -0
  15. data/lib/ams_migration/canvas/converter/classic_quiz/text.rb +28 -0
  16. data/lib/ams_migration/canvas/converter/classic_quiz/true_false.rb +14 -0
  17. data/lib/ams_migration/canvas/converter/classic_quiz/unknown.rb +31 -0
  18. data/lib/ams_migration/canvas/converter/new_quiz/categorization.rb +108 -0
  19. data/lib/ams_migration/canvas/converter/new_quiz/essay.rb +78 -0
  20. data/lib/ams_migration/canvas/converter/new_quiz/file_upload.rb +33 -0
  21. data/lib/ams_migration/canvas/converter/new_quiz/fill_in_blank.rb +180 -0
  22. data/lib/ams_migration/canvas/converter/new_quiz/formula.rb +33 -0
  23. data/lib/ams_migration/canvas/converter/new_quiz/hotspot.rb +166 -0
  24. data/lib/ams_migration/canvas/converter/new_quiz/matching.rb +85 -0
  25. data/lib/ams_migration/canvas/converter/new_quiz/multiple_answer.rb +28 -0
  26. data/lib/ams_migration/canvas/converter/new_quiz/multiple_choice.rb +70 -0
  27. data/lib/ams_migration/canvas/converter/new_quiz/new_question.rb +133 -0
  28. data/lib/ams_migration/canvas/converter/new_quiz/numerical.rb +117 -0
  29. data/lib/ams_migration/canvas/converter/new_quiz/ordering.rb +68 -0
  30. data/lib/ams_migration/canvas/converter/new_quiz/stimulus.rb +35 -0
  31. data/lib/ams_migration/canvas/converter/new_quiz/true_false.rb +33 -0
  32. data/lib/ams_migration/canvas/converter/new_quiz/unknown.rb +30 -0
  33. data/lib/ams_migration/canvas/converter/question_type.rb +30 -0
  34. data/lib/ams_migration/services/README.md +26 -0
  35. data/lib/ams_migration/services/data_provider.rb +26 -0
  36. data/lib/ams_migration/services/learnosity_bank_cleaner.rb +44 -0
  37. data/lib/ams_migration/services/learnosity_data_api_client.rb +135 -0
  38. data/lib/ams_migration/services/learnosity_sink.rb +190 -0
  39. data/lib/ams_migration/services/migration_service.rb +37 -0
  40. data/lib/ams_migration/services/new_quiz_learnosity_transformer.rb +115 -0
  41. data/lib/ams_migration/version.rb +3 -0
  42. data/lib/ams_migration.rb +45 -0
  43. data/lib/factories/classic_quiz_item_converter_factory.rb +43 -0
  44. data/lib/factories/new_quiz_item_converter_factory.rb +76 -0
  45. data/spec/fixtures/sample_data/classic_quiz/calculated.json +24 -0
  46. data/spec/fixtures/sample_data/classic_quiz/essay.json +24 -0
  47. data/spec/fixtures/sample_data/classic_quiz/file_upload.json +24 -0
  48. data/spec/fixtures/sample_data/classic_quiz/fill_in_bank.json +24 -0
  49. data/spec/fixtures/sample_data/classic_quiz/fill_in_blank.json +53 -0
  50. data/spec/fixtures/sample_data/classic_quiz/fill_in_multiple_blanks.json +57 -0
  51. data/spec/fixtures/sample_data/classic_quiz/matching.json +107 -0
  52. data/spec/fixtures/sample_data/classic_quiz/multiple_answer.json +48 -0
  53. data/spec/fixtures/sample_data/classic_quiz/multiple_choice.json +56 -0
  54. data/spec/fixtures/sample_data/classic_quiz/multiple_dropdown.json +73 -0
  55. data/spec/fixtures/sample_data/classic_quiz/numerical.json +55 -0
  56. data/spec/fixtures/sample_data/classic_quiz/text.json +24 -0
  57. data/spec/fixtures/sample_data/classic_quiz/true_false.json +39 -0
  58. data/spec/fixtures/sample_data/classic_quiz/unknown.json +24 -0
  59. data/spec/fixtures/sample_data/new_quiz/bank_entry_item.json +5 -0
  60. data/spec/fixtures/sample_data/new_quiz/bank_item.json +11 -0
  61. data/spec/fixtures/sample_data/new_quiz/categorization.json +135 -0
  62. data/spec/fixtures/sample_data/new_quiz/essay.json +40 -0
  63. data/spec/fixtures/sample_data/new_quiz/file_upload.json +30 -0
  64. data/spec/fixtures/sample_data/new_quiz/fill_in_blank_association.json +162 -0
  65. data/spec/fixtures/sample_data/new_quiz/fill_in_blank_dropdown.json +180 -0
  66. data/spec/fixtures/sample_data/new_quiz/fill_in_blank_math.json +45 -0
  67. data/spec/fixtures/sample_data/new_quiz/fill_in_blank_mixed.json +188 -0
  68. data/spec/fixtures/sample_data/new_quiz/fill_in_blank_text.json +140 -0
  69. data/spec/fixtures/sample_data/new_quiz/formula.json +51 -0
  70. data/spec/fixtures/sample_data/new_quiz/hotspot_oval.json +38 -0
  71. data/spec/fixtures/sample_data/new_quiz/hotspot_polygon.json +66 -0
  72. data/spec/fixtures/sample_data/new_quiz/hotspot_square.json +38 -0
  73. data/spec/fixtures/sample_data/new_quiz/matching.json +88 -0
  74. data/spec/fixtures/sample_data/new_quiz/multiple_answer.json +57 -0
  75. data/spec/fixtures/sample_data/new_quiz/multiple_choice.json +66 -0
  76. data/spec/fixtures/sample_data/new_quiz/multiple_choice_math.json +55 -0
  77. data/spec/fixtures/sample_data/new_quiz/numerical.json +64 -0
  78. data/spec/fixtures/sample_data/new_quiz/ordering.json +60 -0
  79. data/spec/fixtures/sample_data/new_quiz/stimulus.json +17 -0
  80. data/spec/fixtures/sample_data/new_quiz/true_false.json +27 -0
  81. data/spec/fixtures/sample_data/new_quiz/unknown.json +14 -0
  82. data/spec/lib/ams_migration/services/learnosity_sink_spec.rb +198 -0
  83. data/spec/lib/canvas/converter/classic_quiz/calculated_spec.rb +26 -0
  84. data/spec/lib/canvas/converter/classic_quiz/essay_spec.rb +26 -0
  85. data/spec/lib/canvas/converter/classic_quiz/file_upload_spec.rb +23 -0
  86. data/spec/lib/canvas/converter/classic_quiz/fill_in_blank_spec.rb +30 -0
  87. data/spec/lib/canvas/converter/classic_quiz/fill_in_multiple_blanks_spec.rb +33 -0
  88. data/spec/lib/canvas/converter/classic_quiz/matching_spec.rb +38 -0
  89. data/spec/lib/canvas/converter/classic_quiz/multiple_answer_spec.rb +33 -0
  90. data/spec/lib/canvas/converter/classic_quiz/multiple_choice_spec.rb +31 -0
  91. data/spec/lib/canvas/converter/classic_quiz/multiple_dropdown_spec.rb +39 -0
  92. data/spec/lib/canvas/converter/classic_quiz/numerical_spec.rb +41 -0
  93. data/spec/lib/canvas/converter/classic_quiz/text_spec.rb +20 -0
  94. data/spec/lib/canvas/converter/classic_quiz/true_false_spec.rb +28 -0
  95. data/spec/lib/canvas/converter/classic_quiz/unknown_spec.rb +25 -0
  96. data/spec/lib/canvas/converter/common_error.rb +16 -0
  97. data/spec/lib/canvas/converter/new_quiz/categorization_spec.rb +55 -0
  98. data/spec/lib/canvas/converter/new_quiz/essay_spec.rb +26 -0
  99. data/spec/lib/canvas/converter/new_quiz/file_upload_spec.rb +24 -0
  100. data/spec/lib/canvas/converter/new_quiz/fill_in_blank_spec.rb +111 -0
  101. data/spec/lib/canvas/converter/new_quiz/formula_spec.rb +23 -0
  102. data/spec/lib/canvas/converter/new_quiz/hotspot_spec.rb +182 -0
  103. data/spec/lib/canvas/converter/new_quiz/matching_spec.rb +30 -0
  104. data/spec/lib/canvas/converter/new_quiz/multiple_answer_spec.rb +34 -0
  105. data/spec/lib/canvas/converter/new_quiz/multiple_choice_spec.rb +54 -0
  106. data/spec/lib/canvas/converter/new_quiz/numerical_spec.rb +48 -0
  107. data/spec/lib/canvas/converter/new_quiz/ordering_spec.rb +25 -0
  108. data/spec/lib/canvas/converter/new_quiz/stimulus_spec.rb +20 -0
  109. data/spec/lib/canvas/converter/new_quiz/true_false_spec.rb +24 -0
  110. data/spec/lib/canvas/converter/new_quiz/unknown_spec.rb +36 -0
  111. data/spec/lib/factories/classic_quiz_item_converter_factory_spec.rb +183 -0
  112. data/spec/lib/factories/new_quiz_item_converter_factory_spec.rb +71 -0
  113. data/spec/lib/services/learnosity_data_api_client_spec.rb +130 -0
  114. data/spec/lib/services/new_quiz_learnosity_transformer_spec.rb +196 -0
  115. data/spec/spec_helper.rb +20 -0
  116. metadata +229 -0
@@ -0,0 +1,126 @@
1
+ # Copyright (C) 2025 - present Instructure, Inc.
2
+ # This code is made publicly available for educational and reference purposes only.
3
+ # You may not copy, modify, redistribute, or use this code in any software or service without explicit permission.
4
+
5
+ module AmsMigration
6
+ module Canvas
7
+ module Converter
8
+ module ClassicQuiz
9
+ class MultipleDropdown < ClassicQuestion
10
+ def correct_answer
11
+ valid_values = {}
12
+ alternate_values = {}
13
+ possible_values = {}
14
+ ordered_templates = []
15
+ stimulus.scan(/\[.*?\]/).each_with_index do |template, _index|
16
+ template = template.delete("[").delete("]")
17
+ ordered_templates << template
18
+ @question[:answers].select { |answer| answer[:blank_id] == template }.each_with_index do |answer, index|
19
+ if answer[:text] && answer[:blank_id]
20
+ if possible_values[answer[:blank_id]]
21
+ possible_values[answer[:blank_id]] << answer[:text]
22
+ else
23
+ possible_values[answer[:blank_id]] = [answer[:text]]
24
+ end
25
+ end
26
+ if index.zero? && answer[:text] && answer[:blank_id]
27
+ if valid_values[answer[:blank_id]]
28
+ valid_values[answer[:blank_id]] << answer[:text]
29
+ else
30
+ valid_values[answer[:blank_id]] = [answer[:text]]
31
+ end
32
+ elsif answer[:text] && answer[:blank_id]
33
+ if alternate_values[answer[:blank_id]]
34
+ alternate_values[answer[:blank_id]] << answer[:text]
35
+ else
36
+ alternate_values[answer[:blank_id]] = [answer[:text]]
37
+ end
38
+ end
39
+ end
40
+ end
41
+
42
+ valid_values.values.map(&:size).max
43
+
44
+ correct_values = ordered_templates.map do |template|
45
+ valid_values[template].first
46
+ end
47
+
48
+ valid_response = {
49
+ score: point_value,
50
+ value: correct_values
51
+ }
52
+
53
+ alternate_responses = []
54
+ max_answers = alternate_values.values.map(&:size).max
55
+ (0..(max_answers - 1)).each do |i|
56
+ alternate_response = ordered_templates.filter_map do |template|
57
+ alternate_values&.dig(template, i)
58
+ end
59
+ alternate_responses << {
60
+ score: 1,
61
+ value: alternate_response
62
+ }
63
+ end
64
+
65
+ possible_responses = ordered_templates.map do |template|
66
+ possible_values[template]
67
+ end
68
+ {
69
+ valid_response:,
70
+ alternate_responses:,
71
+ possible_responses:
72
+ }
73
+ end
74
+
75
+ def distractors
76
+ results = []
77
+ has_distractors = false
78
+ @question[:answers].each do |answer|
79
+ distractor = answer[:comments_html] ||= ""
80
+ results << distractor
81
+ has_distractors = true if distractor != ""
82
+ end
83
+ has_distractors ? results : nil
84
+ end
85
+
86
+ def convert
87
+ super
88
+ begin
89
+ correct_values = correct_answer
90
+
91
+ @converted_question = {
92
+ template: stimulus.gsub("[", "{{response}}").delete("]").to_s,
93
+ type: "clozedropdown",
94
+ possible_responses: correct_values[:possible_responses],
95
+ validation: {
96
+ valid_response: correct_values[:valid_response],
97
+ scoring_type: "exactMatch",
98
+ match_all_possible_responses: true
99
+ },
100
+ is_math: true,
101
+ response_container: {
102
+ pointer: "left"
103
+ }
104
+ }
105
+
106
+ if correct_values[:alternate_responses].size.positive?
107
+ @converted_question[:validation][:alt_responses] = correct_values[:alternate_responses]
108
+ end
109
+
110
+ @converted_question[:metadata] = {
111
+ distractor_rationale_response_level: distractors
112
+ }
113
+ rescue => e
114
+ @conversion_errors << e.message
115
+ end
116
+ post_convert
117
+ {
118
+ question: @converted_question,
119
+ errors: @conversion_errors
120
+ }
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,89 @@
1
+ # Copyright (C) 2025 - present Instructure, Inc.
2
+ # This code is made publicly available for educational and reference purposes only.
3
+ # You may not copy, modify, redistribute, or use this code in any software or service without explicit permission.
4
+
5
+ module AmsMigration
6
+ module Canvas
7
+ module Converter
8
+ module ClassicQuiz
9
+ class Numerical < ClassicQuestion
10
+ def correct_values
11
+ values = []
12
+ @question[:answers].map do |answer|
13
+ case answer[:numerical_answer_type]
14
+ when "exact_answer"
15
+ values << if answer[:margin].zero?
16
+ {
17
+ method: "equivLiteral",
18
+ value: answer[:exact].to_s,
19
+ options: {
20
+ ignoreOrder: false,
21
+ inverseResult: false
22
+ }
23
+ }
24
+ else
25
+ {
26
+ method: "equivValue",
27
+ value: "#{answer[:exact]}\\pm#{answer[:margin]}"
28
+ }
29
+ end
30
+ when "range_answer"
31
+ @notifications << 'Numeric items with "Answer in Range" may need additional configuration'
32
+ if answer[:end] && answer[:start]
33
+ values << {
34
+ method: "equivValue",
35
+ value: "#{answer[:end] - answer[:start]}\\pm#{(answer[:end] - answer[:start]) / 2}",
36
+ options: {
37
+ ignoreOrder: false,
38
+ inverseResult: false
39
+ }
40
+ }
41
+ end
42
+ when "precision_answer"
43
+ @notifications << 'Numeric items with "Answer with Precision" may need additional configuration'
44
+ values << {
45
+ method: "equivValue",
46
+ value: answer[:approximate].to_s,
47
+ options: {
48
+ decimalPlaces: answer[:precision]
49
+ }
50
+ }
51
+ end
52
+ end
53
+ values
54
+ end
55
+
56
+ def convert
57
+ super
58
+ begin
59
+ @converted_question = {
60
+ stimulus:,
61
+ template: "{{response}}",
62
+ response_container: {
63
+ template: ""
64
+ },
65
+ type: "clozeformula",
66
+ validation: {
67
+ scoring_type: "exactMatch",
68
+ valid_response: {
69
+ score: point_value,
70
+ value: [correct_values]
71
+ }
72
+ },
73
+ is_math: true,
74
+ response_containers: []
75
+ }
76
+ rescue => e
77
+ @conversion_errors << e.message
78
+ end
79
+ post_convert
80
+ {
81
+ question: @converted_question,
82
+ errors: @conversion_errors
83
+ }
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,28 @@
1
+ # Copyright (C) 2025 - present Instructure, Inc.
2
+ # This code is made publicly available for educational and reference purposes only.
3
+ # You may not copy, modify, redistribute, or use this code in any software or service without explicit permission.
4
+
5
+ module AmsMigration
6
+ module Canvas
7
+ module Converter
8
+ module ClassicQuiz
9
+ class Text < ClassicQuestion
10
+ # This question type only returns the stimulus as the question text
11
+ def convert
12
+ super
13
+ begin
14
+ @converted_question = stimulus
15
+ rescue => e
16
+ @conversion_errors << e.message
17
+ end
18
+ post_convert
19
+ {
20
+ question: @converted_question,
21
+ errors: @conversion_errors
22
+ }
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,14 @@
1
+ # Copyright (C) 2025 - present Instructure, Inc.
2
+ # This code is made publicly available for educational and reference purposes only.
3
+ # You may not copy, modify, redistribute, or use this code in any software or service without explicit permission.
4
+
5
+ module AmsMigration
6
+ module Canvas
7
+ module Converter
8
+ module ClassicQuiz
9
+ class TrueFalse < MultipleChoice
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,31 @@
1
+ # Copyright (C) 2025 - present Instructure, Inc.
2
+ # This code is made publicly available for educational and reference purposes only.
3
+ # You may not copy, modify, redistribute, or use this code in any software or service without explicit permission.
4
+
5
+ module AmsMigration
6
+ module Canvas
7
+ module Converter
8
+ module ClassicQuiz
9
+ # Implementation for when Canvas QuizItem question_type is unknown.
10
+ # Allows the Quiz conversion to continue uninterrupted. See MCE-23091.
11
+ class Unknown < ClassicQuestion
12
+ # This question type will be skipped in the conversion process and the
13
+ # question will not be included in the quiz.
14
+ def convert
15
+ begin
16
+ @question = JSON.parse(@question_json).deep_symbolize_keys
17
+ @conversion_errors << "Unknown question type: #{@question[:question_type]}"
18
+ rescue JSON::ParserError => e
19
+ @question = ""
20
+ @conversion_errors << e.message
21
+ end
22
+ {
23
+ question: "",
24
+ errors: @conversion_errors
25
+ }
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,108 @@
1
+ # Copyright (C) 2025 - present Instructure, Inc.
2
+ # This code is made publicly available for educational and reference purposes only.
3
+ # You may not copy, modify, redistribute, or use this code in any software or service without explicit permission.
4
+
5
+ module AmsMigration
6
+ module Canvas
7
+ module Converter
8
+ module NewQuiz
9
+ # This class converts 'Categorization' questions from Canvas New Quizzes to the
10
+ # MasteryConnect 'Classification' question type.
11
+ class Categorization < NewQuestion
12
+ def initialize(question)
13
+ super
14
+ @distractor_lookup = []
15
+ @category_lookup = []
16
+ end
17
+
18
+ def convert
19
+ super
20
+ begin
21
+ @converted_question = converted_question
22
+ rescue => e
23
+ @conversion_errors << e.message
24
+ end
25
+ post_convert
26
+ format
27
+ end
28
+
29
+ def converted_question
30
+ @distractor_lookup = build_lookup(@question[:entry][:interaction_data][:distractors])
31
+ @category_lookup = build_lookup(@question[:entry][:interaction_data][:categories])
32
+ {
33
+ possible_responses:,
34
+ stimulus:,
35
+ type: "classification",
36
+ ui_style:,
37
+ validation:
38
+ }
39
+ end
40
+
41
+ def possible_responses
42
+ @distractor_lookup.map do |distractor|
43
+ distractor[:item_body]
44
+ end
45
+ end
46
+
47
+ def stimulus
48
+ @question[:entry][:item_body]
49
+ end
50
+
51
+ def ui_style
52
+ {
53
+ column_count:,
54
+ column_titles:
55
+ }
56
+ end
57
+
58
+ def column_count
59
+ @question[:entry][:interaction_data][:categories].length
60
+ end
61
+
62
+ def column_titles
63
+ @question[:entry][:interaction_data][:category_order].map do |id|
64
+ find_element(@category_lookup, id)[:item_body]
65
+ end
66
+ end
67
+
68
+ def validation
69
+ {
70
+ scoring_type: "exactMatch",
71
+ valid_response: {
72
+ score:,
73
+ value:
74
+ }
75
+ }
76
+ end
77
+
78
+ def score
79
+ @question[:points_possible].round
80
+ end
81
+
82
+ def value
83
+ @question[:entry][:interaction_data][:category_order].map do |category|
84
+ category = find_element(@question[:entry][:scoring_data][:value], category)
85
+ category[:scoring_data][:value].map do |value|
86
+ find_element(@distractor_lookup, value)[:index]
87
+ end
88
+ end
89
+ end
90
+
91
+ def build_lookup(json_data)
92
+ json_data.map.with_index do |(id, item), index|
93
+ {
94
+ id:,
95
+ item_body: item[:item_body],
96
+ index:
97
+ }
98
+ end
99
+ end
100
+
101
+ def find_element(collection, id)
102
+ collection.find { |item| item[:id].to_s == id }
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,78 @@
1
+ # Copyright (C) 2025 - present Instructure, Inc.
2
+ # This code is made publicly available for educational and reference purposes only.
3
+ # You may not copy, modify, redistribute, or use this code in any software or service without explicit permission.
4
+
5
+ module AmsMigration
6
+ module Canvas
7
+ module Converter
8
+ module NewQuiz
9
+ class Essay < NewQuestion
10
+ def to_h
11
+ @question
12
+ end
13
+
14
+ def spellcheck
15
+ @question.dig(:entry, :interaction_data, :spell_check)
16
+ end
17
+
18
+ def type
19
+ @question.dig(:entry, :interaction_data, :rce) ? "longtextV2" : "plaintext"
20
+ end
21
+
22
+ def rubric
23
+ @question.dig(:entry, :scoring_data, :value)
24
+ end
25
+
26
+ def rubric_points
27
+ @question[:points_possible]
28
+ end
29
+
30
+ def show_word_limit
31
+ @question.dig(:entry, :interaction_data, :word_limit_enabled)
32
+ end
33
+
34
+ def show_word_count
35
+ @question.dig(:entry, :interaction_data, :word_count)
36
+ end
37
+
38
+ def max_length
39
+ if @question.dig(:entry,
40
+ :interaction_data,
41
+ :word_limit_enabled)
42
+ @question.dig(:entry, :interaction_data, :word_limit_max).to_i
43
+ end
44
+ end
45
+
46
+ def convert
47
+ super
48
+ begin
49
+ @converted_question = {
50
+ stimulus:,
51
+ spellcheck:,
52
+ type:,
53
+ metadata: {
54
+ rubric:,
55
+ rubric_points:
56
+ },
57
+ max_length:,
58
+ show_word_count:
59
+ }
60
+
61
+ if type == "plaintext"
62
+ @converted_question[:show_copy] = true
63
+ @converted_question[:show_cut] = true
64
+ @converted_question[:show_paste] = true
65
+ end
66
+
67
+ @converted_question[:show_word_limit] = "off" unless show_word_limit
68
+ rescue => e
69
+ @conversion_errors << e.message
70
+ end
71
+ post_convert
72
+ format
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,33 @@
1
+ # Copyright (C) 2025 - present Instructure, Inc.
2
+ # This code is made publicly available for educational and reference purposes only.
3
+ # You may not copy, modify, redistribute, or use this code in any software or service without explicit permission.
4
+
5
+ module AmsMigration
6
+ module Canvas
7
+ module Converter
8
+ module NewQuiz
9
+ class FileUpload < NewQuestion
10
+ # This question type will be skipped in the conversion process and the
11
+ # question will not be included in the quiz. The JSON returned will be
12
+ # an essay question with text noting the question has been skipped.
13
+ def convert
14
+ super
15
+ @converted_question = {
16
+ stimulus: "The File upload item was removed from the quiz because there is no equivalent item type in Mastery Connect. Please check the original quiz for this question.",
17
+ type: QuestionType::UNSUPPORTED_CANVAS_TYPE,
18
+ validation: {
19
+ valid_response: {
20
+ score: 0,
21
+ value: ""
22
+ },
23
+ scoring_type: "exactMatch"
24
+ },
25
+ is_math: true
26
+ }
27
+ format
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,180 @@
1
+ # Copyright (C) 2025 - present Instructure, Inc.
2
+ # This code is made publicly available for educational and reference purposes only.
3
+ # You may not copy, modify, redistribute, or use this code in any software or service without explicit permission.
4
+
5
+ module AmsMigration
6
+ module Canvas
7
+ module Converter
8
+ module NewQuiz
9
+ # This class converts 'Fill in the Blank' questions from Canvas New Quizzes to a MasteryConnect
10
+ # compatible question type. Mixed answer types are not supported, failing the conversion.
11
+ class FillInBlank < NewQuestion
12
+ def initialize(question)
13
+ super
14
+ @include_possible_responses = false
15
+ @answer_type = ""
16
+ @is_mixed_question_type = false
17
+ end
18
+
19
+ def convert
20
+ super
21
+ begin
22
+ @converted_question = converted_question
23
+ rescue => e
24
+ @conversion_errors << e.message
25
+ end
26
+ post_convert
27
+ format
28
+ end
29
+
30
+ def converted_question
31
+ determine_answer_type
32
+ {
33
+ stimulus: question_title,
34
+ template: convert_item_body,
35
+ type: @answer_type,
36
+ validation:,
37
+ possible_responses: (possible_responses if @include_possible_responses)
38
+ }.compact
39
+ end
40
+
41
+ def question_title
42
+ @question[:entry][:title]
43
+ end
44
+
45
+ def convert_item_body
46
+ html = Nokogiri::HTML::DocumentFragment.parse(@question[:entry][:item_body])
47
+
48
+ # Remove all HTML tags except the <span> elements with 'blank...' IDs
49
+ html.css('span[id^="blank_"]').each do |span|
50
+ span.replace("{{response}}")
51
+ end
52
+
53
+ html.to_xhtml(indent: 0)
54
+ end
55
+
56
+ def validation
57
+ all_responses = response_list
58
+ {
59
+ scoring_type: "exactMatch",
60
+ valid_response: all_responses.shift,
61
+ alt_responses: (all_responses unless all_responses.empty?),
62
+ match_all_possible_responses: (true unless all_responses.empty?)
63
+ }.compact
64
+ end
65
+
66
+ def response_list
67
+ response_list = Array.new(response_length) { { score: point_value, value: [] } }
68
+
69
+ # Iterate through question's value array and populate response_list
70
+ @question[:entry][:scoring_data][:value].each do |blank|
71
+ (0..(response_length - 1)).each do |i|
72
+ if !@is_mixed_question_type && @answer_type == "clozetext"
73
+ validate_scoring_algorithm(blank[:scoring_algorithm])
74
+ end
75
+ # Retrieve value at current index if it exists
76
+ if blank[:scoring_data][:value].is_a?(Array)
77
+ response_list[i][:value].append(val_from_array(i, blank))
78
+ else
79
+ response_list[i][:value].append(blank[:scoring_data][:blank_text])
80
+ end
81
+ end
82
+ end
83
+
84
+ response_list
85
+ end
86
+
87
+ def validate_scoring_algorithm(scoring_algorithm)
88
+ valid_algorithms = %w[TextEquivalence TextInChoices]
89
+ return if valid_algorithms.include?(scoring_algorithm)
90
+
91
+ @notifications << "Fill in the blank question with text match option #{map_scoring_algorithm(scoring_algorithm)} has been converted to exact match"
92
+ end
93
+
94
+ def map_scoring_algorithm(scoring_algorithm)
95
+ case scoring_algorithm
96
+ when "TextContainsAnswer"
97
+ '"Contains"'
98
+ when "TextCloseEnough"
99
+ '"Close Enough"'
100
+ when "TextRegex"
101
+ '"Regular Expression Match"'
102
+ else
103
+ raise JSON::ParserError, "Invalid scoring_algorithm"
104
+ end
105
+ end
106
+
107
+ def response_length
108
+ @question[:entry][:scoring_data][:value].map do |blank|
109
+ blank[:scoring_data][:value].is_a?(Array) ? blank[:scoring_data][:value].length : 1
110
+ end.max || -1
111
+ end
112
+
113
+ def val_from_array(index, blank)
114
+ (index < blank[:scoring_data][:value].length) ? blank[:scoring_data][:value][index] : nil
115
+ end
116
+
117
+ def determine_answer_type
118
+ # Fix for a Canvas API bug where the `anwer_type` key is `answerType`
119
+ answer_type_key = @question[:entry][:interaction_data][:blanks][0][:answer_type] ? :answer_type : :answerType
120
+ current_answer_type = @question[:entry][:interaction_data][:blanks][0][answer_type_key]
121
+ @question[:entry][:interaction_data][:blanks].each do |blank|
122
+ next unless blank[answer_type_key] != current_answer_type
123
+
124
+ @notifications << "Fill in the blank question with mixed answer types have been converted to open entry exact match"
125
+ current_answer_type = "openEntry"
126
+ @is_mixed_question_type = true
127
+ break
128
+ end
129
+ @answer_type = map_answer_type_to_mc_type(current_answer_type)
130
+ end
131
+
132
+ def map_answer_type_to_mc_type(answer_type)
133
+ case answer_type
134
+ when "openEntry"
135
+ "clozetext"
136
+ when "dropdown"
137
+ @include_possible_responses = true
138
+ "clozedropdown"
139
+ when "wordbank"
140
+ @include_possible_responses = true
141
+ "clozeassociation"
142
+ else
143
+ raise JSON::ParserError, "Invalid answer type for FillInBlank"
144
+ end
145
+ end
146
+
147
+ def possible_responses
148
+ case @answer_type
149
+ when "clozedropdown"
150
+ dropdown_possible_responses
151
+ when "clozeassociation"
152
+ wordbank_possible_responses
153
+ else
154
+ raise JSON::ParserError, "There was an error converting this question"
155
+ end
156
+ end
157
+
158
+ def dropdown_possible_responses
159
+ possible_responses = Array.new(@question[:entry][:interaction_data][:blanks].length) { [] }
160
+ @question[:entry][:interaction_data][:blanks].each_with_index do |blank, index|
161
+ blank[:choices].each do |choice|
162
+ possible_responses[index].append(choice[:item_body])
163
+ end
164
+ end
165
+ possible_responses
166
+ end
167
+
168
+ def wordbank_possible_responses
169
+ choices = @question.dig(:entry, :interaction_data, :word_bank_choices)
170
+ unless choices
171
+ raise JSON::ParserError, "Could not find `word_bank_choices` property to form possible responses"
172
+ end
173
+
174
+ choices.map { |choice| choice[:item_body] }
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end