Add indirect outcome alignments via question banks to graphql

closes OUT-5272
flag=outcome_alignment_summary

Test plan:
- Enable Improved Outcomes Management FF
- Enable Outcome Alignment Summary FF
- Go to Course > Outcomes
- Create one outcome, one rubric, one assignment, one graded
discussion and one quiz
- Align the outcome to the rubric and align the rubric to the
assignment, graded discussion and quiz
- Create a module and add to it the assignment and the quiz
- Go to Course > Outcomes > Alignments tab and expand outcome
- Verify that the following alignments are displayed:
rubric, assignment, graded dicussion and quiz
- Verify that the alignments are sorted alphabetically by title
- Click on each of the alignment titles/links and verify that
each opens the corresponding alignment in a new tab
- Click on the module name/link for the assignment and the quiz
and verify that each loads the modules page in a new tab
- Go to /courses/{id}/question_banks and create a question bank
(replace id with the course id)
- Align the question bank with the outcome, create one question
and add it to the quiz via quiz > edit > questions > find
- Go to Course > Outcomes > Alignments tab and expand outcome
- Verify that the following alignments are displayed:
rubric, assignment, graded dicussion, question bank and quiz
- Verify that the quiz alignment is displayed only once
- Click on the question bank title/link and verify that it
loads the question bank in a new tab
- Click on the quiz title/link and verify that it loads
the quiz in a new tab
- Click on the module name/link for the quiz and verify that
it loads the modules page in a new tab
- Go to Course -> Quizzes, select the quiz and remove rubric
via quiz -> show rubric (from menu) -> delete
- Go to Course > Outcomes > Alignments tab and expand outcome
- Verify that the following alignments are displayed:
rubric, assignment, graded dicussion, question bank and quiz
- Click on the quiz title/link and verify that it loads
the quiz in a new tab
- Go to Course -> Quizzes, select the quiz and remove the
questions from the quiz via quiz > edit > questions
- Go to Course > Outcomes > Alignments tab and expand outcome
- Verify that the following alignments are displayed:
rubric, assignment, graded dicussion and question bank
- Click on Course -> Quizzes, select Manage Question Banks from
menu, select the question bank and remove outcome alignment
- Go to /courses/{id}/question_banks, select the question bank
and remove the outcome alignment (replace id with the course id)
- Go to Course > Outcomes > Alignments tab and expand outcome
- Verify that the following alignments are displayed:
rubric, assignment and graded dicussion

Change-Id: I66cec340d63eec0a26eeff319754e4bfc0a48492
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/300792
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Reviewed-by: Angela Gomba <angela.gomba@instructure.com>
Reviewed-by: Dave Wenzlick <david.wenzlick@instructure.com>
QA-Review: Angela Gomba <angela.gomba@instructure.com>
Product-Review: Kyle Rosenbaum <krosenbaum@instructure.com>
This commit is contained in:
Martin Yosifov 2022-09-14 11:33:55 -07:00 committed by Kyle Rosenbaum
parent efe155bdc7
commit b987312c89
5 changed files with 268 additions and 86 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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