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.
- checksums.yaml +7 -0
- data/LICENSE.txt +9 -0
- data/README.md +33 -0
- data/lib/ams_migration/canvas/converter/classic_quiz/calculated.rb +33 -0
- data/lib/ams_migration/canvas/converter/classic_quiz/classic_question.rb +114 -0
- data/lib/ams_migration/canvas/converter/classic_quiz/essay.rb +36 -0
- data/lib/ams_migration/canvas/converter/classic_quiz/file_upload.rb +35 -0
- data/lib/ams_migration/canvas/converter/classic_quiz/fill_in_blank.rb +63 -0
- data/lib/ams_migration/canvas/converter/classic_quiz/fill_in_multiple_blanks.rb +111 -0
- data/lib/ams_migration/canvas/converter/classic_quiz/matching.rb +66 -0
- data/lib/ams_migration/canvas/converter/classic_quiz/multiple_answer.rb +27 -0
- data/lib/ams_migration/canvas/converter/classic_quiz/multiple_choice.rb +57 -0
- data/lib/ams_migration/canvas/converter/classic_quiz/multiple_dropdown.rb +126 -0
- data/lib/ams_migration/canvas/converter/classic_quiz/numerical.rb +89 -0
- data/lib/ams_migration/canvas/converter/classic_quiz/text.rb +28 -0
- data/lib/ams_migration/canvas/converter/classic_quiz/true_false.rb +14 -0
- data/lib/ams_migration/canvas/converter/classic_quiz/unknown.rb +31 -0
- data/lib/ams_migration/canvas/converter/new_quiz/categorization.rb +108 -0
- data/lib/ams_migration/canvas/converter/new_quiz/essay.rb +78 -0
- data/lib/ams_migration/canvas/converter/new_quiz/file_upload.rb +33 -0
- data/lib/ams_migration/canvas/converter/new_quiz/fill_in_blank.rb +180 -0
- data/lib/ams_migration/canvas/converter/new_quiz/formula.rb +33 -0
- data/lib/ams_migration/canvas/converter/new_quiz/hotspot.rb +166 -0
- data/lib/ams_migration/canvas/converter/new_quiz/matching.rb +85 -0
- data/lib/ams_migration/canvas/converter/new_quiz/multiple_answer.rb +28 -0
- data/lib/ams_migration/canvas/converter/new_quiz/multiple_choice.rb +70 -0
- data/lib/ams_migration/canvas/converter/new_quiz/new_question.rb +133 -0
- data/lib/ams_migration/canvas/converter/new_quiz/numerical.rb +117 -0
- data/lib/ams_migration/canvas/converter/new_quiz/ordering.rb +68 -0
- data/lib/ams_migration/canvas/converter/new_quiz/stimulus.rb +35 -0
- data/lib/ams_migration/canvas/converter/new_quiz/true_false.rb +33 -0
- data/lib/ams_migration/canvas/converter/new_quiz/unknown.rb +30 -0
- data/lib/ams_migration/canvas/converter/question_type.rb +30 -0
- data/lib/ams_migration/services/README.md +26 -0
- data/lib/ams_migration/services/data_provider.rb +26 -0
- data/lib/ams_migration/services/learnosity_bank_cleaner.rb +44 -0
- data/lib/ams_migration/services/learnosity_data_api_client.rb +135 -0
- data/lib/ams_migration/services/learnosity_sink.rb +190 -0
- data/lib/ams_migration/services/migration_service.rb +37 -0
- data/lib/ams_migration/services/new_quiz_learnosity_transformer.rb +115 -0
- data/lib/ams_migration/version.rb +3 -0
- data/lib/ams_migration.rb +45 -0
- data/lib/factories/classic_quiz_item_converter_factory.rb +43 -0
- data/lib/factories/new_quiz_item_converter_factory.rb +76 -0
- data/spec/fixtures/sample_data/classic_quiz/calculated.json +24 -0
- data/spec/fixtures/sample_data/classic_quiz/essay.json +24 -0
- data/spec/fixtures/sample_data/classic_quiz/file_upload.json +24 -0
- data/spec/fixtures/sample_data/classic_quiz/fill_in_bank.json +24 -0
- data/spec/fixtures/sample_data/classic_quiz/fill_in_blank.json +53 -0
- data/spec/fixtures/sample_data/classic_quiz/fill_in_multiple_blanks.json +57 -0
- data/spec/fixtures/sample_data/classic_quiz/matching.json +107 -0
- data/spec/fixtures/sample_data/classic_quiz/multiple_answer.json +48 -0
- data/spec/fixtures/sample_data/classic_quiz/multiple_choice.json +56 -0
- data/spec/fixtures/sample_data/classic_quiz/multiple_dropdown.json +73 -0
- data/spec/fixtures/sample_data/classic_quiz/numerical.json +55 -0
- data/spec/fixtures/sample_data/classic_quiz/text.json +24 -0
- data/spec/fixtures/sample_data/classic_quiz/true_false.json +39 -0
- data/spec/fixtures/sample_data/classic_quiz/unknown.json +24 -0
- data/spec/fixtures/sample_data/new_quiz/bank_entry_item.json +5 -0
- data/spec/fixtures/sample_data/new_quiz/bank_item.json +11 -0
- data/spec/fixtures/sample_data/new_quiz/categorization.json +135 -0
- data/spec/fixtures/sample_data/new_quiz/essay.json +40 -0
- data/spec/fixtures/sample_data/new_quiz/file_upload.json +30 -0
- data/spec/fixtures/sample_data/new_quiz/fill_in_blank_association.json +162 -0
- data/spec/fixtures/sample_data/new_quiz/fill_in_blank_dropdown.json +180 -0
- data/spec/fixtures/sample_data/new_quiz/fill_in_blank_math.json +45 -0
- data/spec/fixtures/sample_data/new_quiz/fill_in_blank_mixed.json +188 -0
- data/spec/fixtures/sample_data/new_quiz/fill_in_blank_text.json +140 -0
- data/spec/fixtures/sample_data/new_quiz/formula.json +51 -0
- data/spec/fixtures/sample_data/new_quiz/hotspot_oval.json +38 -0
- data/spec/fixtures/sample_data/new_quiz/hotspot_polygon.json +66 -0
- data/spec/fixtures/sample_data/new_quiz/hotspot_square.json +38 -0
- data/spec/fixtures/sample_data/new_quiz/matching.json +88 -0
- data/spec/fixtures/sample_data/new_quiz/multiple_answer.json +57 -0
- data/spec/fixtures/sample_data/new_quiz/multiple_choice.json +66 -0
- data/spec/fixtures/sample_data/new_quiz/multiple_choice_math.json +55 -0
- data/spec/fixtures/sample_data/new_quiz/numerical.json +64 -0
- data/spec/fixtures/sample_data/new_quiz/ordering.json +60 -0
- data/spec/fixtures/sample_data/new_quiz/stimulus.json +17 -0
- data/spec/fixtures/sample_data/new_quiz/true_false.json +27 -0
- data/spec/fixtures/sample_data/new_quiz/unknown.json +14 -0
- data/spec/lib/ams_migration/services/learnosity_sink_spec.rb +198 -0
- data/spec/lib/canvas/converter/classic_quiz/calculated_spec.rb +26 -0
- data/spec/lib/canvas/converter/classic_quiz/essay_spec.rb +26 -0
- data/spec/lib/canvas/converter/classic_quiz/file_upload_spec.rb +23 -0
- data/spec/lib/canvas/converter/classic_quiz/fill_in_blank_spec.rb +30 -0
- data/spec/lib/canvas/converter/classic_quiz/fill_in_multiple_blanks_spec.rb +33 -0
- data/spec/lib/canvas/converter/classic_quiz/matching_spec.rb +38 -0
- data/spec/lib/canvas/converter/classic_quiz/multiple_answer_spec.rb +33 -0
- data/spec/lib/canvas/converter/classic_quiz/multiple_choice_spec.rb +31 -0
- data/spec/lib/canvas/converter/classic_quiz/multiple_dropdown_spec.rb +39 -0
- data/spec/lib/canvas/converter/classic_quiz/numerical_spec.rb +41 -0
- data/spec/lib/canvas/converter/classic_quiz/text_spec.rb +20 -0
- data/spec/lib/canvas/converter/classic_quiz/true_false_spec.rb +28 -0
- data/spec/lib/canvas/converter/classic_quiz/unknown_spec.rb +25 -0
- data/spec/lib/canvas/converter/common_error.rb +16 -0
- data/spec/lib/canvas/converter/new_quiz/categorization_spec.rb +55 -0
- data/spec/lib/canvas/converter/new_quiz/essay_spec.rb +26 -0
- data/spec/lib/canvas/converter/new_quiz/file_upload_spec.rb +24 -0
- data/spec/lib/canvas/converter/new_quiz/fill_in_blank_spec.rb +111 -0
- data/spec/lib/canvas/converter/new_quiz/formula_spec.rb +23 -0
- data/spec/lib/canvas/converter/new_quiz/hotspot_spec.rb +182 -0
- data/spec/lib/canvas/converter/new_quiz/matching_spec.rb +30 -0
- data/spec/lib/canvas/converter/new_quiz/multiple_answer_spec.rb +34 -0
- data/spec/lib/canvas/converter/new_quiz/multiple_choice_spec.rb +54 -0
- data/spec/lib/canvas/converter/new_quiz/numerical_spec.rb +48 -0
- data/spec/lib/canvas/converter/new_quiz/ordering_spec.rb +25 -0
- data/spec/lib/canvas/converter/new_quiz/stimulus_spec.rb +20 -0
- data/spec/lib/canvas/converter/new_quiz/true_false_spec.rb +24 -0
- data/spec/lib/canvas/converter/new_quiz/unknown_spec.rb +36 -0
- data/spec/lib/factories/classic_quiz_item_converter_factory_spec.rb +183 -0
- data/spec/lib/factories/new_quiz_item_converter_factory_spec.rb +71 -0
- data/spec/lib/services/learnosity_data_api_client_spec.rb +130 -0
- data/spec/lib/services/new_quiz_learnosity_transformer_spec.rb +196 -0
- data/spec/spec_helper.rb +20 -0
- 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
|