407 lines
17 KiB
Ruby
407 lines
17 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
#
|
|
# Copyright (C) 2018 - 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/>.
|
|
#
|
|
|
|
module UserLearningObjectScopes
|
|
ULOS_DEFAULT_LIMIT = 15
|
|
|
|
# This is a helper method for converting a method call's regular parameters
|
|
# and named parameters into a hash. `opts` is considered to be a keyword that
|
|
# contains the rest of the named parameters passed to the method. The `opts`
|
|
# parameter is merged into the return value.
|
|
#
|
|
# This is useful for using the parameters as a cache key and for forwarding
|
|
# named parameters to another method.
|
|
def _params_hash(parent_binding)
|
|
caller_method = method(caller_locations(1, 1).first.base_label)
|
|
caller_param_names = caller_method.parameters.map(&:last)
|
|
param_values = caller_param_names.each_with_object({}) { |v, h| h[v] = parent_binding.local_variable_get(v) }
|
|
opts = param_values[:opts]
|
|
param_values = param_values.except(:opts).merge(opts) if opts
|
|
param_values
|
|
end
|
|
|
|
def ignore_item!(asset, purpose, permanent = false)
|
|
begin
|
|
# more likely this doesn't exist, so try the create first
|
|
asset.ignores.create!(:user => self, :purpose => purpose, :permanent => permanent)
|
|
rescue ActiveRecord::RecordNotUnique
|
|
asset.shard.activate do
|
|
ignore = asset.ignores.where(user_id: self, purpose: purpose).first
|
|
ignore.permanent = permanent
|
|
ignore.save!
|
|
end
|
|
end
|
|
self.touch
|
|
end
|
|
|
|
def assignments_visible_in_course(course)
|
|
return course.active_assignments if course.grants_any_right?(self, :read_as_admin,
|
|
:manage_grades,
|
|
:manage_assignments)
|
|
published_visible_assignments = course.active_assignments.published
|
|
published_visible_assignments = DifferentiableAssignment.scope_filter(published_visible_assignments,
|
|
self, course, is_teacher: false)
|
|
published_visible_assignments
|
|
end
|
|
|
|
def course_ids_for_todo_lists(permission_type, course_ids: nil, contexts: nil, include_concluded: false)
|
|
shard.activate do
|
|
course_ids_result = GuardRail.activate(:secondary) do
|
|
if include_concluded
|
|
all_course_ids
|
|
else
|
|
case permission_type
|
|
when :student
|
|
participating_student_course_ids
|
|
else
|
|
manageable_enrollments_by_permission(permission_type).map(&:course_id)
|
|
end
|
|
end
|
|
end
|
|
|
|
course_ids_result &= course_ids if course_ids
|
|
course_ids_result &= Array.wrap(contexts).select{|c| c.is_a? Course}.map(&:id) if contexts
|
|
course_ids_result
|
|
end
|
|
end
|
|
|
|
def group_ids_for_todo_lists(group_ids: nil, contexts: nil)
|
|
shard.activate do
|
|
group_ids_result = cached_current_group_memberships_by_date.map(&:group_id)
|
|
group_ids_result &= group_ids if group_ids
|
|
group_ids_result &= contexts.select{|g| g.is_a? Group}.map(&:id) if contexts
|
|
group_ids_result
|
|
end
|
|
end
|
|
|
|
def objects_needing(
|
|
object_type, purpose, participation_type, params, expires_in,
|
|
limit: ULOS_DEFAULT_LIMIT, scope_only: false,
|
|
course_ids: nil, group_ids: nil, contexts: nil, include_concluded: false,
|
|
include_ignored: false, include_ungraded: false
|
|
)
|
|
original_shard = Shard.current
|
|
shard.activate do
|
|
course_ids = course_ids_for_todo_lists(participation_type,
|
|
course_ids: course_ids, contexts: contexts, include_concluded: include_concluded)
|
|
group_ids = group_ids_for_todo_lists(group_ids: group_ids, contexts: contexts)
|
|
ids_by_shard = Hash.new({course_ids: [], group_ids: []})
|
|
Shard.partition_by_shard(course_ids) do |shard_course_ids|
|
|
ids_by_shard[Shard.current] = { course_ids: shard_course_ids, group_ids: [] }
|
|
end
|
|
Shard.partition_by_shard(group_ids) do |shard_group_ids|
|
|
shard_hash = ids_by_shard[Shard.current]
|
|
shard_hash[:group_ids] = shard_group_ids
|
|
ids_by_shard[Shard.current] = shard_hash
|
|
end
|
|
|
|
if scope_only
|
|
original_shard.activate do
|
|
# only provide scope on current shard
|
|
shard_course_ids = ids_by_shard.dig(original_shard, :course_ids)
|
|
shard_group_ids = ids_by_shard.dig(original_shard, :group_ids)
|
|
if shard_course_ids.present? || shard_group_ids.present?
|
|
return yield(*arguments_for_objects_needing(
|
|
object_type, purpose, shard_course_ids, shard_group_ids, participation_type,
|
|
include_ignored: include_ignored,
|
|
include_ungraded: include_ungraded,
|
|
))
|
|
end
|
|
return object_type.constantize.none # fallback
|
|
end
|
|
else
|
|
course_ids_cache_key = Digest::MD5.hexdigest(course_ids.sort.join('/'))
|
|
params_cache_key = Digest::MD5.hexdigest(ActiveSupport::Cache.expand_cache_key(params))
|
|
cache_key = [self, "#{object_type}_needing_#{purpose}", course_ids_cache_key, params_cache_key].cache_key
|
|
|
|
Rails.cache.fetch_with_batched_keys(cache_key, expires_in: expires_in, batch_object: self, batched_keys: :todo_list) do
|
|
result = GuardRail.activate(:secondary) do
|
|
ids_by_shard.flat_map do |shard, shard_hash|
|
|
shard.activate do
|
|
yield(*arguments_for_objects_needing(
|
|
object_type, purpose, shard_hash[:course_ids], shard_hash[:group_ids], participation_type,
|
|
include_ignored: include_ignored,
|
|
include_ungraded: include_ungraded
|
|
))
|
|
end
|
|
end
|
|
end
|
|
result = result[0...limit] if limit # limit is sometimes passed in as nil explicitly
|
|
result
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def arguments_for_objects_needing(
|
|
object_type, purpose, shard_course_ids, shard_group_ids, participation_type,
|
|
include_ignored: false,
|
|
include_ungraded: false
|
|
)
|
|
scope = object_type.constantize
|
|
scope = scope.not_ignored_by(self, purpose) unless include_ignored
|
|
scope = scope.for_course(shard_course_ids) if ['Assignment', 'Quizzes::Quiz'].include?(object_type)
|
|
if object_type == 'Assignment'
|
|
scope = participation_type == :student ? scope.published : scope.active
|
|
scope = scope.expecting_submission unless include_ungraded
|
|
end
|
|
[scope, shard_course_ids, shard_group_ids]
|
|
end
|
|
|
|
def assignments_for_student(
|
|
purpose,
|
|
limit: ULOS_DEFAULT_LIMIT,
|
|
due_after: 2.weeks.ago,
|
|
due_before: 2.weeks.from_now,
|
|
cache_timeout: 120.minutes,
|
|
include_locked: false,
|
|
**opts # arguments that are just forwarded to objects_needing
|
|
)
|
|
params = _params_hash(binding)
|
|
objects_needing('Assignment', purpose, :student, params, cache_timeout,
|
|
limit: limit, **opts) do |assignment_scope|
|
|
assignments = assignment_scope.due_between_for_user(due_after, due_before, self)
|
|
assignments = assignments.need_submitting_info(id, limit) if purpose == 'submitting'
|
|
assignments = assignments.having_submissions_for_user(id) if purpose == 'submitted'
|
|
if purpose == 'submitting'
|
|
assignments = assignments.submittable.or(assignments.where('assignments.user_due_date > ?', Time.zone.now))
|
|
end
|
|
assignments = assignments.not_locked unless include_locked
|
|
assignments
|
|
end
|
|
end
|
|
|
|
def assignments_needing_submitting(
|
|
due_after: 4.weeks.ago,
|
|
due_before: 1.week.from_now,
|
|
scope_only: false,
|
|
include_concluded: false,
|
|
**opts # forward args to assignments_for_student
|
|
)
|
|
opts[:cache_timeout] = 15.minutes
|
|
params = _params_hash(binding)
|
|
assignments = assignments_for_student('submitting', **params)
|
|
return assignments if scope_only
|
|
select_available_assignments(assignments, include_concluded: include_concluded)
|
|
end
|
|
|
|
def submitted_assignments(
|
|
scope_only: false,
|
|
include_concluded: false,
|
|
**opts # forward args to assignments_for_student
|
|
)
|
|
params = _params_hash(binding)
|
|
assignments = assignments_for_student('submitted', **params)
|
|
return assignments if scope_only
|
|
select_available_assignments(assignments, include_concluded: include_concluded)
|
|
end
|
|
|
|
def ungraded_quizzes(
|
|
limit: ULOS_DEFAULT_LIMIT,
|
|
due_after: Time.zone.now,
|
|
due_before: 1.week.from_now,
|
|
needing_submitting: false,
|
|
scope_only: false,
|
|
include_locked: false,
|
|
include_concluded: false,
|
|
**opts # arguments that are just forwarded to objects_needing
|
|
)
|
|
params = _params_hash(binding)
|
|
opts.merge!(params.slice(:limit, :scope_only, :include_concluded))
|
|
objects_needing('Quizzes::Quiz', 'viewing', :student, params, 15.minutes, **opts) do |quiz_scope|
|
|
quizzes = quiz_scope.available
|
|
quizzes = quizzes.not_locked unless include_locked
|
|
quizzes = quizzes.
|
|
ungraded_due_between_for_user(due_after, due_before, self).
|
|
preload(:context)
|
|
quizzes = quizzes.need_submitting_info(id, limit) if needing_submitting
|
|
return quizzes if scope_only
|
|
select_available_assignments(quizzes, include_concluded: include_concluded)
|
|
end
|
|
end
|
|
|
|
def submissions_needing_peer_review(
|
|
limit: ULOS_DEFAULT_LIMIT,
|
|
due_after: 2.weeks.ago,
|
|
due_before: 2.weeks.from_now,
|
|
scope_only: false,
|
|
include_ignored: false,
|
|
**opts # arguments that are just forwarded to objects_needing
|
|
)
|
|
params = _params_hash(binding)
|
|
opts.merge!(params.slice(:limit, :scope_only, :include_ignored))
|
|
objects_needing('AssessmentRequest', 'reviewing', :student, params, 15.minutes, **opts) do |ar_scope, shard_course_ids|
|
|
ar_scope = ar_scope.joins(submission: :assignment).
|
|
joins("INNER JOIN #{Submission.quoted_table_name} AS assessor_asset ON assessment_requests.assessor_asset_id = assessor_asset.id
|
|
AND assessor_asset.assignment_id = assignments.id").
|
|
where(assessor_id: id).
|
|
where(assessor_asset: { course_id: shard_course_ids })
|
|
ar_scope = ar_scope.incomplete unless scope_only
|
|
ar_scope = ar_scope.for_courses(shard_course_ids)
|
|
|
|
# The below merging of scopes mimics a portion of the behavior for checking the access policy
|
|
# for the submissions, ensuring that the user has access and can read & comment on them.
|
|
# The check for making sure that the user is a participant in the course is already made
|
|
# by using `course_ids_for_todo_lists` through `objects_needing`
|
|
ar_scope = ar_scope.merge(Submission.active).
|
|
merge(Assignment.published.where(peer_reviews: true))
|
|
|
|
if due_before
|
|
ar_scope = ar_scope.where("assessor_asset.cached_due_date <= ?", due_before)
|
|
end
|
|
|
|
if due_after
|
|
ar_scope = ar_scope.where("assessor_asset.cached_due_date > ?", due_after)
|
|
end
|
|
|
|
if scope_only
|
|
ar_scope
|
|
else
|
|
result = limit ? ar_scope.take(limit) : ar_scope.to_a
|
|
result
|
|
end
|
|
end
|
|
end
|
|
|
|
# opts forwaded to course_ids_for_todo_lists
|
|
def submissions_needing_grading_count(**opts)
|
|
if ::Canvas::DynamicSettings.find(tree: :private, cluster: Shard.current.database_server.id)["disable_needs_grading_queries"]
|
|
return 0
|
|
end
|
|
course_ids = course_ids_for_todo_lists(:manage_grades, **opts)
|
|
Submission.active.
|
|
needs_grading.
|
|
joins("INNER JOIN #{Enrollment.quoted_table_name} AS grader_enrollments ON assignments.context_id = grader_enrollments.course_id").
|
|
where(assignments: {context_id: course_ids}).
|
|
merge(Assignment.expecting_submission).
|
|
merge(Assignment.published).
|
|
where(grader_enrollments: {workflow_state: 'active', user_id: self, type: ['TeacherEnrollment', 'TaEnrollment']}).
|
|
where("grader_enrollments.limit_privileges_to_course_section = 'f'
|
|
OR grader_enrollments.course_section_id = enrollments.course_section_id").
|
|
where("NOT EXISTS (?)",
|
|
Ignore.where(asset_type: 'Assignment',
|
|
user_id: self,
|
|
purpose: 'grading').where('asset_id=submissions.assignment_id')).count
|
|
end
|
|
|
|
def assignments_needing_grading(limit: ULOS_DEFAULT_LIMIT, scope_only: false, **opts)
|
|
if ::Canvas::DynamicSettings.find(tree: :private, cluster: Shard.current.database_server.id)["disable_needs_grading_queries"]
|
|
return scope_only ? Assignment.none : []
|
|
end
|
|
params = _params_hash(binding)
|
|
# not really any harm in extending the expires_in since we touch the user anyway when grades change
|
|
objects_needing('Assignment', 'grading', :manage_grades, params, 120.minutes, **params) do |assignment_scope|
|
|
if Setting.get('assignments_needing_grading_new_style', 'true') == 'true'
|
|
submissions_needing_grading = Submission.select(:assignment_id, :user_id).
|
|
joins("INNER JOIN (#{assignment_scope.to_sql}) assignments ON assignment_id=assignments.id").
|
|
where(Submission.needs_grading_conditions)
|
|
student_enrollments = Enrollment.from("#{Enrollment.quoted_table_name} student_enrollments").
|
|
select("1").
|
|
where("student_enrollments.course_id=assignments.context_id").
|
|
where("student_enrollments.user_id=submissions_needing_grading.user_id AND student_enrollments.workflow_state='active'").
|
|
where("(enrollments.limit_privileges_to_course_section='f' OR student_enrollments.course_section_id=enrollments.course_section_id)")
|
|
as = assignment_scope.joins("INNER JOIN (#{submissions_needing_grading.to_sql}) AS submissions_needing_grading ON assignments.id=submissions_needing_grading.assignment_id").
|
|
where("EXISTS(?)", student_enrollments)
|
|
else
|
|
as = assignment_scope.
|
|
where("EXISTS (#{grader_visible_submissions_sql})")
|
|
end
|
|
as = as.joins("INNER JOIN #{Enrollment.quoted_table_name} ON enrollments.course_id = assignments.context_id").
|
|
where(enrollments: {user_id: self, workflow_state: 'active', type: ['TeacherEnrollment', 'TaEnrollment']}).
|
|
group('assignments.id').
|
|
order('assignments.due_at').
|
|
preload(:context)
|
|
if scope_only
|
|
as # This needs the below `select` somehow to work
|
|
else
|
|
GuardRail.activate(:secondary) do
|
|
as.lazy.reject{|a| Assignments::NeedsGradingCountQuery.new(a, self).count == 0 }.take(limit).to_a
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def grader_visible_submissions_sql
|
|
"SELECT submissions.id
|
|
FROM #{Submission.quoted_table_name}
|
|
INNER JOIN #{Enrollment.quoted_table_name} AS student_enrollments ON student_enrollments.user_id = submissions.user_id
|
|
AND student_enrollments.course_id = submissions.course_id
|
|
WHERE submissions.assignment_id = assignments.id
|
|
AND (enrollments.limit_privileges_to_course_section = 'f'
|
|
OR enrollments.course_section_id = student_enrollments.course_section_id)
|
|
AND #{Submission.needs_grading_conditions}
|
|
AND student_enrollments.workflow_state = 'active'"
|
|
end
|
|
|
|
def assignments_needing_moderation(
|
|
limit: ULOS_DEFAULT_LIMIT,
|
|
scope_only: false,
|
|
**opts # arguments that are just forwarded to objects_needing
|
|
)
|
|
params = _params_hash(binding)
|
|
objects_needing('Assignment', 'moderation', :select_final_grade, params, 120.minutes, **params) do |assignment_scope|
|
|
scope = assignment_scope.active.
|
|
expecting_submission.
|
|
where(final_grader: self, moderated_grading: true).
|
|
where("assignments.grades_published_at IS NULL").
|
|
where(id: ModeratedGrading::ProvisionalGrade.joins(:submission).
|
|
where("submissions.assignment_id=assignments.id").
|
|
where(Submission.needs_grading_conditions).distinct.select(:assignment_id)).
|
|
preload(:context)
|
|
if scope_only
|
|
scope # Also need to check the rights like below
|
|
else
|
|
scope.lazy.select{|a| a.permits_moderation?(self)}.take(limit).to_a
|
|
end
|
|
end
|
|
end
|
|
|
|
def discussion_topics_needing_viewing(
|
|
due_after:,
|
|
due_before:,
|
|
**opts # arguments that are just forwarded to objects_needing
|
|
)
|
|
params = _params_hash(binding)
|
|
objects_needing('DiscussionTopic', 'viewing', :student, params, 120.minutes, **opts) do |topics_context, shard_course_ids, shard_group_ids|
|
|
topics_context.
|
|
active.
|
|
published.
|
|
for_courses_and_groups(shard_course_ids, shard_group_ids).
|
|
todo_date_between(due_after, due_before).
|
|
visible_to_student_sections(self)
|
|
end
|
|
end
|
|
|
|
def wiki_pages_needing_viewing(
|
|
due_after:,
|
|
due_before:,
|
|
**opts # arguments that are just forwarded to objects_needing
|
|
)
|
|
params = _params_hash(binding)
|
|
objects_needing('WikiPage', 'viewing', :student, params, 120.minutes, **opts) do |wiki_pages_context, shard_course_ids, shard_group_ids|
|
|
wiki_pages_context.
|
|
available_to_planner.
|
|
visible_to_user(self).
|
|
for_courses_and_groups(shard_course_ids, shard_group_ids).
|
|
todo_date_between(due_after, due_before)
|
|
end
|
|
end
|
|
end
|