canvas-lms/lib/messageable_user.rb

225 lines
7.4 KiB
Ruby

# frozen_string_literal: true
#
# Copyright (C) 2013 - 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/>.
#
class MessageableUser < User
COLUMNS = %w[id updated_at pronouns short_name name avatar_image_url avatar_image_source].map { |col| "users.#{col}" }
SELECT = COLUMNS.join(", ")
AVAILABLE_CONDITIONS = "users.workflow_state IN ('registered', 'pre_registered')"
def self.build_select(options = {})
options = {
common_course_column: nil,
common_group_column: nil,
common_role_column: nil
}.merge(options)
common_course_sql =
if options[:common_role_column]
raise ArgumentError unless options[:common_course_column]
connection.func(:group_concat,
:"#{options[:common_course_column]}::text || ':' || #{options[:common_role_column]}::text")
else
"NULL::text"
end
common_group_sql =
if options[:common_group_column].is_a?(String)
connection.func(:group_concat, options[:common_group_column].to_sym)
elsif options[:common_group_column]
options[:common_group_column].to_s
else
"NULL::text"
end
"#{SELECT}, sortable_name, #{common_course_sql} AS common_courses, #{common_group_sql} AS common_groups"
end
def self.prepped(options = {})
options = {
strict_checks: true,
include_deleted: false
}.merge(options)
# if either of our common course/group id columns are column names (vs.
# integers), they need to go in the group by. we turn the first element
# into an array and add them to that, so that both postgresql/mysql are
# happy (see the documentation on group_concat if you're curious about
# the gory details)
columns = COLUMNS.dup
if options[:common_course_column].is_a?(String) || options[:common_group_column].is_a?(String)
head = [columns.shift]
head << options[:common_course_column] if options[:common_course_column].is_a?(String)
head << options[:common_group_column] if options[:common_group_column].is_a?(String)
columns.unshift(head)
end
scope = self
.select(MessageableUser.build_select(options))
.group(MessageableUser.connection.group_by(*columns))
.order(User.sortable_name_order_by_clause).order(Arel.sql("users.id"))
if options[:strict_checks]
scope.where(AVAILABLE_CONDITIONS)
elsif !options[:include_deleted]
scope.where("users.workflow_state <> 'deleted'")
else
scope
end
end
def self.unfiltered(options = {})
prepped(options.merge(strict_checks: false))
end
def self.available(options = {})
prepped(options.merge(strict_checks: true))
end
def self.context_recipients(recipients)
recipients.grep(Calculator::CONTEXT_RECIPIENT)
end
def self.individual_recipients(recipients)
recipients.select do |id|
!id.is_a?(String) ||
id =~ Calculator::INDIVIDUAL_RECIPIENT
end.map(&:to_i)
end
def common_groups
common_contexts_on_current_shard(global_common_groups)
end
def common_courses
common_contexts_on_current_shard(global_common_courses)
end
# only MessageableUser::Calculator should access these directly. if you're
# outside the calculator, you almost certainly want the versions above that
# transpose to the current shard. additionally, any time you access these,
# make sure you're still on the same shard where common_course_id and/or
# common_group_id were queried
attr_accessor :global_common_courses, :global_common_groups
# this will be executed on the shard where the find was called (I think?).
# as such, we can correctly interpret local ids in the common_courses and
# common_groups
def populate_common_contexts
@global_common_courses = {}
read_attribute(:common_courses)&.to_s&.split(",")&.each do |common_course|
course_id, role = common_course.split(":")
course_id = course_id.to_i
# a course id of 0 indicates admin visibility without an actual shared
# course; don't "globalize" it
course_id = Shard.global_id_for(course_id) unless course_id.zero?
@global_common_courses[course_id] ||= []
@global_common_courses[course_id] << role
end
@global_common_groups = {}
read_attribute(:common_groups)&.to_s&.split(",")&.each do |group_id|
group_id = Shard.global_id_for(group_id.to_i)
@global_common_groups[group_id] ||= []
@global_common_groups[group_id] << "Member"
end
end
after_find :populate_common_contexts
def include_common_contexts_from(other)
combine_common_contexts(global_common_courses, other.global_common_courses)
combine_common_contexts(global_common_groups, other.global_common_groups)
end
def serializable_hash(options = {})
options[:except] ||= []
options[:except] << :bookmark
super
end
private
def common_contexts_on_current_shard(common_contexts)
local_common_contexts = {}
target_shard = Shard.current
return local_common_contexts if common_contexts.empty?
Shard.partition_by_shard(common_contexts.keys) do |sharded_ids|
sharded_ids.each do |id|
# a context id of 0 indicates admin visibility without an actual shared
# context; don't "globalize" it
global_id = (id == 0) ? id : Shard.global_id_for(id)
id = global_id unless Shard.current == target_shard
local_common_contexts[id] = common_contexts[global_id]
end
end
local_common_contexts
end
def combine_common_contexts(left, right)
right.each { |key, values| (left[key] ||= []).concat(values) }
end
# both bookmark_for and restrict_scope should always be executed on the
# same shard (not guaranteed, but we don't have to guarantee correctness if
# they aren't). so local ids here and local ids there have identical
# interpretation: local to Shard.current.
class MessageableUser::Bookmarker
def self.bookmark_for(user)
[user.sortable_name, user.id]
end
def self.validate(bookmark)
bookmark.is_a?(Array) &&
bookmark.size == 2 &&
bookmark[0].is_a?(String) &&
bookmark[1].is_a?(Integer)
end
# ordering is already guaranteed
def self.restrict_scope(scope, pager)
if pager.current_bookmark
name, id = pager.current_bookmark
scope_shard = scope.shard_value
id = Shard.relative_id_for(id, Shard.current, scope_shard) if scope_shard
condition = [
<<~SQL.squish,
#{User.sortable_name_order_by_clause} > ? OR
#{User.sortable_name_order_by_clause} = ? AND users.id > ?
SQL
name,
name,
id
]
if pager.include_bookmark
condition[0] << "OR #{User.sortable_name_order_by_clause} = ? AND users.id = ?"
condition.push(name, id)
end
scope.where(condition)
else
scope
end
end
end
end