senkyoshi 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +92 -0
  4. data/bin/console +14 -0
  5. data/bin/import_blackboard +6 -0
  6. data/lib/senkyoshi/canvas_course.rb +179 -0
  7. data/lib/senkyoshi/collection.rb +26 -0
  8. data/lib/senkyoshi/config.rb +35 -0
  9. data/lib/senkyoshi/exceptions.rb +13 -0
  10. data/lib/senkyoshi/models/announcement.rb +36 -0
  11. data/lib/senkyoshi/models/answer.rb +32 -0
  12. data/lib/senkyoshi/models/assessment.rb +95 -0
  13. data/lib/senkyoshi/models/assignment.rb +26 -0
  14. data/lib/senkyoshi/models/assignment_group.rb +23 -0
  15. data/lib/senkyoshi/models/blog.rb +22 -0
  16. data/lib/senkyoshi/models/content.rb +92 -0
  17. data/lib/senkyoshi/models/content_file.rb +26 -0
  18. data/lib/senkyoshi/models/course.rb +39 -0
  19. data/lib/senkyoshi/models/file.rb +99 -0
  20. data/lib/senkyoshi/models/forum.rb +28 -0
  21. data/lib/senkyoshi/models/gradebook.rb +30 -0
  22. data/lib/senkyoshi/models/group.rb +22 -0
  23. data/lib/senkyoshi/models/module.rb +22 -0
  24. data/lib/senkyoshi/models/module_item.rb +23 -0
  25. data/lib/senkyoshi/models/question.rb +188 -0
  26. data/lib/senkyoshi/models/questions/calculated.rb +72 -0
  27. data/lib/senkyoshi/models/questions/either_or.rb +31 -0
  28. data/lib/senkyoshi/models/questions/essay.rb +11 -0
  29. data/lib/senkyoshi/models/questions/file_upload.rb +4 -0
  30. data/lib/senkyoshi/models/questions/fill_in_blank.rb +15 -0
  31. data/lib/senkyoshi/models/questions/fill_in_blank_plus.rb +20 -0
  32. data/lib/senkyoshi/models/questions/hot_spot.rb +11 -0
  33. data/lib/senkyoshi/models/questions/jumbled_sentence.rb +37 -0
  34. data/lib/senkyoshi/models/questions/matching.rb +57 -0
  35. data/lib/senkyoshi/models/questions/multiple_answer.rb +56 -0
  36. data/lib/senkyoshi/models/questions/multiple_choice.rb +18 -0
  37. data/lib/senkyoshi/models/questions/numerical.rb +31 -0
  38. data/lib/senkyoshi/models/questions/opinion_scale.rb +18 -0
  39. data/lib/senkyoshi/models/questions/ordering.rb +40 -0
  40. data/lib/senkyoshi/models/questions/quiz_bowl.rb +11 -0
  41. data/lib/senkyoshi/models/questions/short_response.rb +4 -0
  42. data/lib/senkyoshi/models/questions/true_false.rb +15 -0
  43. data/lib/senkyoshi/models/quiz.rb +7 -0
  44. data/lib/senkyoshi/models/resource.rb +33 -0
  45. data/lib/senkyoshi/models/scorm_package.rb +109 -0
  46. data/lib/senkyoshi/models/staff_info.rb +72 -0
  47. data/lib/senkyoshi/models/wikipage.rb +34 -0
  48. data/lib/senkyoshi/tasks.rb +90 -0
  49. data/lib/senkyoshi/version.rb +3 -0
  50. data/lib/senkyoshi/xml_parser.rb +154 -0
  51. data/lib/senkyoshi.rb +69 -0
  52. metadata +250 -0
