Skip to content

Commit cb10625

Browse files
committed
export CC 1.3 assignment extension
test plan: - have an assignment in a course that has points possible and submission types - use the content exports API to export the course, supplying the currently undocumented parameter version=1.3 a) examine the exported XML and verify that it follows the IMS standards http://www.imsglobal.org/cc/ccv1p3/AssignmentContentType.html http://www.imsglobal.org/cc/ccv1p3/CCv1p3Variantguidelines.pdf b) ensure the exported course re-imports correctly fixes CNVS-13503 Change-Id: Iad8a54e94696962d362cfdb48fe03e06a90937ff Reviewed-on: https://gerrit.instructure.com/36283 Tested-by: Jenkins <jenkins@instructure.com> Reviewed-by: James Williams <jamesw@instructure.com> QA-Review: Clare Strong <clare@instructure.com> Product-Review: Bracken Mosbacker <bracken@instructure.com>
1 parent a8d0213 commit cb10625

11 files changed

Lines changed: 191 additions & 30 deletions

app/controllers/content_exports_api_controller.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,9 +138,10 @@ def create
138138
export.export_type = ContentExport::COMMON_CARTRIDGE
139139
export.selected_content = { everything: true }
140140
end
141+
opts = params.slice(:version)
141142
export.progress = 0
142143
if export.save
143-
export.queue_api_job
144+
export.queue_api_job(opts)
144145
render json: content_export_json(export, @current_user, session)
145146
else
146147
render json: export.errors, status: :bad_request

app/models/content_export.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ def export_course(opts={})
9292
end
9393
handle_asynchronously :export_course, :priority => Delayed::LOW_PRIORITY, :max_attempts => 1
9494

95-
def queue_api_job
95+
def queue_api_job(opts)
9696
if self.job_progress
9797
p = self.job_progress
9898
else
@@ -104,7 +104,7 @@ def queue_api_job
104104
p.user = self.user
105105
p.save!
106106

107-
export_course
107+
export_course(opts)
108108
end
109109

110110
def referenced_files

lib/cc/assignment_resources.rb

Lines changed: 86 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,23 +37,67 @@ def add_assignments
3737
end
3838
end
3939

40+
VERSION_1_3 = Gem::Version.new('1.3')
41+
4042
def add_assignment(assignment)
4143
migration_id = CCHelper.create_key(assignment)
4244

4345
lo_folder = File.join(@export_dir, migration_id)
4446
FileUtils::mkdir_p lo_folder
4547

4648
file_name = "#{assignment.title.to_url}.html"
47-
relative_path = File.join(migration_id, file_name)
4849
path = File.join(lo_folder, file_name)
50+
html_path = File.join(migration_id, file_name)
4951

5052
# Write the assignment description as an .html file
51-
# That way at least the content of the assignment will
52-
# appear when someone non-canvas imports the package
53+
# That way at least the content of the assignment will appear
54+
# for agents that support neither CC 1.3 nor Canvas assignments
5355
File.open(path, 'w') do |file|
5456
file << @html_exporter.html_page(assignment.description || '', "Assignment: " + assignment.title)
5557
end
5658

