exclude muted assocations from OS results in sLMGB

closes OUT-5299

flag=outcome_service_results_to_canvas

test plan:
- tests pass in Jenkins: please pay extra attention
to the test plan.  These tests should line up with
the tests in learning_outcome_result_spec.rb to
ensure all assignments that are classified as muted
are removed from the OS results.
- For testing in the UI:
- In a course with outcomes, create 2 New Quizzes
  - Each quiz should be aligned to a different outcome
- Turn on LMGB, sLMGB, and Outcome Service Results to
  Canvas FF on.
- Take each quiz as a student
- As a teacher, confirm:
  - both outcomes have results in the LMGB
  - both outcomes have results in the sLMGB
    - results will be identified as a mastered/unmaster
      pill.  The list of assignments & its aligning
      mastery in PS OUT-5297 & OUT-5298
- As a student, confirm:
  - both outcomes has results in the sLMGB
- As a teacher, mute 1 quiz in the gradebook:
https://community.canvaslms.com/docs/DOC-12961-4152724339
- As a teacher, confirm:
  - both outcomes are displaying in the LMGB & sLMGB
- As a student, confirm:
  - Only 1 outcome results is displaying in the sLMGB
- As a teacher, mute the 2 quiz in the gradebook and
  confirm:
  - both outcomes are displaying in the LMGB & sLMGB
- As a student, confirm:
  - 0 outcome results is displaying in the sLMGB
- As a teacher, unmute both quizzes & confirm:
  - both outcomes has results in the LMGB & sLMGB
- As a student, confirm:
  - both outcomes has results in the sLMGB

Change-Id: I21e085b3e856410cfe89ce57db2271f05858c097
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/302565
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Reviewed-by: Dave Wenzlick <david.wenzlick@instructure.com>
QA-Review: Dave Wenzlick <david.wenzlick@instructure.com>
Product-Review: Chrystal Langston <chrystal.langston@instructure.com>
This commit is contained in:
Chrystal Langston 2022-10-04 17:31:58 -04:00
parent 1e78ace96f
commit c3eff1e944
11 changed files with 147 additions and 97 deletions

View File

