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,35 @@
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 Stimulus < NewQuestion
10
+ def to_h
11
+ @question
12
+ end
13
+
14
+ def combined_stimulus
15
+ "<p><strong>Instructions</strong></p><p>#{@question.dig(:entry,
16
+ :instructions)}</p><p><strong>#{@question.dig(
17
+ :entry, :title
18
+ )}</strong></p>#{@question.dig(:entry, :body)}"
19
+ end
20
+
21
+ def convert
22
+ super
23
+ begin
24
+ @converted_question = substitute_mathml(combined_stimulus)
25
+ rescue => e
26
+ @conversion_errors << e.message
27
+ end
28
+ post_convert
29
+ format
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ 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 TrueFalse < MultipleChoice
10
+ def options
11
+ @question[:entry][:interaction_data].map do |answer|
12
+ if answer[0] == :true_choice
13
+ {
14
+ label: "True",
15
+ value: "0"
16
+ }
17
+ else
18
+ {
19
+ label: "False",
20
+ value: "1"
21
+ }
22
+ end
23
+ end
24
+ end
25
+
26
+ def correct_answer
27
+ @question[:entry][:scoring_data][:value] ? ["0"] : ["1"]
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,30 @@
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
+ # Implementation for when Canvas QuizItem question_type is unknown.
10
+ # Allows the Quiz conversion to continue uninterrupted. See MCE-23091.
11
+ class Unknown < NewQuestion
12
+ def convert
13
+ begin
14
+ @question = JSON.parse(@question_json).deep_symbolize_keys
15
+ entry_type = @question[:entry_type]
16
+ question_type = @question.dig(:entry, :interaction_type_slug)
17
+
18
+ @conversion_errors << "Unsupported entry type: #{entry_type}" if entry_type
19
+ @conversion_errors << "Unknown question type: #{question_type}" if question_type
20
+ rescue JSON::ParserError => e
21
+ @question = ""
22
+ @conversion_errors << e.message
23
+ end
24
+ format
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,30 @@
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
+ class QuestionType
8
+ # NOTE: These are not necessarily all the names, just some that are referenced
9
+ # in the code.
10
+ NAMES = [
11
+ CHOICE_MULTI_TYPE = "CHOICE_MULTI".freeze,
12
+ CHOICE_TYPE = "CHOICE".freeze,
13
+ EXTENDED_TEXT_TYPE = "EXTENDED_TEXT".freeze,
14
+ GAP_MATCH_TYPE = "GAP_MATCH".freeze,
15
+ GRAPHIC_GAP_FILL_TYPE = "GRAPHIC_GAP_FILL".freeze,
16
+ INLINE_CHOICE_TYPE = "INLINE_CHOICE".freeze,
17
+ LETTER_SCORE_TYPE = "LETTER_SCORE".freeze,
18
+ MCQ_TYPE = "mcq".freeze,
19
+ MULTI_CHOICE_TYPE = "MULTI_CHOICE".freeze,
20
+ MULTI_SELECT_TYPE = "MULTI_SELECT".freeze,
21
+ ORDER_TYPE = "ORDER".freeze,
22
+ RUBRIC_CRITERIA_TYPE = "RUBRIC_CRITERIA".freeze,
23
+ TEXT_ENTRY_TYPE = "TEXT_ENTRY".freeze,
24
+ TRUE_FALSE_QTI_TYPE = "TRUE_FALSE_QTI".freeze,
25
+ TRUE_FALSE_TYPE = "TRUE_FALSE".freeze,
26
+ UNSUPPORTED_CANVAS_TYPE = "unsupported_canvas_question_type".freeze
27
+ ].freeze
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,26 @@
1
+ # How to use
2
+
3
+ Assuming you implemented a data provider for NQ or CQ, you can use the following code snipped to run the migration service
4
+ ```
5
+ quiz_id = nil # all quizzes in the course
6
+ course_id = 21
7
+ shard_id = 1
8
+ root_account_uuid = data_provider.root_account_uuid
9
+
10
+ learnosity_consumer_key = "YOUR_LEARNOSITY_CONSUMER_KEY"
11
+ learnosity_consumer_secret = "YOUR_LEARNOSITY_CONSUMER_SECRET"
12
+ learnosity_organisation_id = 1455
13
+
14
+ data_provider = Ams::DataProvider.new({ shard_id:, course_id:, quiz_id: })
15
+ destination = AmsMigration::Services::LearnositySink.new({
16
+ consumer_key: learnosity_consumer_key,
17
+ consumer_secret: learnosity_consumer_secret,
18
+ domain: "localhost",
19
+ organization_id: learnosity_organisation_id,
20
+ replace_images: true,
21
+ new_image_prefix: root_account_uuid
22
+ })
23
+
24
+ transformer = AmsMigration::Services::NewQuizLearnosityTransformer.new(root_account_uuid:)
25
+ AmsMigration::Services::MigrationService.new(data_provider:, transformer:, destination:).run
26
+ ```
@@ -0,0 +1,26 @@
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 Services
7
+ class DataProvider
8
+ # returns serialized data according to NQ Public API.
9
+ # [{
10
+ # "quiz": { response compatible with https://developerdocs.instructure.com/services/canvas/file.all_resources/new_quizzes },
11
+ # "quiz_items": [{ an array of quiz items. https://developerdocs.instructure.com/services/canvas/file.all_resources/new_quiz_items}],
12
+ # }]
13
+ def quizzes
14
+ raise NotImplementedError, "This method should be implemented in a subclass"
15
+ end
16
+
17
+ def items
18
+ raise NotImplementedError, "This method should be implemented in a subclass"
19
+ end
20
+
21
+ def banks
22
+ raise NotImplementedError, "This method should be implemented in a subclass"
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,44 @@
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
+ require "net/http"
6
+ require "json"
7
+ require "learnosity/sdk/request/init"
8
+
9
+ module AmsMigration
10
+ module Services
11
+ # Allows to completely "clean" Learnosity bank
12
+ # by archiving all items and activities
13
+ # NOTE: In learnosity, it is not possible to hard delete content. You can only archive it.
14
+ # also it is not possible to archive a question or a feature. These entities don't have status field.
15
+ class LearnosityBankCleaner
16
+ attr_reader :client
17
+
18
+ def initialize(config = {})
19
+ @client = LearnosityDataApiClient.new(config.slice(:consumer_key,
20
+ :consumer_secret,
21
+ :domain,
22
+ :data_api_url,
23
+ :organization_id))
24
+ end
25
+
26
+ def clean
27
+ archive "activities"
28
+ archive "items"
29
+ end
30
+
31
+ private
32
+
33
+ def archive(enum)
34
+ client.entity_enumerator(enum, { status: %w[published unpublished] }).each_slice(50) do |batch|
35
+ batch.each { |entity| entity["status"] = "archived" }
36
+
37
+ request = { enum => batch }
38
+
39
+ client.post_request(request, enum, "set")
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,135 @@
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
+ require "logger"
6
+
7
+ require "learnosity/sdk/request/init"
8
+ require "marcel"
9
+
10
+ module AmsMigration
11
+ module Services
12
+ # Learnosity client for API requests
13
+ class LearnosityDataApiClient
14
+ DEFAULT_DATA_API_URL = "https://data-or.learnosity.com/v2025.1.LTS".freeze
15
+ DEFAULT_DOMAIN = "localhost".freeze
16
+
17
+ attr_reader :consumer_key, :consumer_secret, :domain, :data_api_url, :organization_id, :logger, :log_responses
18
+
19
+ def initialize(config)
20
+ @consumer_key = config[:consumer_key]
21
+ @consumer_secret = config[:consumer_secret]
22
+ @domain = config.fetch(:domain, DEFAULT_DOMAIN)
23
+ @data_api_url = config.fetch(:data_api_url, DEFAULT_DATA_API_URL)
24
+ @organization_id = config[:organization_id]
25
+ @logger = config.fetch(:logger, Logger.new($stdout))
26
+ @log_responses = config.fetch(:log_responses, false)
27
+
28
+ validate!
29
+ end
30
+
31
+ def post_request(request, endpoint, method = "get")
32
+ request = request.merge({ "organisation_id" => organization_id })
33
+ init = Learnosity::Sdk::Request::Init.new("data", security_packet, consumer_secret, request, method)
34
+ signed_request = init.generate
35
+
36
+ request_url = "#{@data_api_url}/itembank/#{endpoint}"
37
+ response = Net::HTTP.post_form URI(request_url), signed_request
38
+
39
+ handle_response response
40
+ end
41
+
42
+ def entity_enumerator(endpoint, params = {})
43
+ Enumerator.new do |yielder|
44
+ next_token = nil
45
+
46
+ loop do
47
+ request_data = {
48
+ limit: 50,
49
+ next: next_token
50
+ }.merge(params)
51
+
52
+ response = post_request(request_data, endpoint, "get")
53
+ break unless response["data"]&.any?
54
+
55
+ response["data"].each do |entity|
56
+ yielder << entity
57
+ end
58
+
59
+ next_token = response.dig("meta", "next")
60
+ break unless next_token
61
+ end
62
+ end
63
+ end
64
+
65
+ protected
66
+
67
+ def security_packet
68
+ @security_packet ||= {
69
+ consumer_key:,
70
+ domain:
71
+ }.with_indifferent_access
72
+ end
73
+
74
+ def log(message, level: :debug)
75
+ logger.send(level, "#LearnosityBase: #{message}")
76
+ end
77
+
78
+ def validate!
79
+ raise ArgumentError, "Missing consumer_key" if consumer_key.blank?
80
+ raise ArgumentError, "Missing consumer_secret" if consumer_secret.blank?
81
+ raise ArgumentError, "Missing domain" if domain.blank?
82
+ raise ArgumentError, "Missing data_api_url" if data_api_url.blank?
83
+ raise ArgumentError, "Missing organization_id" if organization_id.blank?
84
+ end
85
+
86
+ def handle_response(response)
87
+ response_json = parse_response(response)
88
+ log_response(response, response_json) if log_responses
89
+ log_result(response, response_json)
90
+
91
+ response_json
92
+ end
93
+
94
+ private
95
+
96
+ def parse_response(response)
97
+ return nil if response.body.blank?
98
+
99
+ JSON.parse(response.body)
100
+ rescue JSON::ParserError => e
101
+ log("Failed to parse response: #{e.message}", level: :error)
102
+ nil
103
+ end
104
+
105
+ def log_response(response, response_json)
106
+ log "\n--- API Response ---"
107
+ log "HTTP Status: #{response.code} #{response.message}"
108
+ log "Response Body: #{JSON.pretty_generate(response_json)}"
109
+ end
110
+
111
+ def log_result(response, response_json)
112
+ if successful_response?(response, response_json)
113
+ log "\nSuccess."
114
+ else
115
+ log_error_details(response_json)
116
+ end
117
+ end
118
+
119
+ def successful_response?(response, response_json)
120
+ response.is_a?(Net::HTTPSuccess) &&
121
+ (response_json.blank? || response_json.dig("meta", "status") == true)
122
+ end
123
+
124
+ def log_error_details(response_json)
125
+ log "\nRequest failed."
126
+ if (meta = response_json.dig("meta", "message"))
127
+ log "Error Message: #{meta}"
128
+ end
129
+ if (errors = response_json["errors"])
130
+ log "API Errors: #{JSON.pretty_generate(errors)}"
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,190 @@
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
+ require "learnosity/sdk/request/init"
6
+ require "marcel"
7
+
8
+ module AmsMigration
9
+ module Services
10
+ class LearnositySink
11
+ attr_reader :replace_images, :image_path, :client, :logger
12
+
13
+ def initialize(config)
14
+ @client = LearnosityDataApiClient.new(config.slice(:consumer_key,
15
+ :consumer_secret,
16
+ :domain,
17
+ :data_api_url,
18
+ :organization_id))
19
+
20
+ @replace_images = config[:replace_images] || false
21
+ @image_path = config[:new_image_prefix] || ""
22
+ @logger = config[:logger] || Logger.new($stdout)
23
+ end
24
+
25
+ def save(package)
26
+ save_questions(package[:questions])
27
+ save_features(package[:features])
28
+ save_items(package[:items])
29
+ save_activities(package[:activities])
30
+ end
31
+
32
+ protected
33
+
34
+ def save_questions(questions)
35
+ questions = replace_images_in_questions(questions) if replace_images?
36
+
37
+ save_collection(questions, "questions")
38
+ end
39
+
40
+ def save_items(items)
41
+ save_collection(items, "items")
42
+ end
43
+
44
+ def save_activities(activities)
45
+ save_collection(activities, "activities")
46
+ end
47
+
48
+ def save_features(features)
49
+ save_collection(features, "features")
50
+ end
51
+
52
+ def save_collection(collection, collection_type)
53
+ return if collection.blank?
54
+
55
+ collection.each_slice(50) do |batch|
56
+ request = {
57
+ collection_type => batch
58
+ }
59
+
60
+ client.post_request(request, collection_type, "set")
61
+ end
62
+ end
63
+
64
+ def replace_images_in_questions(questions)
65
+ questions.map do |question|
66
+ map = upload_images(find_images_in_question(question))
67
+ replace_images_in_json(question, map)
68
+ end
69
+ end
70
+
71
+ def log(message, level: :debug)
72
+ logger.send(level, "#LearnositySink: #{message}")
73
+ end
74
+
75
+ def replace_images?
76
+ @replace_images
77
+ end
78
+
79
+ def image_sources(fragment)
80
+ html = Nokogiri::HTML::DocumentFragment.parse(fragment, "UTF-8")
81
+ html.css("img").filter_map { |img| img.attr("src") }
82
+ end
83
+
84
+ def find_images_in_question(question)
85
+ return [] unless question.is_a?(Hash)
86
+
87
+ json_data = (question["data"] || {}).with_indifferent_access
88
+ image_sources = find_images_in_json(json_data.slice(:stimulus, :options, :template))
89
+ image_sources << json_data.dig(:image, :source) if json_data[:type] == "hotspot"
90
+
91
+ image_sources
92
+ end
93
+
94
+ def find_images_in_json(question_json)
95
+ images = []
96
+ case question_json
97
+ when Hash
98
+ question_json.each_value { |value| images.concat(find_images_in_json(value)) }
99
+ when Array
100
+ question_json.each { |item| images.concat(find_images_in_json(item)) }
101
+ when String
102
+ images.concat(image_sources(question_json))
103
+ end
104
+ images.compact.uniq
105
+ end
106
+
107
+ def download_image(url)
108
+ uri = URI.parse(url)
109
+ response = Net::HTTP.get_response(uri)
110
+ return response.body if response.is_a?(Net::HTTPSuccess)
111
+
112
+ raise "Downloading image #{url} failed with code: #{response.code}. Response message: #{response.message}"
113
+ end
114
+
115
+ def upload_images(img_sources)
116
+ return {} if img_sources.blank?
117
+
118
+ image_map = {}
119
+ begin
120
+ img_sources.uniq.each do |download_url|
121
+ file_name = File.basename(URI.parse(download_url).path)
122
+
123
+ content = download_image download_url
124
+ content_type = Marcel::MimeType.for(content)
125
+
126
+ # Override files for now since this is a temporary solution for ICON
127
+ # There are no plans at the moment to store media files on Learnosity side,
128
+ # The plan as of now is to use InstFS.
129
+
130
+ # add extension to content type if the file doesn't have one
131
+ file_name += ".#{content_type.split(";").first.split("/").last}" unless /\.[a-z]+$/i.match?(file_name)
132
+
133
+ remote_key = "assets/#{@image_path}/#{file_name}"
134
+ request = {
135
+ "subkeys" => [remote_key]
136
+ }
137
+
138
+ result = client.post_request(request, "upload/assets", "get")
139
+
140
+ next unless result && result["data"]
141
+
142
+ learnosity_upload = result["data"].first
143
+ if learnosity_upload["upload"]
144
+ upload_image(learnosity_upload["upload"], content, content_type)
145
+ image_map[download_url] = learnosity_upload["public"]
146
+ end
147
+ end
148
+ rescue => e
149
+ log "Error processing image sources: #{e.message}", level: :error
150
+ log e.backtrace.join("\n"), level: :error
151
+ end
152
+
153
+ image_map
154
+ end
155
+
156
+ def upload_image(s3_signed_url, content, content_type)
157
+ uri = URI(s3_signed_url)
158
+ http = Net::HTTP.new(uri.host, uri.port)
159
+ http.use_ssl = uri.scheme == "https"
160
+
161
+ request = Net::HTTP::Put.new(uri)
162
+ request.body = content
163
+ request["Content-Type"] = content_type
164
+ request["Content-Length"] = content.bytesize.to_s
165
+
166
+ response = http.request(request)
167
+ raise "Failed to upload image: #{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
168
+ end
169
+
170
+ def replace_images_in_json(json, image_map)
171
+ return json if image_map.blank?
172
+
173
+ case json
174
+ when Hash
175
+ json.transform_values do |value|
176
+ replace_images_in_json(value, image_map)
177
+ end
178
+ when Array
179
+ json.map { |item| replace_images_in_json(item, image_map) }
180
+ when String
181
+ image_map.reduce(json) do |text, (source, url)|
182
+ text.gsub(source, url)
183
+ end
184
+ else
185
+ json
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,37 @@
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 Services
7
+ class MigrationService
8
+ attr_reader :data_provider, :transformer, :destination
9
+
10
+ def initialize(data_provider:, transformer:, destination:, logger: nil)
11
+ @data_provider = data_provider
12
+ @transformer = transformer
13
+ @destination = destination
14
+ @logger = logger
15
+
16
+ validate!
17
+ end
18
+
19
+ def run
20
+ data_provider.quizzes.each do |quiz_wrapper|
21
+ transformed = transformer.transform(quiz_wrapper)
22
+ destination.save(transformed)
23
+ end
24
+ end
25
+
26
+ protected
27
+
28
+ def validate!
29
+ raise ArgumentError, "Data provider is required" unless data_provider
30
+ raise ArgumentError, "Transformer is required" unless transformer
31
+ raise ArgumentError, "Destination is required" unless destination
32
+
33
+ raise "Invalid data provider" unless data_provider.is_a?(DataProvider)
34
+ end
35
+ end
36
+ end
37
+ end