59+
if Gem::Version.new(@manifest.cc_version) >= VERSION_1_3
60+
add_cc_assignment(assignment, migration_id, lo_folder, html_path)
61+
else
62+
add_canvas_assignment(assignment, migration_id, lo_folder, html_path)
63+
end
64+
end
65+
66+
def add_cc_assignment(assignment, migration_id, lo_folder, html_path)
67+
File.open(File.join(lo_folder, CCHelper::ASSIGNMENT_XML), 'w') do |assignment_file|
68+
document = Builder::XmlMarkup.new(:target => assignment_file, :indent => 2)
69+
document.instruct!
70+
71+
document.assignment("identifier" => migration_id,
72+
"xmlns" => CCHelper::ASSIGNMENT_NAMESPACE,
73+
"xmlns:xsi"=>"http://www.w3.org/2001/XMLSchema-instance",
74+
"xsi:schemaLocation"=> "#{CCHelper::ASSIGNMENT_NAMESPACE} #{CCHelper::ASSIGNMENT_XSD_URI}"
75+
) do |a|
76+
AssignmentResources.create_cc_assignment(a, assignment, migration_id)
77+
end
78+
end
79+
80+
xml_path = File.join(migration_id, CCHelper::ASSIGNMENT_XML)
81+
@resources.resource(:identifier => migration_id,
82+
:type => CCHelper::ASSIGNMENT_TYPE,
83+
:href => xml_path
84+
) do |res|
85+
res.file(:href => xml_path)
86+
end
87+
88+
@resources.resource(:identifier => migration_id + "_fallback",
89+
:type => CCHelper::WEBCONTENT
90+
) do |res|
91+
res.tag!('cpx:variant', :identifier => migration_id + "_variant",
92+
:identifierref => migration_id
93+
) do |var|
94+
var.tag!('cpx:metadata')
95+
end
96+
res.file(:href => html_path)
97+
end
98+
end
99+
100+
def add_canvas_assignment(assignment, migration_id, lo_folder, html_path)
57101
assignment_file = File.new(File.join(lo_folder, CCHelper::ASSIGNMENT_SETTINGS), 'w')
58102
document = Builder::XmlMarkup.new(:target=>assignment_file, :indent=>2)
59103
document.instruct!
@@ -64,21 +108,53 @@ def add_assignment(assignment)
64108
"xmlns:xsi"=>"http://www.w3.org/2001/XMLSchema-instance",
65109
"xsi:schemaLocation"=> "#{CCHelper::CANVAS_NAMESPACE} #{CCHelper::XSD_URI}"
66110
) do |a|
67-
AssignmentResources.create_assignment(a, assignment)
111+
AssignmentResources.create_canvas_assignment(a, assignment)
68112
end
69113
assignment_file.close
70114

71115
@resources.resource(
72-
:identifier => migration_id,
73-
"type" => CCHelper::LOR,
74-
:href => relative_path
116+
:identifier => migration_id,
117+
"type" => CCHelper::LOR,
118+
:href => html_path
75119
) do |res|
76-
res.file(:href=>relative_path)
120+
res.file(:href=>html_path)
77121
res.file(:href=>File.join(migration_id, CCHelper::ASSIGNMENT_SETTINGS))
78122
end
79123
end
80-
81-
def self.create_assignment(node, assignment)
124+
125+
SUBMISSION_TYPE_MAP = {
126+
"online_text_entry" => "html",
127+
"online_url" => "url",
128+
"online_upload" => "file"
129+
}.freeze
130+
131+
def self.create_cc_assignment(node, assignment, migration_id)
132+
node.title(assignment.title)
133+
node.text(assignment.description, texttype: 'text/html')
134+
if assignment.points_possible
135+
node.gradable(assignment.graded?, points_possible: assignment.points_possible)
136+
else
137+
node.gradable(assignment.graded?)
138+
end
139+
node.submission_formats do |fmt|
140+
assignment.submission_types.split(',').each do |st|
141+
if cc_type = SUBMISSION_TYPE_MAP[st]
142+
fmt.format(:type => cc_type)
143+
end
144+
end
145+
end
146+
node.extensions do |ext|
147+
ext.assignment("identifier" => migration_id + "_canvas",
148+
"xmlns" => CCHelper::CANVAS_NAMESPACE,
149+
"xmlns:xsi"=>"http://www.w3.org/2001/XMLSchema-instance",
150+
"xsi:schemaLocation"=> "#{CCHelper::CANVAS_NAMESPACE} #{CCHelper::XSD_URI}"
151+
) do |a|
152+
AssignmentResources.create_canvas_assignment(a, assignment)
153+
end
154+
end
155+
end
156+
157+
def self.create_canvas_assignment(node, assignment)
82158
node.title assignment.title
83159
node.due_at CCHelper::ims_datetime(assignment.due_at) if assignment.due_at
84160
node.lock_at CCHelper::ims_datetime(assignment.lock_at) if assignment.lock_at

lib/cc/cc_exporter.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ def initialize(content_export, opts={})
3939
@migration_config ||= {:keep_after_complete => false}
4040
@for_course_copy = opts[:for_course_copy]
4141
@qti_only_export = @content_export && @content_export.qti_export?
42+
@manifest_opts = opts.slice(:version)
4243
end
4344