@ -371,6 +371,7 @@ class OutcomeResultsController < ApplicationController
def find_outcomes_service_results(opts = {})
find_outcomes_service_outcome_results(
@current_user,
users: opts[:all_users] ? @all_users : @users,
context: @context,
outcomes: @outcomes,

View File

@ -21,11 +21,7 @@ module OutcomeResultResolverHelper
include OutcomesServiceAuthoritativeResultsHelper
def resolve_outcome_results(authoritative_results)
# TODO: Since get_lmgb_results returns a parsed JSON object
# we will need to update json_to_outcome_results to handle an already parsed object
# this will be done in a separate PS as it is not just a simple fix since this helper
# module is heavily dependent on the data to be in JSON. See OUT-5283
results = json_to_outcome_results({ results: authoritative_results }.to_json)
results = convert_to_learning_outcome_results(authoritative_results)
rubric_results = LearningOutcomeResult.preload(:learning_outcome).active.where(association_type: "RubricAssociation")
results.reject { |res| rubric_result?(res, rubric_results) }
end

View File

@ -17,39 +17,40 @@
# 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/>.
# This helper provides methods to transform JSON data retrieved upon calling
# This helper provides methods to transform hash data retrieved upon calling
# OutcomesService (OS) authoritative_results endpoint either into:
#
# - a RollupScores collection via Outcomes::ResultAnalytics
# - a LearningOutcomeResult collection
#
# It also provides a transformation method for a single OS' AuthoritativeResult
# JSON object into a LearningOutcomeResult object
# hash object into a LearningOutcomeResult object
module OutcomesServiceAuthoritativeResultsHelper
Rollup = Struct.new(:context, :scores)
# Transforms an OS' JSON AuthoritativeResult collection into a
# Transforms an OS' hash of AuthoritativeResult collection into a
# RollupScore collection
def json_to_rollup_scores(authoritative_results)
rollup_user_results json_to_outcome_results(authoritative_results)
def rollup_scores(authoritative_results)
rollup_user_results convert_to_learning_outcome_results(authoritative_results)
end
# Transforms an OS' JSON AuthoritativeResult collection into a
# Transforms an OS' hash AuthoritativeResult collection into a
# LearningOutcomeResult collection
def json_to_outcome_results(authoritative_results)
JSON.parse(authoritative_results).deep_symbolize_keys[:results].map do |r|
json_to_outcome_result(r)
def convert_to_learning_outcome_results(authoritative_results)
authoritative_results.each_with_object([]) do |r, all_results|
result = convert_to_learning_outcome_result(r)
all_results.push(result) unless result.nil?
end
end
# Transforms an OS' JSON AuthoritativeResult (AR) object into an
# Transforms an OS' hash AuthoritativeResult (AR) object into an
# instance of LearningOutcomeResult
def json_to_outcome_result(authoritative_result)
def convert_to_learning_outcome_result(authoritative_result)
outcome = LearningOutcome.find(authoritative_result[:external_outcome_id])
assignment = Assignment.find(authoritative_result[:associated_asset_id])
user = User.find_by(uuid: authoritative_result[:user_uuid])
submission = Submission.find_by(user_id: user.id, assignment_id: assignment.id)
student_user = User.find_by(uuid: authoritative_result[:user_uuid])
submission = Submission.find_by(user_id: student_user.id, assignment_id: assignment.id)
context = assignment.context
root_account = assignment.root_account
@ -75,9 +76,9 @@ module OutcomesServiceAuthoritativeResultsHelper
learning_outcome: outcome,
associated_asset: assignment,
artifact: submission,
title: "#{user.name}, #{assignment.name}",
user: user,
user_uuid: user.uuid,
title: "#{student_user.name}, #{assignment.name}",
user: student_user,
user_uuid: student_user.uuid,
alignment: alignment,
context: context,
root_account: root_account,

View File

@ -162,6 +162,16 @@ class Assignment < ActiveRecord::Base
}
scope :not_type_quiz_lti, -> { where.not(id: type_quiz_lti) }
scope :exclude_muted_associations_for_user, lambda { |user|
joins("LEFT JOIN #{Submission.quoted_table_name} ON submissions.user_id = #{User.connection.quote(user.id_for_database)} AND submissions.assignment_id = assignments.id")
.joins("LEFT JOIN #{PostPolicy.quoted_table_name} pc on pc.assignment_id = assignments.id")
.where(" assignments.id IS NULL"\
" OR submissions.posted_at IS NOT NULL"\
" OR assignments.grading_type = 'not_graded'"\
" OR pc.id IS NULL"\
" OR (pc.id IS NOT NULL AND pc.post_manually = False)")
}
validates_associated :external_tool_tag, if: :external_tool?
validate :group_category_changes_ok?
validate :turnitin_changes_ok?

View File

