diff --git a/app/graphql/loaders/outcome_alignment_loader.rb b/app/graphql/loaders/outcome_alignment_loader.rb index 8412e852422..2a675236ef3 100644 --- a/app/graphql/loaders/outcome_alignment_loader.rb +++ b/app/graphql/loaders/outcome_alignment_loader.rb @@ -37,10 +37,11 @@ class Loaders::OutcomeAlignmentLoader < GraphQL::Batch::Loader end outcomes.each do |outcome| - # map assignment id to quiz and discussion ids + # direct outcome alignments to rubric, assignment, quiz, and graded discussions + # map assignment id to quiz/discussion id assignments_sub = Assignment .active - .select("assignments.id as assignment_id, discussion_topics.id as discussion_id, quizzes.id as quizzes_id") + .select("assignments.id as assignment_id, discussion_topics.id as discussion_id, quizzes.id as quiz_id") .where(context: @context) .left_joins(:discussion_topic) .left_joins(:quiz) @@ -53,25 +54,80 @@ class Loaders::OutcomeAlignmentLoader < GraphQL::Batch::Loader .left_joins(:content_tags) .where(content_tags: { workflow_state: "active" }) - # map alignment id to assignment, quiz and discussion ids + # map alignment id to assignment/quiz/discussion ids alignments_sub = outcome .alignments - .select("content_tags.id, content_tags.content_id, content_tags.content_type, content_tags.context_id, content_tags.context_type, content_tags.title, content_tags.learning_outcome_id, content_tags.created_at, content_tags.updated_at, assignments.assignment_id, assignments.discussion_id, assignments.quizzes_id") + .select("content_tags.id, 'direct' as alignment_type, content_tags.content_id, content_tags.content_type, content_tags.context_id, content_tags.context_type, content_tags.title, content_tags.learning_outcome_id, content_tags.created_at, content_tags.updated_at, assignments.assignment_id, assignments.discussion_id, assignments.quiz_id") .where(context: @context, content_type: %w[Rubric Assignment AssessmentQuestionBank]) .joins("LEFT OUTER JOIN (#{assignments_sub.to_sql}) AS assignments ON content_tags.content_id = assignments.assignment_id AND content_tags.content_type = 'Assignment'") - alignments = ContentTag - .select("alignments.*, modules.module_id, modules.module_name, modules.module_workflow_state") - .from("(#{alignments_sub.to_sql}) AS alignments") - .joins("LEFT OUTER JOIN (#{modules_sub.to_sql}) AS modules - ON (alignments.quizzes_id = modules.assignment_content_id AND modules.assignment_content_type = 'Quizzes::Quiz') - OR (alignments.discussion_id = modules.assignment_content_id AND modules.assignment_content_type = 'DiscussionTopic') - OR (alignments.assignment_id = modules.assignment_content_id AND modules.assignment_content_type = 'Assignment') - ") - .order("title ASC") - .to_a + direct_alignments = ContentTag + .select("alignments.*, modules.module_id, modules.module_name, modules.module_workflow_state") + .from("(#{alignments_sub.to_sql}) AS alignments") + .joins("LEFT OUTER JOIN (#{modules_sub.to_sql}) AS modules + ON (alignments.quiz_id = modules.assignment_content_id AND modules.assignment_content_type = 'Quizzes::Quiz') + OR (alignments.discussion_id = modules.assignment_content_id AND modules.assignment_content_type = 'DiscussionTopic') + OR (alignments.assignment_id = modules.assignment_content_id AND modules.assignment_content_type = 'Assignment') + ") - fulfill(outcome, alignments) + # indirect outcome alignments to quizzes via question banks + # map question banks to questions + question_banks_to_questions_sub = AssessmentQuestionBank + .active + .select("assessment_question_banks.id as bank_id, assessment_questions.id as question_id") + .where(context: @context) + .left_joins(:assessment_questions) + .where(assessment_questions: { workflow_state: "active" }) + + # map question banks to quizzes via questions + question_banks_to_quizzes_sub = AssessmentQuestionBank + .select("banks.bank_id, quiz_questions.quiz_id") + .from("(#{question_banks_to_questions_sub.to_sql}) AS banks") + .joins("INNER JOIN #{Quizzes::QuizQuestion.quoted_table_name} AS quiz_questions + ON banks.question_id = quiz_questions.assessment_question_id + WHERE quiz_questions.workflow_state <> 'deleted' + ") + + # quizzes which are indirectly aligned to outcome via question banks + quizzes_to_outcome_indirect = outcome.alignments + .select("question_bank.quiz_id as id") + .where(context: @context, content_type: "AssessmentQuestionBank") + .joins("LEFT OUTER JOIN (#{question_banks_to_quizzes_sub.to_sql}) AS question_bank + ON content_tags.content_id = question_bank.bank_id + ") + .distinct + + indirect_alignments = Assignment + .active + .select("assignments.id, 'indirect' as alignment_type, assignments.id as content_id, 'Assignment' as content_type, assignments.context_id, assignments.context_type, quizzes.title as title, #{outcome.id} as learning_outcome_id, assignments.created_at, assignments.updated_at, assignments.id as assignment_id, null::bigint as discussion_id, quizzes.id as quiz_id, modules.module_id, modules.module_name, modules.module_workflow_state") + .where(context: @context) + .left_joins(:quiz) + .where(quizzes: { id: quizzes_to_outcome_indirect }) + .joins("LEFT OUTER JOIN (#{modules_sub.to_sql}) AS modules + ON (quizzes.id = modules.assignment_content_id + AND modules.assignment_content_type = 'Quizzes::Quiz') + ") + .distinct + + all_alignments = ContentTag.from("(#{direct_alignments.to_sql} UNION #{indirect_alignments.to_sql}) AS content_tags") + + # deduplicate and sort alignments + alignments = [] + uniq_alignments = Set.new + all_alignments_sorted = all_alignments.sort_by { |a| a[:alignment_type] } + + all_alignments_sorted.each do |a| + align = alignment_hash(a) + art_id = artifact_id(a) + unless uniq_alignments.include?(art_id) + alignments.push(align) + uniq_alignments.add(art_id) + end + end + + sorted_alignments = alignments.sort_by { |a| a[:title] } + + fulfill(outcome, sorted_alignments) end end @@ -80,4 +136,64 @@ class Loaders::OutcomeAlignmentLoader < GraphQL::Batch::Loader fulfill(outcome, nil) unless fulfilled?(outcome) end end + + private + + def alignment_hash(alignment) + { + id: id(alignment), + title: alignment[:title], + content_id: alignment[:content_id], + content_type: alignment[:content_type], + context_id: alignment[:context_id], + context_type: alignment[:context_type], + learning_outcome_id: alignment[:learning_outcome_id], + url: url(alignment), + module_id: alignment[:module_id], + module_name: alignment[:module_name], + module_url: module_url(alignment), + module_workflow_state: alignment[:module_workflow_state], + assignment_content_type: assignment_content_type(alignment), + created_at: alignment[:created_at], + updated_at: alignment[:updated_at] + } + end + + def id(alignment) + # prepend id with alignment type (D - direct/I - indirect) to ensure unique alignment id + base_id = [alignment[:alignment_type] == "direct" ? "D" : "I", alignment[:id]].join("_") + # append id with module id to ensure unique alignment id when artifact is included in multiple modules + return [base_id, alignment[:module_id]].join("_") if alignment[:module_id] + + base_id + end + + def assignment_content_type(alignment) + return "quiz" unless alignment[:quiz_id].nil? + return "discussion" unless alignment[:discussion_id].nil? + return "assignment" unless alignment[:assignment_id].nil? + end + + def base_url(alignment) + ["/#{alignment[:context_type].downcase.pluralize}", alignment[:context_id]].join("/") + end + + def url(alignment) + return [base_url(alignment), "rubrics", alignment[:content_id]].join("/") if alignment[:content_type] == "Rubric" + return [base_url(alignment), "question_banks", alignment[:content_id]].join("/") if alignment[:content_type] == "AssessmentQuestionBank" + return [base_url(alignment), "assignments", alignment[:content_id]].join("/") if alignment[:content_type] == "Assignment" + + base_url(alignment) + end + + def module_url(alignment) + [base_url(alignment), "modules", alignment[:module_id]].join("/") if alignment[:module_id] + end + + def artifact_id(alignment) + base_art_id = [alignment[:content_type], alignment[:content_id]].join("_") + return [base_art_id, alignment[:module_id]].join("_") if alignment[:module_id] + + base_art_id + end end diff --git a/app/graphql/types/outcome_alignment_type.rb b/app/graphql/types/outcome_alignment_type.rb index 488787ce717..45a1c083161 100644 --- a/app/graphql/types/outcome_alignment_type.rb +++ b/app/graphql/types/outcome_alignment_type.rb @@ -27,12 +27,6 @@ module Types global_id_field :id - field :_id, ID, null: false - def _id - return [object.id, object.module_id].join("_") unless object.module_id.nil? - - object.id - end field :title, String, null: false field :content_id, ID, null: false field :content_type, String, null: false @@ -40,27 +34,10 @@ module Types field :context_type, String, null: false field :learning_outcome_id, ID, null: false field :url, String, null: false - def url - [base_context_url.to_s, "outcomes", object.learning_outcome_id, "alignments", object.id].join("/") - end field :module_id, String, null: true field :module_name, String, null: true field :module_url, String, null: true - def module_url - [base_context_url.to_s, "modules", object.module_id].join("/") if object.module_id - end field :module_workflow_state, String, null: true field :assignment_content_type, String, null: true - def assignment_content_type - return "quiz" unless object.quizzes_id.nil? - return "discussion" unless object.discussion_id.nil? - return "assignment" unless object.assignment_id.nil? - end - - private - - def base_context_url - ["/#{object.context_type.downcase.pluralize}", object.context_id].join("/") - end end end diff --git a/spec/graphql/loaders/outcome_alignment_loader_spec.rb b/spec/graphql/loaders/outcome_alignment_loader_spec.rb index 83ca14a6c04..fc39dade69b 100644 --- a/spec/graphql/loaders/outcome_alignment_loader_spec.rb +++ b/spec/graphql/loaders/outcome_alignment_loader_spec.rb @@ -23,28 +23,49 @@ describe Loaders::OutcomeAlignmentLoader do course_model outcome_with_rubric assessment_question_bank_with_questions - @quiz_item = assignment_quiz([], course: @course, title: "quiz item") - @quiz_assignment = @assignment - @assignment = @course.assignments.create!(title: "regular assignment") + @quiz1 = assignment_quiz([], course: @course, title: "quiz") + @quiz1_assignment = @assignment @discussion_assignment = @course.assignments.create!(title: "discussion assignment") @discussion_item = @course.discussion_topics.create!( user: @teacher, title: "discussion item", assignment: @discussion_assignment ) + # quiz aligned with outcome via question bank + @outcome.align(@bank, @bank.context) + @quiz2 = assignment_quiz([], course: @course, title: "quiz with questions from questions bank") + @quiz2_assignment = @assignment + @quiz2.add_assessment_questions [@q1, @q2] + @assignment = @course.assignments.create!(title: "regular assignment") @module1 = @course.context_modules.create!(name: "module1") @module2 = @course.context_modules.create!(name: "module2", workflow_state: "unpublished") @tag1 = @module1.add_item type: "assignment", id: @assignment.id @module1.add_item type: "discussion_topic", id: @discussion_item.id - @module2.add_item type: "quiz", id: @quiz_item.id + @module1.add_item type: "quiz", id: @quiz2.id + @module2.add_item type: "quiz", id: @quiz1.id @rubric.associate_with(@assignment, @course, purpose: "grading") @rubric.associate_with(@discussion_assignment, @course, purpose: "grading") - @rubric.associate_with(@quiz_assignment, @course, purpose: "grading") - @outcome.align(@bank, @bank.context) + @rubric.associate_with(@quiz1_assignment, @course, purpose: "grading") @course.account.enable_feature!(:outcome_alignment_summary) end + def base_url + ["/courses", @course.id].join("/") + end + + def url(alignment) + return [base_url, "rubrics", alignment[:content_id]].join("/") if alignment[:content_type] == "Rubric" + return [base_url, "question_banks", alignment[:content_id]].join("/") if alignment[:content_type] == "AssessmentQuestionBank" + return [base_url, "assignments", alignment[:content_id]].join("/") if alignment[:content_type] == "Assignment" + + base_url + end + + def module_url(alignment) + [base_url, "modules", alignment[:module_id]].join("/") if alignment[:module_id] + end + it "resolves to nil if context id is invalid" do GraphQL::Batch.batch do Loaders::OutcomeAlignmentLoader.for( @@ -83,55 +104,67 @@ describe Loaders::OutcomeAlignmentLoader do @course.id, "Course" ).load(@outcome).then do |alignments| expect(alignments.is_a?(Array)).to be_truthy - expect(alignments.length).to eq 5 + expect(alignments.length).to eq 6 end end end - it "resolves outcome alignments to assignment, rubric, quiz and discussion" do + it "resolves outcome alignments to assignment, rubric, quiz, discussion and quiz with question bank" do GraphQL::Batch.batch do Loaders::OutcomeAlignmentLoader.for( @course.id, "Course" ).load(@outcome).then do |alignments| alignments.each do |alignment| - if alignment.content_type == "Assignment" + if alignment[:content_type] == "Assignment" content_id = @assignment.id content_type = "Assignment" module_id = @module1.id module_name = @module1.name + module_url = [base_url, "modules", alignment[:module_id]].join("/") if alignment[:module_id] module_workflow_state = "active" title = @assignment.title - if alignment.title == "discussion item" + assignment_content_type = "assignment" + if alignment[:title] == @discussion_item.title content_id = @discussion_assignment.id title = @discussion_item.title + assignment_content_type = "discussion" end - if alignment.title == "quiz item" - content_id = @quiz_assignment.id + if alignment[:title] == @quiz1.title + content_id = @quiz1_assignment.id module_id = @module2.id module_name = @module2.name module_workflow_state = "unpublished" - title = @quiz_item.title + title = @quiz1.title + assignment_content_type = "quiz" end - elsif alignment.content_type == "Rubric" + if alignment[:title] == @quiz2.title + content_id = @quiz2_assignment.id + title = @quiz2.title + assignment_content_type = "quiz" + end + elsif alignment[:content_type] == "Rubric" content_id = @rubric.id content_type = "Rubric" title = @rubric.title - elsif alignment.content_type == "AssessmentQuestionBank" + elsif alignment[:content_type] == "AssessmentQuestionBank" content_id = @bank.id content_type = "AssessmentQuestionBank" title = @bank.title end - expect(alignment.id).not_to be_nil - expect(alignment.content_id).to eq content_id - expect(alignment.content_type).to eq content_type - expect(alignment.context_id).to eq @course.id - expect(alignment.context_type).to eq "Course" - expect(alignment.title).to eq title - expect(alignment.learning_outcome_id).to eq @outcome.id - expect(alignment.module_id).to eq module_id - expect(alignment.module_name).to eq module_name - expect(alignment.module_workflow_state).to eq module_workflow_state + expect(alignment[:id]).not_to be_nil + expect(alignment[:content_id]).to eq content_id + expect(alignment[:content_type]).to eq content_type + expect(alignment[:context_id]).to eq @course.id + expect(alignment[:context_type]).to eq "Course" + expect(alignment[:title]).to eq title + expect(alignment[:url]).to eq url(alignment) + expect(alignment[:learning_outcome_id]).to eq @outcome.id + expect(alignment[:module_id]).to eq module_id + expect(alignment[:module_name]).to eq module_name + expect(alignment[:module_url]).to eq module_url + expect(alignment[:module_workflow_state]).to eq module_workflow_state + expect(alignment[:assignment_content_type]).to eq assignment_content_type end end end @@ -142,27 +175,47 @@ describe Loaders::OutcomeAlignmentLoader do @tag1.destroy! end - it "resolves outcome alignment to assignment with nil module id, module name and module workflow_state" do + it "resolves outcome alignment to assignment with null values for module id, module name and module workflow_state" do GraphQL::Batch.batch do Loaders::OutcomeAlignmentLoader.for( @course.id, "Course" ).load(@outcome).then do |alignments| alignments.each do |alignment| - next unless alignment.content_type == "Assignment" && alignment.title == "regular assignment" + next unless alignment[:content_type] == "Assignment" && alignment[:title] == @assignment.title - expect(alignment.id).not_to be_nil - expect(alignment.content_id).to eq @assignment.id - expect(alignment.content_type).to eq "Assignment" - expect(alignment.context_id).to eq @course.id - expect(alignment.context_type).to eq "Course" - expect(alignment.title).to eq "regular assignment" - expect(alignment.learning_outcome_id).to eq @outcome.id - expect(alignment.module_id).to be_nil - expect(alignment.module_name).to be_nil - expect(alignment.module_workflow_state).to be_nil + expect(alignment[:id]).not_to be_nil + expect(alignment[:content_id]).to eq @assignment.id + expect(alignment[:content_type]).to eq "Assignment" + expect(alignment[:context_id]).to eq @course.id + expect(alignment[:context_type]).to eq "Course" + expect(alignment[:title]).to eq @assignment.title + expect(alignment[:url]).to eq url(alignment) + expect(alignment[:learning_outcome_id]).to eq @outcome.id + expect(alignment[:module_id]).to be_nil + expect(alignment[:module_name]).to be_nil + expect(alignment[:module_url]).to be_nil + expect(alignment[:module_workflow_state]).to be_nil + expect(alignment[:assignment_content_type]).to eq "assignment" end end end end end + + context "when outcome is aligned to the same quiz via rubric and question bank" do + before do + @rubric.associate_with(@quiz2_assignment, @course, purpose: "grading") + end + + it "displays only once the outcome alignment to the quiz" do + GraphQL::Batch.batch do + Loaders::OutcomeAlignmentLoader.for( + @course.id, "Course" + ).load(@outcome).then do |alignments| + expect(alignments.is_a?(Array)).to be_truthy + expect(alignments.length).to eq 6 + end + end + end + end end diff --git a/spec/graphql/types/learning_outcome_type_spec.rb b/spec/graphql/types/learning_outcome_type_spec.rb index fed1a7ac7fc..b95fae85904 100644 --- a/spec/graphql/types/learning_outcome_type_spec.rb +++ b/spec/graphql/types/learning_outcome_type_spec.rb @@ -175,7 +175,7 @@ describe Types::LearningOutcomeType do it "resolves alignments correctly" do alignment_ids = outcome_type.resolve("alignments(contextType: \"Course\", contextId: #{@course.id}) { _id }") expect(alignment_ids.length).to eq 2 - expect(alignment_ids).to include(@alignment1.id.to_s, @alignment2.id.to_s) + expect(alignment_ids).to include(["D", @alignment1.id].join("_"), ["D", @alignment2.id].join("_")) end end end diff --git a/spec/graphql/types/outcome_alignment_type_spec.rb b/spec/graphql/types/outcome_alignment_type_spec.rb index bc0ae647510..bfb621cbb95 100644 --- a/spec/graphql/types/outcome_alignment_type_spec.rb +++ b/spec/graphql/types/outcome_alignment_type_spec.rb @@ -29,10 +29,10 @@ describe Types::OutcomeAlignmentType do @quiz_item = assignment_quiz([], course: @course, title: "BBB Quiz") @quiz = @assignment @assignment = @course.assignments.create!(title: "AAA Assignment") - @discussion = @course.assignments.create!(title: "CCC Discussion") - @discussion_item = @course.discussion_topics.create!( + @discussion = @course.assignments.create!(title: "CCC Graded Discussion") + @course.discussion_topics.create!( user: @teacher, - title: "discussion item", + title: "CCC discussion item", assignment: @discussion ) @module = @course.context_modules.create!(name: "module") @@ -46,15 +46,15 @@ describe Types::OutcomeAlignmentType do outcome_type.resolve("alignments(contextType: \"Course\", contextId: #{@course.id}) { #{field_name} }")[0] end - describe "for each outcome alignment" do + describe "for direct outcome alignments to assignment, quiz and graded discussion" do before do @module.add_item type: "assignment", id: @assignment.id @rubric.associate_with(@assignment, @course, purpose: "grading") @outcome_alignment = ContentTag.last end - it "returns _id = alignment_id + '_' + module_id" do - expect(resolve_field("_id")).to eq [@outcome_alignment.id.to_s, @module.id.to_s].join("_") + it "returns _id like D_{alignment_id}_{module_id}" do + expect(resolve_field("_id")).to eq ["D", @outcome_alignment.id.to_s, @module.id.to_s].join("_") end it "returns learning_outcome_id" do @@ -82,7 +82,7 @@ describe Types::OutcomeAlignmentType do end it "returns url" do - expect(resolve_field("url")).to eq "/courses/#{@course.id}/outcomes/#{@outcome.id}/alignments/#{@outcome_alignment.id}" + expect(resolve_field("url")).to eq "/courses/#{@course.id}/assignments/#{@assignment.id}" end it "returns module_id" do @@ -108,8 +108,8 @@ describe Types::OutcomeAlignmentType do @outcome_alignment = ContentTag.last end - it "returns _id = alignment_id" do - expect(resolve_field("_id")).to eq @outcome_alignment.id.to_s + it "returns _id not appended with module_id" do + expect(resolve_field("_id")).to eq ["D", @outcome_alignment.id.to_s].join("_") end end @@ -133,7 +133,26 @@ describe Types::OutcomeAlignmentType do end end - describe "for outcome alignment to a Discussion" do + describe "for indirect outcome alignment to Quiz via question bank" do + before do + assessment_question_bank_with_questions + @outcome.align(@bank, @bank.context) + @quiz2 = assignment_quiz([], course: @course, title: "DDD quiz with questions from questions bank") + @quiz2_assignment = @assignment + @quiz2.add_assessment_questions [@q1, @q2] + @module.add_item type: "quiz", id: @quiz2.id + end + + it "returns _id like I_{quiz_assignment_id}_{module_id}" do + expect(resolve_field("_id")).to eq ["I", @quiz2_assignment.id.to_s, @module.id.to_s].join("_") + end + + it "returns assignment_content_type 'quiz'" do + expect(resolve_field("assignmentContentType")).to eq "quiz" + end + end + + describe "for outcome alignment to a Graded Discussion" do before do @rubric.associate_with(@discussion, @course, purpose: "grading") end @@ -147,5 +166,22 @@ describe Types::OutcomeAlignmentType do it "returns null assignment_content_type" do expect(resolve_field("assignmentContentType")).to be_nil end + + it "returns url for the rubric" do + expect(resolve_field("url")).to eq "/courses/#{@course.id}/rubrics/#{@rubric.id}" + end + end + + describe "for outcome alignment to a Question Bank" do + before do + assessment_question_bank_model + @bank.title = "EEE question bank" + @bank.save! + @outcome.align(@bank, @bank.context) + end + + it "returns url for the question bank" do + expect(resolve_field("url")).to eq "/courses/#{@course.id}/question_banks/#{@bank.id}" + end end end