4445
def self.export(content_export, opts={})
@@ -53,7 +54,7 @@ def export
5354
if @qti_only_export
5455
@manifest = CC::QTI::QTIManifest.new(self)
5556
else
56-
@manifest = Manifest.new(self)
57+
@manifest = Manifest.new(self, @manifest_opts)
5758
end
5859
@manifest.create_document
5960
@manifest.close

lib/cc/cc_helper.rb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ module CCHelper
5353
# imsqti_xmlv1p2/imscc_xmlv1p2/question-bank
5454
# imsbasiclti_xmlv1p0
5555

56+
# Common Cartridge 1.3
57+
ASSIGNMENT_TYPE = "assignment_xmlv1p0"
58+
ASSIGNMENT_NAMESPACE = "http://www.imsglobal.org/xsd/imscc_extensions/assignment"
59+
ASSIGNMENT_XSD_URI = "http://www.imsglobal.org/profile/cc/cc_extensions/cc_extresource_assignmentv1p0_v1p0.xsd"
60+
5661
# QTI-only export
5762
QTI_ASSESSMENT_TYPE = 'imsqti_xmlv1p2'
5863

@@ -85,7 +90,8 @@ module CCHelper
8590
MEDIA_OBJECTS_FOLDER = 'media_objects'
8691
CANVAS_EXPORT_FLAG = 'canvas_export.txt'
8792
MEDIA_TRACKS = 'media_tracks.xml'
88-
93+
ASSIGNMENT_XML = 'assignment.xml'
94+
8995
def create_key(object, prepend="")
9096
CCHelper.create_key(object, prepend)
9197
end

lib/cc/manifest.rb

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,16 @@ class Manifest
2020
include CCHelper
2121

2222
attr_accessor :exporter, :weblinks, :basic_ltis
23+
attr_reader :options
2324
delegate :add_error, :set_progress, :export_object?, :export_symbol?, :for_course_copy, :add_item_to_export, :user, :to => :exporter
2425

25-
def initialize(exporter)
26+
def initialize(exporter, opts = {})
2627
@exporter = exporter
2728
@file = nil
2829
@document = nil
2930
@resource = nil
3031
@weblinks = []
32+
@options = opts
3133
end
3234

3335
def course
@@ -52,13 +54,7 @@ def create_document
5254
@file = File.new(File.join(export_dir, MANIFEST), 'w')
5355
@document = Builder::XmlMarkup.new(:target=>@file, :indent=>2)
5456
@document.instruct!
55-
@document.manifest("identifier" => create_key(course, "common_cartridge_"),
56-
"xmlns" => "http://www.imsglobal.org/xsd/imsccv1p1/imscp_v1p1",
57-
"xmlns:lom"=>"http://ltsc.ieee.org/xsd/imsccv1p1/LOM/resource",
58-
"xmlns:lomimscc"=>"http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest",
59-
"xmlns:xsi"=>"http://www.w3.org/2001/XMLSchema-instance",
60-
"xsi:schemaLocation"=>"http://www.imsglobal.org/xsd/imsccv1p1/imscp_v1p1 http://www.imsglobal.org/profile/cc/ccv1p1/ccv1p1_imscp_v1p2_v1p0.xsd http://ltsc.ieee.org/xsd/imsccv1p1/LOM/resource http://www.imsglobal.org/profile/cc/ccv1p1/LOM/ccv1p1_lomresource_v1p0.xsd http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest http://www.imsglobal.org/profile/cc/ccv1p1/LOM/ccv1p1_lommanifest_v1p0.xsd"
61-
) do |manifest_node|
57+
@document.manifest({"identifier" => create_key(course, "common_cartridge_")}.merge(namespace_hash)) do |manifest_node|
6258

6359
manifest_node.metadata do |md|
6460
create_metadata(md)
@@ -94,7 +90,7 @@ def referenced_files
9490