@ -302,7 +302,7 @@ module AccountReports
end
def combine_result(student, authoritative_results)
learning_outcome_result = json_to_outcome_result(authoritative_results)
learning_outcome_result = convert_to_learning_outcome_result(authoritative_results)
base_student = student.attributes.merge(
{
"learning outcome mastered" => learning_outcome_result.mastery,

View File

@ -45,10 +45,11 @@ module Outcomes
learning_outcome_id: outcomes.map(&:id)
)
# muted associations is applied to remove assignments that students
# are not yet allowed to see:
# are not yet allowed to view:
# Assignment Grades have not be posted yet (i.e. Submission.posted_at = nil)
# PostPolicy.post_manually is false or null
# PostPolicy.post_manually is false & the submission is not posted
# Assignment grading_type is not_graded
# see result_analytics_spec.rb for more details around what is excluded/included
unless context.grants_any_right?(user, :manage_grades, :view_all_grades)
results = results.exclude_muted_associations
end
@ -72,12 +73,25 @@ module Outcomes
# :outcomes - The outcomes to lookup results for (required)
#
# Returns a relation of the results
def find_outcomes_service_outcome_results(opts)
def find_outcomes_service_outcome_results(user, opts)
required_opts = %i[users context outcomes]
required_opts.each { |p| raise "#{p} option is required" unless opts[p] }
users, context, outcomes = opts.values_at(*required_opts)
user_uuids = users.pluck(:uuid).join(",")
assignment_ids = Assignment.where(context: context).type_quiz_lti.pluck(:id).join(",")
# check if the logged in user has manage_grades & view_all_grades permissions
# if not, apply exclude_muted_associations to the assignment query
assignment_ids =
if context.grants_any_right?(user, :manage_grades, :view_all_grades)
Assignment.active.where(context: context).quiz_lti.pluck(:id).join(",")
else
# return if there is more than one user in users as this would indicate
# user with insufficient permissions accessing the LMGB
return if users.length > 1
Assignment.active.where(context: context).quiz_lti.exclude_muted_associations_for_user(users[0]).pluck(:id).join(",")
end
outcome_ids = outcomes.pluck(:id).join(",")
handle_outcome_service_results(
get_lmgb_results(context, assignment_ids, "canvas.assignment.quizzes", outcome_ids, user_uuids),

View File

@ -96,24 +96,21 @@ module Factories
# Mocks calls to the OS endpoints:
#
# - retrieving data from the Canvas' LearningOutcomeResult table
# - transforming this data into a collection of JSON AuthoritativeResult objects
# - transforming this data into a collection of AuthoritativeResult hash objects
def authoritative_results_from_db
{
results:
LearningOutcomeResult.all.map do |lor|
{
user_uuid: lor.user.uuid,
points: lor.score,
points_possible: lor.possible,
external_outcome_id: lor.learning_outcome.id,
attempts: nil,
associated_asset_type: nil,
associated_asset_id: lor.alignment.content_id,
artifact_type: nil,
artifact_id: nil,
submitted_at: lor.submitted_at
}
end
}.to_json
LearningOutcomeResult.all.map do |lor|
{
user_uuid: lor.user.uuid,
points: lor.score,
points_possible: lor.possible,
external_outcome_id: lor.learning_outcome.id,
attempts: nil,
associated_asset_type: nil,
associated_asset_id: lor.alignment.content_id,
artifact_type: nil,
artifact_id: nil,
submitted_at: lor.submitted_at
}
end
end
end

View File

@ -35,18 +35,11 @@ describe OutcomeResultResolverHelper do
end
end
# TODO: authoritative_results_from_db to return a hash not json
# Since get_lmgb_results returns a parsed JSON object,
# we will need to update json_to_outcome_results, which is called in
# resolve_outcome_results, to handle an already parsed object.
# This will be done in a separate PS as it is not just a simple fix since this helper
# module is heavily dependent on the data to be in JSON. See OUT-5283
describe "removes the alignment result" do
it "if there is a rubric result for that student, assignment, and outcome" do
create_outcome
create_alignment
lor = create_learning_outcome_result @students[0], 1.0
authoritative_results = JSON.parse(authoritative_results_from_db)["results"]
# We cannot have two LORs for the same student, assignment, and outcome in the db
lor.workflow_state = "deleted"
@ -55,7 +48,7 @@ describe OutcomeResultResolverHelper do
create_alignment_with_rubric({ assignment: @assignment })
create_learning_outcome_result_from_rubric @students[0], 1.0
expect(resolve_outcome_results(authoritative_results).size).to eq 0
expect(resolve_outcome_results(authoritative_results_from_db).size).to eq 0
end
it "if a rubric result exists for multiple students for the same assignment and outcome" do
@ -63,7 +56,6 @@ describe OutcomeResultResolverHelper do
create_alignment
lor1 = create_learning_outcome_result @students[0], 1.0
lor2 = create_learning_outcome_result @students[1], 1.0
authoritative_results = JSON.parse(authoritative_results_from_db)["results"]
# We cannot have two LORs for the same student, assignment, and outcome in the db
lor1.workflow_state = "deleted"
@ -75,7 +67,7 @@ describe OutcomeResultResolverHelper do
create_learning_outcome_result_from_rubric @students[0], 1.0
create_learning_outcome_result_from_rubric @students[1], 1.0
expect(resolve_outcome_results(authoritative_results).size).to eq 0
expect(resolve_outcome_results(authoritative_results_from_db).size).to eq 0
end
it "for just one student if a rubric result exists for their assignment and outcome" do
@ -83,7 +75,6 @@ describe OutcomeResultResolverHelper do
create_alignment
lor = create_learning_outcome_result @students[0], 1.0
create_learning_outcome_result @students[1], 1.0
authoritative_results = JSON.parse(authoritative_results_from_db)["results"]
# We cannot have two LORs for the same student, assignment, and outcome in the db
lor.workflow_state = "deleted"
@ -92,7 +83,7 @@ describe OutcomeResultResolverHelper do
create_alignment_with_rubric({ assignment: @assignment })
create_learning_outcome_result_from_rubric @students[0], 1.0
expect(resolve_outcome_results(authoritative_results).size).to eq 1
expect(resolve_outcome_results(authoritative_results_from_db).size).to eq 1
end
it "for an assignment with multiple outcomes aligned, where one outcome has a rubric result for a student" do
@ -103,7 +94,6 @@ describe OutcomeResultResolverHelper do
create_outcome
create_alignment
lor = create_learning_outcome_result @students[0], 1.0
authoritative_results = JSON.parse(authoritative_results_from_db)["results"]
# We cannot have two LORs for the same student, assignment, and outcome in the db
lor.workflow_state = "deleted"
@ -112,7 +102,7 @@ describe OutcomeResultResolverHelper do
create_alignment_with_rubric({ assignment: @assignment })
create_learning_outcome_result_from_rubric @students[0], 1.0
expect(resolve_outcome_results(authoritative_results).size).to eq 1
expect(resolve_outcome_results(authoritative_results_from_db).size).to eq 1
end
end
@ -121,46 +111,42 @@ describe OutcomeResultResolverHelper do
create_outcome
create_alignment
create_learning_outcome_result @students[0], 1.0
authoritative_results = JSON.parse(authoritative_results_from_db)["results"]
expect(resolve_outcome_results(authoritative_results).size).to eq 1
expect(resolve_outcome_results(authoritative_results_from_db).size).to eq 1
end
it "if there is no rubric result for that student" do
create_outcome
create_alignment
create_learning_outcome_result @students[0], 1.0
authoritative_results = JSON.parse(authoritative_results_from_db)["results"]
create_alignment_with_rubric({ assignment: @assignment })
create_learning_outcome_result_from_rubric @students[1], 1.0
expect(resolve_outcome_results(authoritative_results).size).to eq 1
expect(resolve_outcome_results(authoritative_results_from_db).size).to eq 1
end
it "if there is no rubric result for that assignment" do
create_outcome
create_alignment
create_learning_outcome_result @students[2], 1.0
authoritative_results = JSON.parse(authoritative_results_from_db)["results"]
create_alignment_with_rubric
create_learning_outcome_result_from_rubric @students[2], 1.0
expect(resolve_outcome_results(authoritative_results).size).to eq 1
expect(resolve_outcome_results(authoritative_results_from_db).size).to eq 1
end
it "if there is no rubric result for that outcome" do
create_outcome
create_alignment
create_learning_outcome_result @students[0], 1.0
authoritative_results = JSON.parse(authoritative_results_from_db)["results"]
create_outcome
create_alignment_with_rubric
create_learning_outcome_result_from_rubric @students[0], 1.0
expect(resolve_outcome_results(authoritative_results).size).to eq 1
expect(resolve_outcome_results(authoritative_results_from_db).size).to eq 1
end
end
end

View File

@ -133,24 +133,21 @@ describe OutcomesServiceAuthoritativeResultsHelper do
# Mocks calls to the OS endpoints:
#
# - retrieving data from the Canvas' LearningOutcomeResult table
# - transforming this data into a collection of JSON AuthoritativeResult objects
# - transforming this data into a collection of AuthoritativeResult objects
def authoritative_results_from_db
{
results:
LearningOutcomeResult.all.map do |lor|
{
user_uuid: lor.user.uuid,
points: lor.score,
points_possible: lor.possible,
external_outcome_id: lor.learning_outcome.id,
attempts: nil,
associated_asset_type: nil,
associated_asset_id: lor.alignment.content_id,
artifact: lor.artifact,
submitted_at: lor.submitted_at
}
end
}.to_json
LearningOutcomeResult.all.map do |lor|
{
user_uuid: lor.user.uuid,
points: lor.score,
points_possible: lor.possible,
external_outcome_id: lor.learning_outcome.id,
attempts: nil,
associated_asset_type: nil,
associated_asset_id: lor.alignment.content_id,
artifact: lor.artifact,
submitted_at: lor.submitted_at
}
end
end
describe "percentage and mastery calculation" do
@ -163,7 +160,7 @@ describe OutcomesServiceAuthoritativeResultsHelper do
create_learning_outcome_result @students[0], points, { points_possible: 19.0 }
end
results = json_to_outcome_results(authoritative_results_from_db)
results = convert_to_learning_outcome_results(authoritative_results_from_db)
expect(results.size).to eq 20
results.each do |r|
@ -175,7 +172,7 @@ describe OutcomesServiceAuthoritativeResultsHelper do
end
end
describe "#json_to_outcome_result" do
describe "#convert_to_learning_outcome_result" do
it "sets artifact to submission" do
create_outcome
create_alignment
@ -188,7 +185,7 @@ describe OutcomesServiceAuthoritativeResultsHelper do
user_uuid: @students[0].uuid,
associated_asset_type: "canvas.assignment.quizzes"
}
learning_outcome_result = json_to_outcome_result(ar_hash)
learning_outcome_result = convert_to_learning_outcome_result(ar_hash)
expect(learning_outcome_result.artifact_id).to eq submission.id
expect(learning_outcome_result.artifact_type).to eq "Submission"
@ -206,7 +203,7 @@ describe OutcomesServiceAuthoritativeResultsHelper do
create_learning_outcome_result @students[0], 3.0
from_lor = rollup_user_results LearningOutcomeResult.all.to_a
from_ar = json_to_rollup_scores(authoritative_results_from_db)
from_ar = rollup_scores(authoritative_results_from_db)
expect(from_lor.size).to eq 2
from_lor.each_with_index do |ru, i|
@ -233,7 +230,7 @@ describe OutcomesServiceAuthoritativeResultsHelper do
end
from_lor = rollup_user_results LearningOutcomeResult.all.to_a
from_ar = json_to_rollup_scores(authoritative_results_from_db)
from_ar = rollup_scores(authoritative_results_from_db)
expect(from_lor.size).to eq 0
@ -251,7 +248,7 @@ describe OutcomesServiceAuthoritativeResultsHelper do
end
from_lor = rollup_user_results LearningOutcomeResult.all.to_a
from_ar = json_to_rollup_scores(authoritative_results_from_db)
from_ar = rollup_scores(authoritative_results_from_db)
expect(from_lor.size).to eq 1
expect(from_lor[0].count).to eq 2
@ -270,7 +267,7 @@ describe OutcomesServiceAuthoritativeResultsHelper do
end
from_lor = rollup_user_results LearningOutcomeResult.all.to_a
from_ar = json_to_rollup_scores(authoritative_results_from_db)
from_ar = rollup_scores(authoritative_results_from_db)
expect(from_lor[0].score).to eq 3.0
@ -295,7 +292,7 @@ describe OutcomesServiceAuthoritativeResultsHelper do
create_from_scores [1.0, 2.0, 3.0, 4.0], 1
from_lor = rollup_user_results LearningOutcomeResult.all.to_a
from_ar = json_to_rollup_scores(authoritative_results_from_db)
from_ar = rollup_scores(authoritative_results_from_db)
expect(from_lor.size).to eq 6
# without sorting the arrays this spec may fail at Flakey Spec Catcher
@ -313,7 +310,7 @@ describe OutcomesServiceAuthoritativeResultsHelper do
end
from_lor = rollup_user_results LearningOutcomeResult.all.to_a
from_ar = json_to_rollup_scores(authoritative_results_from_db)
from_ar = rollup_scores(authoritative_results_from_db)
expect(from_lor.map(&:score)).to eq [3.75]
@ -332,7 +329,7 @@ describe OutcomesServiceAuthoritativeResultsHelper do
end
from_lor = rollup_user_results LearningOutcomeResult.all.to_a
from_ar = json_to_rollup_scores(authoritative_results_from_db)
from_ar = rollup_scores(authoritative_results_from_db)
expect(from_lor.size).to eq 2
# without sorting the arrays this spec may fail at Flakey Spec Catcher
@ -355,7 +352,7 @@ describe OutcomesServiceAuthoritativeResultsHelper do
create_outcome
create_alignment
create_learning_outcome_result @students[2], 2.0
results = json_to_outcome_results(authoritative_results_from_db)
results = convert_to_learning_outcome_results(authoritative_results_from_db)
rollups = outcome_results_rollups(results: results, users: @students)
os_rollups = outcome_service_results_rollups(results)
@ -379,7 +376,7 @@ describe OutcomesServiceAuthoritativeResultsHelper do
create_outcome
create_alignment
create_learning_outcome_result @students[1], 3.0
results = json_to_outcome_results(authoritative_results_from_db)
results = convert_to_learning_outcome_results(authoritative_results_from_db)
rollups = outcome_service_results_rollups(results)
expect(rollups.count).to eq 2
@ -393,7 +390,7 @@ describe OutcomesServiceAuthoritativeResultsHelper do
create_outcome
create_alignment
create_learning_outcome_result @students[0], 3.0
results = json_to_outcome_results(authoritative_results_from_db)
results = convert_to_learning_outcome_results(authoritative_results_from_db)
rollups = outcome_service_results_rollups(results)
expect(rollups.count).to eq 1

View File

@ -78,8 +78,14 @@ describe Outcomes::ResultAnalytics do
outcome_ids = @outcomes.pluck(:id).join(",")
uuids = "#{@students[0].uuid},#{@students[1].uuid},#{@students[2].uuid}"
expect(ra).to receive(:get_lmgb_results).with(@course, quiz.id.to_s, "canvas.assignment.quizzes", outcome_ids, uuids).and_return(nil)
opts = { context: @course, users: @students, outcomes: @outcomes }
ra.send(:find_outcomes_service_outcome_results, @teacher, opts)
end
ra.send(:find_outcomes_service_outcome_results, { context: @course, users: @students, outcomes: @outcomes })
it "returns nil if session user is a student and there are more than 1 users sent in the opts" do
opts = { context: @course, users: @students, outcomes: @outcomes }
results = ra.find_outcomes_service_outcome_results(@student, opts)
expect(results).to eq nil
end
describe "#handle_outcome_service_results" do

View File

@ -5686,6 +5686,48 @@ describe Assignment do
end
end
describe "scope: exclude_muted_associations_for_user" do
before do
@assignment = assignment_model(course: @course)
end
context "includes assignment" do
it "posted submission" do
@assignment.submission_for_student(@student).update!(posted_at: Time.zone.now)
expect(Assignment.exclude_muted_associations_for_user(@student).count).to eq 1
end
it "unposted submissions with default posting policy" do
# By default, an automatic post policy (post_manually: false) is associated to
# an assignment. Now that post policy is included in exclude_muted_associations
# the outcome result will appear in LMGB/SLMGB. It will not appear for manual
# post policy assignment until the submission is posted. See "manual posting
# policy" test cases below.
expect(Assignment.exclude_muted_associations_for_user(@student).count).to eq 1
end
it "not graded assignment with unposted submissions with default posting policy" do
@assignment.update!(grading_type: "not_graded")
expect(Assignment.exclude_muted_associations_for_user(@student).count).to eq 1
end
it "not graded assignment with unposted submissions with manual posting policy" do
@assignment.post_policy.update!(post_manually: true)
@assignment.update!(grading_type: "not_graded")
expect(Assignment.exclude_muted_associations_for_user(@student).count).to eq 1
end
end
context "excludes assignment" do
it "graded assignment with unposted submissions with manual posting policy" do
submission = Submission.find_by(user_id: @user.id, assignment_id: @assignment.id)
expect(submission.posted?).to eq false
@assignment.post_policy.update!(post_manually: true)
expect(Assignment.exclude_muted_associations_for_user(@student).count).to eq 0
end
end
end
describe "linked submissions" do
shared_examples_for "submittable" do
before :once do