@@ -0,0 +1,4 @@
1
+ module Senkyoshi
2
+ class FileUpload < Question
3
+ end
4
+ end
@@ -0,0 +1,15 @@
1
+ module Senkyoshi
2
+ class FillInBlank < Question
3
+ def iterate_xml(data)
4
+ super
5
+ conditionvar = data.at("resprocessing").at("conditionvar")
6
+ # not all fill in the blank questions have answers(ie: surveys)
7
+ if conditionvar
8
+ answer = Answer.new(conditionvar.at("varequal").text)
9
+ answer.fraction = @max_score
10
+ @answers.push(answer)
11
+ end
12
+ self
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,20 @@
1
+ module Senkyoshi
2
+ class FillInBlankPlus < Question
3
+ def iterate_xml(data)
4
+ super
5
+ conditionvar = data.at("resprocessing").at("conditionvar")
6
+ # not all fill in the blank questions have answers(ie: surveys)
7
+ if conditionvar
8
+ conditionvar.at("and").children.each do |or_child|
9
+ or_child.children.each do |varequal|
10
+ answer = Answer.new(varequal.text)
11
+ answer.resp_ident = varequal.attributes["respident"].value
12
+ answer.fraction = @max_score
13
+ @answers.push(answer)
14
+ end
15
+ end
16
+ end
17
+ self
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,11 @@
1
+ module Senkyoshi
2
+ class HotSpot < Question
3
+ def iterate_xml(data)
4
+ super
5
+ @material = "#{@title} -- This question was imported from an
6
+ external source. It was a #{@blackboard_type} question, which
7
+ is not supported in this quiz tool."
8
+ self
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,37 @@
1
+ module Senkyoshi
2
+ class JumbledSentence < Question
3
+ def initialize
4
+ @responses = []
5
+ super
6
+ end
7
+
8
+ def iterate_xml(data)
9
+ super
10
+ if response_block = data.at("flow[@class=RESPONSE_BLOCK]")
11
+ choices = []
12
+ response_block.at("flow_label").children.each do |response|
13
+ text = response.at("mattext").text
14
+ choices << { id: response.attributes["ident"], text: text }
15
+ end
16
+ set_answers(data.at("resprocessing"))
17
+ correct = data.at("respcondition[title=correct]")
18
+ correct.at("and").children.each do |answer_element|
19
+ id = answer_element.text
20
+ response_label = data.at("response_label[ident='#{id}']")
21
+ answer_text = response_label.at("mattext").text
22
+ answer = Answer.new(answer_text, id)
23
+ resp_ident = answer_element.attributes["respident"].value
24
+ answer.resp_ident = resp_ident
25
+ @responses << { id: resp_ident, choices: choices }
26
+ @answers.push(answer)
27
+ end
28
+ end
29
+ self
30
+ end
31
+
32
+ def canvas_conversion(assessment, _resources = nil)
33
+ @question.responses = @responses
34
+ super
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,57 @@
1
+ module Senkyoshi
2
+ class Matching < Question
3
+ def initialize
4
+ super
5
+ @matches = []
6
+ @matching_answers = {}
7
+ end
8
+
9
+ def iterate_xml(data)
10
+ super
11
+ resprocessing = data.at("resprocessing")
12
+ @matching_answers = set_matching_answers(resprocessing)
13
+ matches_array = []
14
+ if match_block = data.at("flow[@class=RIGHT_MATCH_BLOCK]")
15
+ matches_array = match_block.children.map do |match|
16
+ match.at("mat_formattedtext").text
17
+ end
18
+ end
19
+ if response_block = data.at("flow[@class=RESPONSE_BLOCK]")
20
+ response_block.children.each do |response|
21
+ id = response.at("response_lid").attributes["ident"].value
22
+ question = response.at("mat_formattedtext").text
23
+ answer_id = @matching_answers[id]
24
+ answer = ""
25
+ flow_label = response.at("flow_label")
26
+ flow_label.children.each_with_index do |label, index|
27
+ if label.attributes["ident"].value == answer_id
28
+ answer = matches_array[index]
29
+ end
30
+ end
31
+ @matches << { id: id, question_text: question, answer_text: answer }
32
+ end
33
+ end
34
+ self
35
+ end
36
+
37
+ def canvas_conversion(assessment, _resources = nil)
38
+ @question.matches = @matches
39
+ super
40
+ end
41
+
42
+ def set_matching_answers(resprocessing)
43
+ matching_answers = {}
44
+ respcondition = resprocessing.css("respcondition")
45
+ respcondition.each do |condition|
46
+ if condition.attributes["title"] != "incorrect"
47
+ varequal = condition.at("varequal")
48
+ if varequal
49
+ id = varequal.attributes["respident"].value
50
+ matching_answers[id] = varequal.text
51
+ end
52
+ end
53
+ end
54
+ matching_answers
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,56 @@
1
+ module Senkyoshi
2
+ class MultipleAnswer < Question
3
+ def iterate_xml(data)
4
+ super
5
+ if response_block = data.at("flow[@class=RESPONSE_BLOCK]")
6
+ set_answers(data.at("resprocessing"))
7
+ response_block.at("render_choice").children.each do |choice|
8
+ id = choice.at("response_label").attributes["ident"].value
9
+ @answer_text = choice.at("mat_formattedtext").text
10
+ answer = Answer.new(@answer_text, id)
11
+ answer.fraction = get_fraction(id)
12
+ @answers.push(answer)
13
+ end
14
+ end
15
+ self
16
+ end
17
+
18
+ def set_answers(resprocessing)
19
+ @correct_answers = set_correct_answer(resprocessing)
20
+ @incorrect_answers = set_incorrect_answer(resprocessing)
21
+ end
22
+
23
+ def set_correct_answer(resprocessing)
24
+ correct_answers = {}
25
+ correct = resprocessing.at("respcondition[title=correct]")
26
+ if correct
27
+ correct.at("and").children.each do |answer|
28
+ if answer.name == "varequal"
29
+ id = answer.text
30
+ correct_answers[id] = {}
31
+ correct_answers[id]["name"] = id
32
+ if correct.at("setvar")
33
+ score = correct.at("setvar").text
34
+ score_number = score == "SCORE.max" ? @max_score.to_f : score.to_f
35
+ correct_answers[id]["fraction"] = score_number
36
+ else
37
+ correct_answers[id]["fraction"] = 0
38
+ end
39
+ end
40
+ end
41
+ end
42
+ correct_answers
43
+ end
44
+
45
+ def set_incorrect_answer(resprocessing)
46
+ incorrect = resprocessing.at("respcondition[ident=incorrect]")
47
+ incorrect_answers = {}
48
+ if incorrect && incorrect.at("setvar")
49
+ incorrect_answers["fraction"] = incorrect.at("setvar").text
50
+ else
51
+ incorrect_answers["fraction"] = 0
52
+ end
53
+ incorrect_answers
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,18 @@
1
+ module Senkyoshi
2
+ class MultipleChoice < Question
3
+ def iterate_xml(data)
4
+ super
5
+ if response_block = data.at("flow[@class=RESPONSE_BLOCK]")
6
+ set_answers(data)
7
+ response_block.at("render_choice").children.each do |choice|
8
+ id = choice.at("response_label").attributes["ident"].value
9
+ answer_text = choice.at("mat_formattedtext").text
10
+ answer = Answer.new(answer_text, id)
11
+ answer.fraction = get_fraction(id)
12
+ @answers.push(answer)
13
+ end
14
+ end
15
+ self
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,31 @@
1
+ module Senkyoshi
2
+ class NumericalQuestion < Question
3
+ def initialize
4
+ @ranges = {}
5
+ @tolerances = {}
6
+ super
7
+ end
8
+
9
+ def iterate_xml(data)
10
+ super
11
+ conditionvar = data.at("resprocessing").at("conditionvar")
12
+ if conditionvar
13
+ range = CanvasCc::CanvasCC::Models::Range.new
14
+ range.low_range = conditionvar.at("vargte").text.to_i
15
+ range.high_range = conditionvar.at("varlte").text.to_i
16
+ answer_text = conditionvar.at("varequal").text.to_i
17
+ answer = Answer.new(answer_text)
18
+ @ranges[answer.id] = range
19
+ answer.fraction = @max_score
20
+ @answers.push(answer)
21
+ end
22
+ self
23
+ end
24
+
25
+ def canvas_conversion(assessment, _resources = nil)
26
+ @question.tolerances = @tolerances
27
+ @question.ranges = @ranges
28
+ super
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,18 @@
1
+ module Senkyoshi
2
+ class OpinionScale < Question
3
+ def iterate_xml(data)
4
+ super
5
+ if response_block = data.at("flow[@class=RESPONSE_BLOCK]")
6
+ set_answers(data.at("resprocessing"))
7
+ response_block.at("render_choice").children.each do |choice|
8
+ id = choice.at("response_label").attributes["ident"].value
9
+ @answer_text = choice.at("mat_formattedtext").text
10
+ answer = Answer.new(@answer_text, id)
11
+ answer.fraction = get_fraction(id)
12
+ @answers.push(answer)
13
+ end
14
+ end
15
+ self
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,40 @@
1
+ module Senkyoshi
2
+ class Ordering < Question
3
+ def initialize
4
+ super
5
+ @matches = []
6
+ @order_answers = {}
7
+ end
8
+
9
+ def iterate_xml(data)
10
+ super
11
+ resprocessing = data.at("resprocessing")
12
+ @order_answers = set_order_answers(resprocessing)
13
+ if response_block = data.at("flow[@class=RESPONSE_BLOCK]")
14
+ response_block.at("render_choice").children.each do |choice|
15
+ id = choice.at("response_label").attributes["ident"].value
16
+ question = @order_answers[id].to_s
17
+ answer = choice.at("mat_formattedtext").text
18
+ @matches << { id: id, question_text: question, answer_text: answer }
19
+ end
20
+ @matches = @matches.sort_by { |hsh| hsh[:question_text] }
21
+ end
22
+ self
23
+ end
24
+
25
+ def canvas_conversion(assessment, _resources = nil)
26
+ @question.matches = @matches
27
+ super
28
+ end
29
+
30
+ def set_order_answers(resprocessing)
31
+ order_answers = {}
32
+ correct = resprocessing.at("respcondition[title=correct]")
33
+ correct.at("and").children.each_with_index do |varequal, index|
34
+ id = varequal.text
35
+ order_answers[id] = index + 1
36
+ end
37
+ order_answers
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,11 @@
1
+ module Senkyoshi
2
+ class QuizBowl < Question
3
+ def iterate_xml(data)
4
+ super
5
+ @material = "#{@title} -- This question was imported from
6
+ an external source. It was a #{@blackboard_type} question,
7
+ which is not supported in this quiz tool."
8
+ self
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,4 @@
1
+ module Senkyoshi
2
+ class ShortResponse < Question
3
+ end
4
+ end
@@ -0,0 +1,15 @@
1
+ module Senkyoshi
2
+ class TrueFalse < Question
3
+ def iterate_xml(data)
4
+ super
5
+ answers_array = [true, false]
6
+ set_answers(data.at("resprocessing"))
7
+ answers_array.each do |answer_text|
8
+ answer = Answer.new(answer_text)
9
+ answer.fraction = get_fraction(answer_text)
10
+ @answers.push(answer)
11
+ end
12
+ self
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,7 @@
1
+ module Senkyoshi
2
+ class Quiz < Content
3
+ def canvas_conversion(course, _resource)
4
+ create_module(course)
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,33 @@
1
+ module Senkyoshi
2
+ class Resource
3
+ def fix_html(contents, resources)
4
+ if contents && contents.respond_to?(:empty?) && !contents.empty?
5
+ node_html = Nokogiri::HTML.fragment(contents)
6
+
7
+ _search_and_replace(resources, node_html, "a", "href")
8
+ _search_and_replace(resources, node_html, "img", "src")
9
+
10
+ node_html.to_s
11
+ else
12
+ contents
13
+ end
14
+ end
15
+
16
+ def matches_xid?(_xid)
17
+ false
18
+ end
19
+
20
+ def _search_and_replace(resources, node_html, tag, attr)
21
+ node_html.search(tag).each do |element|
22
+ original_src = element[attr]
23
+ xid = original_src.split("/").last
24
+ file_resource = resources.detect_xid(xid)
25
+
26
+ if file_resource
27
+ name = file_resource.name
28
+ element[attr] = "#{BASE}/#{IMPORTED_FILES_DIRNAME}/#{name}"
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,109 @@
1
+ module Senkyoshi
2
+ class ScormPackage
3
+ attr_accessor(:entries, :manifest)
4
+
5
+ ##
6
+ # Scorm packages should include this string in the <schema> tag. We
7
+ # downcase, and remove spaces before checking to see if a manifest contains
8
+ # this schema to determine whether or not it belongs to a scorm package
9
+ ##
10
+ SCORM_SCHEMA = "adlscorm".freeze
11
+
12
+ def initialize(zip_file, manifest)
13
+ @manifest = manifest
14
+ @entries = ScormPackage.get_entries zip_file, manifest
15
+ end
16
+
17
+ ##
18
+ # Returns true if a manifest is a scorm manifest file, false otherwise
19
+ ##
20
+ def self.scorm_manifest?(manifest)
21
+ parsed_manifest = Nokogiri::XML(manifest.get_input_stream.read)
22
+ schema_name = parsed_manifest.
23
+ xpath("//xmlns:metadata/xmlns:schema").
24
+ text.delete(" ").downcase
25
+ return schema_name == SCORM_SCHEMA
26
+ # NOTE we occasionally run into malformed manifest files
27
+ rescue Nokogiri::XML::XPath::SyntaxError => e
28
+ filename = manifest.zipfile
29
+ STDERR.puts "Malformed scorm manifest found: #{manifest} in #{filename}"
30
+ STDERR.puts e.to_s
31
+ false
32
+ end
33
+
34
+ ##
35
+ # Extracts scorm packages from a blackboard export zip file
36
+ ##
37
+ def self.get_scorm_packages(blackboard_export)
38
+ find_scorm_manifests(blackboard_export).map do |manifest|
39
+ ScormPackage.new blackboard_export, manifest
40
+ end
41
+ end
42
+
43
+ ##
44
+ # Returns array of all scorm manifest files inside of blackboard export
45
+ ##
46
+ def self.find_scorm_manifests(zip_file)
47
+ return [] if zip_file.nil?
48
+ zip_file.entries.select do |e|
49
+ File.fnmatch("*imsmanifest.xml", e.name) && scorm_manifest?(e)
50
+ end
51
+ end
52
+
53
+ ##
54
+ # Returns array of paths to scorm packages
55
+ ##
56
+ def self.find_scorm_paths(zip_file)
57
+ manifests = ScormPackage.find_scorm_manifests(zip_file)
58
+ manifests.map { |manifest| File.dirname(manifest.name) }
59
+ end
60
+
61
+ ##
62
+ # Returns array of all zip file entries that belong in scorm package
63
+ ##
64
+ def self.get_entries(zip_file, manifest)
65
+ zip_file.entries.select do |e|
66
+ File.dirname(e.name).start_with?(File.dirname(manifest.name)) &&
67
+ !e.directory?
68
+ end
69
+ end
70
+
71
+ ##
72
+ # Returns file path with relative path to scorm package removed
73
+ ##
74
+ def self.correct_path(path, scorm_path)
75
+ corrected = path.gsub(scorm_path, "")
76
+ corrected.slice(1, corrected.size) if corrected.start_with? "/"
77
+ end
78
+
79
+ ##
80
+ # Writes all entries to a zip file in a temporary directory and returns
81
+ # location of temporary file
82
+ ##
83
+ def write_zip(export_name)
84
+ @@dir ||= Dir.mktmpdir
85
+ scorm_path = File.dirname @manifest.name
86
+ path = "#{@@dir}/#{export_name}"
87
+ Zip::File.open path, Zip::File::CREATE do |zip|
88
+ @entries.each do |entry|
89
+ if entry.file?
90
+ zip.get_output_stream(
91
+ ScormPackage.correct_path(entry.name, scorm_path),
92
+ ) do |file|
93
+ file.write(entry.get_input_stream.read)
94
+ end
95
+ end
96
+ end
97
+ end
98
+ path
99
+ end
100
+
101
+ ##
102
+ # Removes all temp files if they exist
103
+ ##
104
+ def self.cleanup
105
+ @@dir ||= nil
106
+ FileUtils.rm_r @@dir unless @@dir.nil?
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,72 @@
1
+ require "senkyoshi/models/resource"
2
+
3
+ module Senkyoshi
4
+ class StaffInfo < Resource
5
+ attr_reader(
6
+ :id,
7
+ :title,
8
+ :bio,
9
+ :name,
10
+ :email,
11
+ :phone,
12
+ :office_hours,
13
+ :office_address,
14
+ :home_page,
15
+ :image,
16
+ )
17
+
18
+ def parse_name(contact)
19
+ parts = [
20
+ contact.xpath("./NAME/FORMALTITLE/@value").text,
21
+ contact.xpath("./NAME/GIVEN/@value").text,
22
+ contact.xpath("./NAME/FAMILY/@value").text,
23
+ ]
24
+
25
+ resp = ""
26
+ parts.each do |part|
27
+ resp << " " unless resp.empty?
28
+ resp << part unless part.empty?
29
+ end
30
+ resp
31
+ end
32
+
33
+ def iterate_xml(xml, _pre_data)
34
+ contact = xml.xpath("//CONTACT")
35
+ @id = xml.xpath("//STAFFINFO/@id").text
36
+ @title = xml.xpath("//STAFFINFO/TITLE/@value").text
37
+ @bio = xml.xpath("//BIOGRAPHY/TEXT").text
38
+ @name = parse_name(contact)
39
+ @email = xml.xpath("//CONTACT/EMAIL/@value").text
40
+ @phone = xml.xpath("//CONTACT/PHONE/@value").text
41
+ @office_hours = xml.xpath("//OFFICE/HOURS/@value").text
42
+ @office_address = xml.xpath("//OFFICE/ADDRESS/@value").text
43
+ @home_page = xml.xpath("//HOMEPAGE/@value").text
44
+ @image = xml.xpath("//IMAGE/@value").text
45
+
46
+ self
47
+ end
48
+
49
+ def construct_body
50
+ <<-HTML
51
+ <h3>#{@name}</h3>
52
+ <p>#{@bio}</p>
53
+ <ul>
54
+ <li>Email: #{@email}</li>
55
+ <li>Phone: #{@phone}</li>
56
+ <li>Office Hours: #{@office_hours}</li>
57
+ <li>Office Address: #{@office_address}</li>
58
+ </ul>
59
+ HTML
60
+ end
61
+
62
+ def canvas_conversion(course, _resources = nil)
63
+ page = CanvasCc::CanvasCC::Models::Page.new
64
+ page.body = construct_body
65
+ page.identifier = @id
66
+ page.page_name = @title.empty? ? "Contact" : @title
67
+
68
+ course.pages << page
69
+ course
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,34 @@
1
+ require "senkyoshi/models/resource"
2
+
3
+ module Senkyoshi
4
+ class WikiPage < Content
5
+ def canvas_conversion(course, resources)
6
+ unless @title == "--TOP--"
7
+ page_count = course.pages.
8
+ select { |p| p.title.start_with? @title }.count
9
+ @title = "#{@title}-#{page_count + 1}" if page_count > 0
10
+ page = CanvasCc::CanvasCC::Models::Page.new
11
+ if !@url.empty?
12
+ @body = %{
13
+ <a href="#{@url}">
14
+ #{@url}
15
+ </a>
16
+ #{@body}
17
+ }
18
+ end
19
+ page.body = fix_html(@body, resources)
20
+ page.identifier = @id
21
+ page.page_name = @title
22
+ page.workflow_state = "active"
23
+
24
+ # Add page links to page body
25
+ @files.each { |f| page.body << f.canvas_conversion }
26
+ course.pages << page
27
+
28
+ course = create_module(course)
29
+ end
30
+
31
+ course
32
+ end
33
+ end
34
+ end