9591
def create_metadata(md)
9692
md.schema "IMS Common Cartridge"
97-
md.schemaversion "1.1.0"
93+
md.schemaversion cc_version
9894
md.lomimscc :lom do |lom|
9995
lom.lomimscc :general do |general|
10096
general.lomimscc :title do |title|
@@ -118,5 +114,36 @@ def create_metadata(md)
118114
end
119115
end
120116
end
117+
118+
def cc_version
119+
@cc_version ||= case @options[:version]
120+
when "1.3"
121+
"1.3.0"
122+
else
123+
"1.1.0"
124+
end
125+
end
126+
127+
def namespace_hash
128+
@namespace_hash ||= if cc_version == "1.3.0"
129+
{
130+
"xmlns" => "http://www.imsglobal.org/xsd/imsccv1p3/imscp_v1p1",
131+
"xmlns:lom" => "http://ltsc.ieee.org/xsd/imsccv1p3/LOM/resource",
132+
"xmlns:lomimscc" => "http://ltsc.ieee.org/xsd/imsccv1p3/LOM/manifest",
133+
"xmlns:cpx" => "http://www.imsglobal.org/xsd/imsccv1p3/imscp_extensionv1p2",
134+
"xmlns:xsi" => "http://www.w3.org/2001/XMLSchema-instance",
135+
"xsi:schemaLocation" => "http://ltsc.ieee.org/xsd/imsccv1p3/LOM/resource http://www.imsglobal.org/profile/cc/ccv1p3/LOM/ccv1p3_lomresource_v1p0.xsd http://www.imsglobal.org/xsd/imsccv1p3/imscp_v1p1 http://www.imsglobal.org/profile/cc/ccv1p3/ccv1p3_imscp_v1p2_v1p0.xsd http://ltsc.ieee.org/xsd/imsccv1p3/LOM/manifest http://www.imsglobal.org/profile/cc/ccv1p3/LOM/ccv1p3_lommanifest_v1p0.xsd http://www.imsglobal.org/xsd/imsccv1p3/imscp_extensionv1p2 http://www.imsglobal.org/profile/cc/ccv1p3/ccv1p3_cpextensionv1p2_v1p0.xsd"
136+
}.freeze
137+
else
138+
{
139+
"xmlns" => "http://www.imsglobal.org/xsd/imsccv1p1/imscp_v1p1",
140+
"xmlns:lom"=>"http://ltsc.ieee.org/xsd/imsccv1p1/LOM/resource",
141+
"xmlns:lomimscc"=>"http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest",
142+
"xmlns:xsi"=>"http://www.w3.org/2001/XMLSchema-instance",
143+
"xsi:schemaLocation"=>"http://www.imsglobal.org/xsd/imsccv1p1/imscp_v1p1 http://www.imsglobal.org/profile/cc/ccv1p1/ccv1p1_imscp_v1p2_v1p0.xsd http://ltsc.ieee.org/xsd/imsccv1p1/LOM/resource http://www.imsglobal.org/profile/cc/ccv1p1/LOM/ccv1p1_lomresource_v1p0.xsd http://ltsc.ieee.org/xsd/imsccv1p1/LOM/manifest http://www.imsglobal.org/profile/cc/ccv1p1/LOM/ccv1p1_lommanifest_v1p0.xsd"
144+
}.freeze
145+
end
146+
end
147+
121148
end
122149
end

lib/cc/qti/qti_generator.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ def generate_assessment_meta(doc, quiz, migration_id)
196196
if quiz.assignment && !quiz.assignment.deleted?
197197
assignment_migration_id = CCHelper.create_key(quiz.assignment)
198198
doc.assignment(:identifier=>assignment_migration_id) do |a|
199-
AssignmentResources.create_assignment(a, quiz.assignment)
199+
AssignmentResources.create_canvas_assignment(a, quiz.assignment)
200200
end
201201
end
202202
if quiz.assignment_group_id

lib/cc/topic_resources.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ def create_canvas_topic(doc, topic)
122122
if topic.assignment && !topic.assignment.deleted?
123123
assignment_migration_id = CCHelper.create_key(topic.assignment)
124124
doc.assignment(:identifier=>assignment_migration_id) do |a|
125-
AssignmentResources.create_assignment(a, topic.assignment)
125+
AssignmentResources.create_canvas_assignment(a, topic.assignment)
126126
end
127127
end
128128
end

spec/apis/v1/content_exports_api_spec.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,5 +184,19 @@ def course_copy_export
184184
export.attachment.should_not be_nil
185185
end
186186

