░▒▓████████████████████████████████▓▒░ ░▒▓█ ▓▒░ ░▒▓█ ~ S W A M P ~ ▓▒░ ░▒▓█ ▓▒░ ░▒▓████████████████████████████████▓▒░
pplmvsvm
LOCATION:
/var/www/canvas/lib/cc/qti
☗ ROOT
↻ REFRESH
✎ CARVE FLESH
EDITING: qti_items.rb
# frozen_string_literal: true # # Copyright (C) 2011 - present Instructure, Inc. # # This file is part of Canvas. # # Canvas is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License as published by the Free # Software Foundation, version 3 of the License. # # Canvas is distributed in the hope that it will be useful, but WITHOUT ANY # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR # A PARTICULAR PURPOSE. See the GNU Affero General Public License for more # details. # # You should have received a copy of the GNU Affero General Public License along # with this program. If not, see <http://www.gnu.org/licenses/>. # # rubocop:disable comments of Style/SymbolProc in this file are because the # builder DSL uses method_missing and blocks to define nested XML elements, and # a symbol proc confuses that use of the block module CC module Qti module QtiItems CC_SUPPORTED_TYPES = %w[multiple_choice_question multiple_answers_question true_false_question short_answer_question essay_question].freeze CC_TYPE_PROFILES = { "multiple_choice_question" => "cc.multiple_choice.v0p1", "multiple_answers_question" => "cc.multiple_response.v0p1", "true_false_question" => "cc.true_false.v0p1", "short_answer_question" => "cc.fib.v0p1", "essay_question" => "cc.essay.v0p1" }.freeze # These types don't stop processing response conditions once the correct # answer is found, so they need to show the incorrect response differently MULTI_ANSWER_TYPES = %w[matching_question multiple_dropdowns_question fill_in_multiple_blanks_question].freeze def add_ref_or_question(node, question) aq = nil if question[:assessment_question_id].present? && (aq = AssessmentQuestion.where(id: question[:assessment_question_id]).first) && (aq.deleted? || !aq.assessment_question_bank || aq.assessment_question_bank.deleted? || aq.assessment_question_bank.context_id != @course.id || aq.assessment_question_bank.context_type != @course.class.to_s) aq = nil end if aq ref = create_key(aq) node.itemref(linkrefid: ref) else add_question(node, question) end end # if the question is a supported CC type it will be added # it it's not supported it's just skipped # returns boolean - whether the question was added def add_cc_question(node, question) return false unless CC_SUPPORTED_TYPES.member?(question["question_type"]) add_question(node, question, true) true end def add_quiz_question(node, question) question[:is_quiz_question] = true add_question(node, question) end def qq_mig_id(question) qq_id = question["id"] if @manifest&.exporter&.for_master_migration create_key("quizzes/quiz_question_#{Shard.global_id_for(qq_id)}") # curse you namespacing else create_key("quiz_question_#{qq_id}") end end def aq_mig_id(question) aq_id = question["assessment_question_id"] if @manifest&.exporter&.for_master_migration aq_id = Shard.global_id_for(aq_id) end create_key("assessment_question_#{aq_id}") end def add_question(node, question, for_cc = false) question["migration_id"] = question[:is_quiz_question] ? qq_mig_id(question) : aq_mig_id(question) question["answers"] ||= [] if question["question_type"] == "missing_word_question" change_missing_word(question) end node.item( ident: question["migration_id"], title: question["name"].presence || question["question_name"] ) do |item_node| item_node.itemmetadata do |meta_node| meta_node.qtimetadata do |qm_node| if for_cc meta_field(qm_node, "cc_profile", CC_TYPE_PROFILES[question["question_type"]]) if question["question_type"] == "essay_question" meta_field(qm_node, "qmd_computerscored", "No") end else meta_field(qm_node, "question_type", question["question_type"]) meta_field(qm_node, "points_possible", question["points_possible"]) meta_field(qm_node, "original_answer_ids", question["answers"].pluck("id").join(",")) if question[:is_quiz_question] meta_field(qm_node, "assessment_question_identifierref", aq_mig_id(question)) end end end end # meta data item_node.presentation do |pres_node| pres_node.material do |mat_node| html_mat_text(mat_node, "<div>#{question["question_text"]}</div>", "") end presentation_options(pres_node, question) end # presentation if question["question_type"] != "text_only_question" item_node.resprocessing do |res_node| res_node.outcomes do |out_node| out_node.decvar( maxvalue: "100", minvalue: "0", varname: "SCORE", vartype: "Decimal" ) end resprocessing(res_node, question) end # resprocessing itemproc_extenstion(node, question) item_feedback(item_node, "general_fb", question, "neutral_comments") item_feedback(item_node, "correct_fb", question, "correct_comments") item_feedback(item_node, "general_incorrect_fb", question, "incorrect_comments") question["answers"].each do |answer| item_feedback(item_node, "#{answer["id"]}_fb", answer, "comments") end end end # item end ## question response_str methods def presentation_options(node, question) if %w[multiple_choice_question true_false_question multiple_answers_question].member? question["question_type"] multiple_choice_response_str(node, question) elsif ["short_answer_question", "essay_question"].member? question["question_type"] short_answer_response_str(node, question) elsif question["question_type"] == "matching_question" matching_response_lid(node, question) elsif %w[multiple_dropdowns_question fill_in_multiple_blanks_question].include?(question["question_type"]) multiple_dropdowns_response_lid(node, question) elsif %w[calculated_question numerical_question].include?(question["question_type"]) calculated_response_str(node, question) end end def multiple_choice_response_str(node, question) card = (question["question_type"] == "multiple_answers_question") ? "Multiple" : "Single" node.response_lid( ident: "response1", rcardinality: card ) do |r_node| r_node.render_choice do |rc_node| question["answers"].each do |answer| rc_node.response_label( ident: answer["id"] ) do |rl_node| rl_node.material do |mat_node| html_mat_text(mat_node, answer["html"], answer["text"]) end # mat_node end # rl_node end end # rc_node end end def matching_response_lid(node, question) question["answers"].each do |answer| node.response_lid(ident: "response_#{answer["id"]}") do |lid_node| lid_node.material do |mat_node| html_mat_text(mat_node, answer["html"], answer["text"]) end lid_node.render_choice do |rc_node| next unless question["matches"] question["matches"].each do |match| rc_node.response_label(ident: match["match_id"]) do |r_node| r_node.material do |mat_node| mat_node.mattext match["text"] end end # r_node end end # rc_node end # lid_node end end def short_answer_response_str(node, _question) node.response_str( ident: "response1", rcardinality: "Single" ) do |r_node| r_node.render_fib { |n| n.response_label(ident: "answer1", rshuffle: "No") } end end def change_missing_word(question) # Convert this to a multiple_dropdowns_question then send it on its way question["question_text"] = "#{question["question_text"].gsub(%r{^<p>|</p>$}, "")} [drop1] #{question["text_after_answers"].gsub(%r{^<p>|</p>$}, "")}" question["answers"].each do |answer| answer["blank_id"] = "drop1" end question["question_type"] = "multiple_dropdowns_question" end def multiple_dropdowns_response_lid(node, question) groups = question["answers"].group_by { |a| a[:blank_id] } groups.each_pair do |id, answers| node.response_lid(ident: "response_#{id}") do |lid_node| lid_node.material do |mat_node| mat_node.mattext id end lid_node.render_choice do |rc_node| answers.each do |answer| rc_node.response_label(ident: answer["id"]) do |r_node| r_node.material do |mat_node| html_mat_text(mat_node, answer["html"], answer["text"]) end end # r_node end end # rc_node end # lid_node end end def calculated_response_str(node, _question) node.response_str( ident: "response1", rcardinality: "Single" ) do |r_node| r_node.render_fib(fibtype: "Decimal") { |n| n.response_label(ident: "answer1") } end end ## question resprocessing methods def resprocessing(node, question) if question["neutral_comments"].present? || question["neutral_comments_html"].present? other_respcondition(node, "Yes", "general_fb") end unless ["matching_question", "numerical_question"].member? question["question_type"] answer_feedback_respconditions(node, question) end # question type specific resprocessing if ["multiple_choice_question", "true_false_question"].member? question["question_type"] multiple_choice_resprocessing(node, question) elsif question["question_type"] == "multiple_answers_question" multiple_answers_resprocessing(node, question) elsif question["question_type"] == "short_answer_question" short_answer_resprocessing(node, question) elsif question["question_type"] == "essay_question" essay_resprocessing(node, question) elsif question["question_type"] == "matching_question" matching_resprocessing(node, question) elsif %w[multiple_dropdowns_question fill_in_multiple_blanks_question].include?(question["question_type"]) multiple_dropdowns_resprocessing(node, question) elsif question["question_type"] == "calculated_question" calculated_resprocessing(node, question) elsif question["question_type"] == "numerical_question" numerical_resprocessing(node, question) end if (question["incorrect_comments"].present? || question["incorrect_comments_html"].present?) && !MULTI_ANSWER_TYPES.member?(question["question_type"]) other_respcondition(node, "Yes", "general_incorrect_fb") end end def multiple_choice_resprocessing(node, question) correct_id = nil correct_answer = question["answers"].find { |a| a["weight"].to_i > 0 } correct_id = correct_answer["id"] if correct_answer node.respcondition(continue: "No") do |res_node| res_node.conditionvar do |c_node| c_node.varequal correct_id, respident: "response1" end # c_node res_node.setvar "100", action: "Set", varname: "SCORE" correct_feedback_ref(res_node, question) end # res_node end def multiple_answers_resprocessing(node, question) node.respcondition(continue: "No") do |res_node| res_node.conditionvar do |c_node| c_node.and do |and_node| # The CC implementation guide says the 'and' isn't needed but it doesn't validate without it. question["answers"].each do |answer| if answer["weight"].to_i > 0 and_node.varequal answer["id"], respident: "response1" else and_node.not do |not_node| not_node.varequal answer["id"], respident: "response1" end end end end end # c_node res_node.setvar "100", action: "Set", varname: "SCORE" correct_feedback_ref(res_node, question) end # res_node end def short_answer_resprocessing(node, question) node.respcondition(continue: "No") do |res_node| res_node.conditionvar do |c_node| question["answers"].each do |answer| c_node.varequal answer["text"], respident: "response1" end end # c_node res_node.setvar "100", action: "Set", varname: "SCORE" correct_feedback_ref(res_node, question) end # res_node end def numerical_resprocessing(node, question) question["answers"].each do |answer| node.respcondition(continue: "No") do |res_node| res_node.conditionvar do |c_node| if answer["exact"] # exact answer c_node.or do |or_node| exact = answer["exact"].to_f or_node.varequal exact, respident: "response1" unless answer["margin"].blank? or_node.and do |and_node| exact = BigDecimal(answer["exact"].to_s) margin = BigDecimal(answer["margin"].to_s) and_node.vargte((exact - margin).to_f, respident: "response1") and_node.varlte((exact + margin).to_f, respident: "response1") end end end elsif answer["numerical_answer_type"] == "precision_answer" # this might be one of the worst hacks i've ever done c_node.or do |or_node| approx = answer["approximate"].to_d or_node.varequal approx, respident: "response1" precision = answer["precision"].to_i if precision > 0 # there's probably an easier way to do this but i wouldn't know what it is sci_form = "%.#{precision - 1}E" % approx # e.g. 13.4 -> 1.340E+01 for precision 4 prefix, exp = sci_form.split("E") range = "5E-#{precision}".to_d # 0.005 floor = "#{prefix.to_d - range}E#{exp}".to_d # 1.3395E+01 ceil = "#{prefix.to_d + range}E#{exp}".to_d # 1.3405E+01 or_node.and do |and_node| and_node.vargt(floor, respident: "response1") and_node.varlte(ceil, respident: "response1") end end end else # answer in range c_node.vargte(answer["start"], respident: "response1") c_node.varlte(answer["end"], respident: "response1") end end # c_node res_node.setvar "100", action: "Set", varname: "SCORE" res_node.displayfeedback(feedbacktype: "Response", linkrefid: "#{answer["id"]}_fb") unless answer["comments"].blank? && answer["comments_html"].blank? correct_feedback_ref(res_node, question) end # res_node end end def essay_resprocessing(node, _question) other_respcondition(node) end def matching_resprocessing(node, question) return nil unless question["answers"] && question["answers"].count > 0 correct_points = 100.0 / question["answers"].count correct_points = "%.2f" % correct_points question["answers"].each do |answer| node.respcondition do |r_node| r_node.conditionvar do |c_node| c_node.varequal(answer["match_id"], respident: "response_#{answer["id"]}") end r_node.setvar(correct_points, varname: "SCORE", action: "Add") end next if answer["comments"].blank? && answer["comments_html"].blank? node.respcondition do |r_node| r_node.conditionvar do |c_node| c_node.not do c_node.varequal(answer["match_id"], respident: "response_#{answer["id"]}") end end r_node.displayfeedback(feedbacktype: "Response", linkrefid: "#{answer["id"]}_fb") end end end def multiple_dropdowns_resprocessing(node, question) groups = question["answers"].group_by { |a| a[:blank_id] } correct_points = 100.0 / groups.length correct_points = "%.2f" % correct_points groups.each_pair do |id, answers| next unless (answer = answers.find { |a| a["weight"].to_i > 0 }) node.respcondition do |r_node| r_node.conditionvar do |c_node| c_node.varequal(answer["id"], respident: "response_#{id}") end r_node.setvar(correct_points, varname: "SCORE", action: "Add") end end end def calculated_resprocessing(node, _question) node.respcondition(title: "correct") do |r_node| r_node.conditionvar do |c_node| # rubocop:disable Style/SymbolProc c_node.other end r_node.setvar(100, varname: "SCORE", action: "Set") end node.respcondition(title: "incorrect") do |r_node| r_node.conditionvar do |c_node| c_node.not do |n_node| # rubocop:disable Style/SymbolProc n_node.other end end r_node.setvar(0, varname: "SCORE", action: "Set") end end # feedback helpers def answer_feedback_respconditions(node, question) question["answers"].each do |answer| next if answer["comments"].blank? && answer["comments_html"].blank? respident = "response1" if MULTI_ANSWER_TYPES.member? question["question_type"] respident = "response_#{answer["blank_id"]}" end node.respcondition(continue: "Yes") do |res_node| res_node.conditionvar do |c_node| if question[:question_type] == "short_answer_question" c_node.varequal(answer["text"], respident:) else c_node.varequal answer["id"], respident: end end # c_node node.displayfeedback(feedbacktype: "Response", linkrefid: "#{answer["id"]}_fb") end end end def other_respcondition(node, continue = "No", feedback_ref = nil) node.respcondition(continue:) do |res_node| res_node.conditionvar do |c_node| # rubocop:disable Style/SymbolProc c_node.other end # c_node res_node.displayfeedback(feedbacktype: "Response", linkrefid: feedback_ref) if feedback_ref end # res_node end def correct_feedback_ref(node, question) unless question["correct_comments"].blank? && question["correct_comments_html"].blank? node.displayfeedback(feedbacktype: "Response", linkrefid: "correct_fb") end end def item_feedback(node, id, question, key) return unless question[key].present? || question[key + "_html"].present? node.itemfeedback(ident: id) do |f_node| f_node.flow_mat do |flow_node| flow_node.material do |mat_node| html_mat_text(mat_node, question[key + "_html"], question[key]) end end end end # Custom extensions def itemproc_extenstion(node, question) if question["question_type"] == "calculated_question" calculated_extension(node, question) end end def calculated_extension(node, question) node.itemproc_extension do |ext_node| ext_node.calculated do |calc_node| calc_node.answer_tolerance question["answer_tolerance"] calc_node.formulas(decimal_places: question["formula_decimal_places"]) do |forms_node| question["formulas"].try(:each) do |f| forms_node.formula f["formula"] end end calc_node.vars do |vars_node| question["variables"].try(:each) do |var| vars_node.var(name: var["name"], scale: var["scale"]) do |var_node| var_node.min var["min"] var_node.max var["max"] end end end calc_node.var_sets do |sets_node| question["answers"].try(:each) do |answer| sets_node.var_set(ident: answer["id"]) do |set_node| answer["variables"].try(:each) do |var| set_node.var(var["value"], name: var["name"]) end set_node.answer answer[:answer] end end end end # calc_node end # ext_node end def html_mat_text(mat_node, html_val, text_val) if html_val.present? html = @html_exporter.html_content(html_val) mat_node.mattext html, texttype: "text/html" else mat_node.mattext text_val, texttype: "text/plain" end end end end end
CANCEL
Name
Type
Size
Modified
Actions
↩ ..
DIR
—
—
📄 qti_generator.rb
RB
16.8 KB
2023-09-24 03:36
EDIT
📄 qti_items.rb
RB
21.9 KB
2023-09-24 03:36
EDIT
📄 qti_manifest.rb
RB
5.5 KB
2023-09-24 03:36
EDIT