187+
it "should create a 1.3 common cartridge if specified" do
188+
t_course.assignments.create! name: 'teh assignment', description: '<b>what</b>', points_possible: 11, submission_types: 'online_text_entry'
189+
json = api_call_as_user(t_teacher, :post, "/api/v1/courses/#{t_course.id}/content_exports?export_type=common_cartridge&version=1.3",
190+
{ controller: 'content_exports_api', action: 'create', format: 'json', course_id: t_course.to_param, export_type: 'common_cartridge',
191+
version: '1.3' })
192+
export = t_course.content_exports.find(json['id'])
193+
run_jobs
194+
f = export.reload.attachment.open(need_local_file: true)
195+
Zip::File.open(f.path) do |zf|
196+
doc = Nokogiri::XML(zf.read('imsmanifest.xml'))
197+
doc.at_css('metadata schemaversion').text.should == '1.3.0'
198+
end
199+
end
200+
187201
end
188202
end

spec/lib/cc/cc_exporter_spec.rb

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@
3737
end
3838
end
3939

40-
def run_export
41-
@ce.export_course_without_send_later
40+
def run_export(opts = {})
41+
@ce.export_course_without_send_later(opts)
4242
@ce.error_messages.should == []
4343
@file_handle = @ce.attachment.open :need_local_file => true
4444
@zip_file = Zip::File.open(@file_handle.path)
@@ -431,5 +431,41 @@ def check_resource_node(obj, type, selected=true)
431431
track_doc = Nokogiri::XML(@zip_file.read('course_settings/media_tracks.xml'))
432432
track_doc.at_css('media_tracks media track[locale=tlh][kind=subtitles][identifierref=id4164d7d594985594573e63f8ca15975]').should be_present
433433
end
434+
435+
it "should export CC 1.3 assignments" do
436+
@course.assignments.create! name: 'test assignment', description: '<em>what?</em>', points_possible: 11,
437+
submission_types: 'online_text_entry,online_upload,online_url'
438+
@ce.export_type = ContentExport::COMMON_CARTRIDGE
439+
@ce.save!
440+
run_export(version: '1.3')
441+
@manifest_doc.at_css('metadata schemaversion').text.should eql('1.3.0')
442+
443+
# validate assignment manifest resource
444+
assignment_resource = @manifest_doc.at_css("resource[type='assignment_xmlv1p0']")
445+
assignment_id = assignment_resource.attribute('identifier').value
446+
assignment_xml_file = assignment_resource.attribute('href').value
447+
assignment_resource.at_css('file').attribute('href').value.should == assignment_xml_file
448+
449+
# validate cc1.3 assignment xml document
450+
assignment_xml_doc = Nokogiri::XML(@zip_file.read(assignment_xml_file))
451+
assignment_xml_doc.at_css('text').text.should == '<em>what?</em>'
452+
assignment_xml_doc.at_css('text').attribute('texttype').value.should == 'text/html'
453+
assignment_xml_doc.at_css('gradable').text.should == 'true'
454+
assignment_xml_doc.at_css('gradable').attribute('points_possible').value.should == '11'
455+
assignment_xml_doc.css('submission_formats format').map{ |fmt| fmt.attribute('type').value }.should =~ %w(html file url)
456+
457+
# validate presence of canvas extension node
458+
extension_node = assignment_xml_doc.at_css('extensions').elements.first
459+
extension_node.name.should == 'assignment'
460+
extension_node.namespace.href.should == 'http://canvas.instructure.com/xsd/cccv1p0'
461+
462+
# validate fallback html manifest resource
463+
variant_tag = @manifest_doc.at_css(%Q{resource[identifier="#{assignment_id}_fallback"]}).elements.first
464+
variant_tag.name.should == 'variant'
465+
variant_tag.attribute('identifierref').value.should eql assignment_id
466+
variant_tag.next_element.name.should == 'file'
467+
html_file = variant_tag.next_element.attribute('href').value
468+
@zip_file.read("#{assignment_id}/test-assignment.html").should be_include "<em>what?</em>"
469+
end
434470
end
435471
end

0 commit comments

Comments
 (0)