diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index c8afefc0465..871b68ec364 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
#
# Copyright (C) 2011 - present Instructure, Inc.
#
@@ -26,10 +28,18 @@ class SearchController < ApplicationController
before_action :get_context, except: :recipients
def rubrics
- contexts = @current_user.management_contexts rescue []
+ contexts = begin
+ @current_user.management_contexts
+ rescue
+ []
+ end
res = []
contexts.each do |context|
- res += context.rubrics rescue []
+ res += begin
+ context.rubrics
+ rescue
+ []
+ end
end
res += Rubric.publicly_reusable.matching(params[:q])
res = res.select{|r| r.title.downcase.match(params[:q].downcase) }
@@ -120,49 +130,18 @@ class SearchController < ApplicationController
permissions << :send_messages if params[:messageable_only]
load_all_contexts :context => search_context, :permissions => permissions
- types = (params[:types] || [] + [params[:type]]).compact
- types |= [:course, :section, :group] if types.delete('context')
- types = if types.present?
- {:user => types.delete('user').present?, :context => types.present? && types.map(&:to_sym)}
- else
- {:user => true, :context => [:course, :section, :group]}
- end
-
params[:per_page] = nil if params[:per_page].to_i <= 0
- exclude = params[:exclude] || []
recipients = []
if params[:user_id]
known = @current_user.address_book.known_user(
params[:user_id],
context: params[:context],
- conversation_id: params[:from_conversation_id])
+ conversation_id: params[:from_conversation_id]
+ )
recipients << known if known
elsif params[:context] || params[:search]
- collections = []
- exclude_users, exclude_contexts = AddressBook.partition_recipients(exclude)
-
- if types[:context]
- collections << ['contexts', search_messageable_contexts(
- :search => params[:search],
- :context => params[:context],
- :synthetic_contexts => params[:synthetic_contexts],
- :include_inactive => params[:include_inactive],
- :messageable_only => params[:messageable_only],
- :exclude_ids => exclude_contexts,
- :search_all_contexts => params[:search_all_contexts],
- :types => types[:context]
- )]
- end
-
- if types[:user] && !@skip_users
- collections << ['participants', @current_user.address_book.search_users(
- search: params[:search],
- exclude_ids: exclude_users,
- context: params[:context],
- weak_checks: params[:skip_visibility_checks]
- )]
- end
+ collections = search_contexts_and_users(params)
recipients = BookmarkedCollection.concat(*collections)
recipients = Api.paginate(recipients, self, api_v1_search_recipients_url)
@@ -187,10 +166,10 @@ class SearchController < ApplicationController
# Only return courses that allow self enrollment. Defaults to false.
#
def all_courses
- @courses = Course.where(root_account_id: @domain_root_account)
- .where(indexed: true)
- .where(workflow_state: 'available')
- .order('created_at')
+ @courses = Course.where(root_account_id: @domain_root_account).
+ where(indexed: true).
+ where(workflow_state: 'available').
+ order('created_at')
@search = params[:search]
if @search.present?
@courses = @courses.where(@courses.wildcard('name', @search.to_s))
@@ -222,197 +201,4 @@ class SearchController < ApplicationController
return render :html => @contentHTML
end
end
-
- private
-
- # stupid bookmarker that instantiates the whole collection and then
- # "bookmarks" subsets of the collection by index. will want to improve this
- # eventually, but for now it's no worse than the old way, and lets us compose
- # the messageable contexts and messageable users for pagination.
- class ContextBookmarker
- def initialize(collection)
- @collection = collection
- end
-
- def bookmark_for(item)
- @collection.index(item)
- end
-
- def validate(bookmark)
- bookmark.is_a?(Integer)
- end
-
- def self.wrap(collection)
- BookmarkedCollection.build(self.new(collection)) do |pager|
- page_start = pager.current_bookmark ? pager.current_bookmark + 1 : 0
- page_end = page_start + pager.per_page
- pager.replace collection[page_start, page_end]
- pager.has_more! if collection.size > page_end
- pager
- end
- end
- end
-
- def search_messageable_contexts(options={})
- ContextBookmarker.wrap(matching_contexts(options))
- end
-
- def matching_contexts(options)
- context_name = options[:context]
- avatar_url = avatar_url_for_group
- terms = options[:search].to_s.downcase.strip.split(/\s+/)
- exclude = options[:exclude_ids] || []
-
- result = []
- if context_name.nil?
- result = if terms.blank?
- courses = @contexts[:courses].values
- group_ids = @current_user.current_groups.shard(@current_user).pluck(:id)
- groups = @contexts[:groups].slice(*group_ids).values
- courses + groups
- else
- @contexts.values_at(*options[:types].map{|t|t.to_s.pluralize.to_sym}).compact.map(&:values).flatten
- end
- elsif options[:synthetic_contexts]
- if context_name =~ /\Acourse_(\d+)(_(groups|sections))?\z/ && (course = @contexts[:courses][$1.to_i]) && messageable_context_states[course[:state]]
- sections = @contexts[:sections].values.select{ |section| section[:parent] == {:course => course[:id]} }
- groups = @contexts[:groups].values.select{ |group| group[:parent] == {:course => course[:id]} }
- case context_name
- when /\Acourse_\d+\z/
- if terms.present? || options[:search_all_contexts] # search all groups and sections (and users)
- result = sections + groups
- else # otherwise we show synthetic contexts
- result = synthetic_contexts_for(course, context_name)
- found_custom_sections = sections.any? { |s| s[:id] != course[:default_section_id] }
- result << {:id => "#{context_name}_sections", :name => t(:course_sections, "Course Sections"), :item_count => sections.size, :type => :context} if found_custom_sections
- result << {:id => "#{context_name}_groups", :name => t(:student_groups, "Student Groups"), :item_count => groups.size, :type => :context} if groups.size > 0
- return result
- end
- when /\Acourse_\d+_groups\z/
- @skip_users = true # whether searching or just enumerating, we just want groups
- result = groups
- when /\Acourse_\d+_sections\z/
- @skip_users = true # ditto
- result = sections
- end
- elsif context_name =~ /\Asection_(\d+)\z/ && (section = @contexts[:sections][$1.to_i]) && messageable_context_states[section[:state]]
- if terms.present? # we'll just search the users
- result = []
- else
- return synthetic_contexts_for(course_for_section(section), context_name)
- end
- end
- end
-
- result = if options[:search].present?
- result.sort_by{ |context|
- [
- context_state_ranks[context[:state]],
- context_type_ranks[context[:type]],
- Canvas::ICU.collation_key(context[:name]),
- context[:id]
- ]
- }
- else
- result.sort_by{ |context|
- [
- Canvas::ICU.collation_key(context[:name]),
- context[:id]
- ]
- }
- end
-
- # pre-calculate asset strings and permissions
- result.each do |context|
- context[:asset_string] = "#{context[:type]}_#{context[:id]}"
- if context[:type] == :section
- # TODO: have load_all_contexts actually return section-level
- # permissions. but before we do that, sections will need to grant many
- # more permission (possibly inherited from the course, like
- # :send_messages_all)
- context[:permissions] = course_for_section(context)[:permissions]
- elsif context[:type] == :group && context[:parent]
- course = course_for_group(context)
- # People have groups in unpublished courses that they use for messaging.
- # We should really train them to use subaccount-level groups.
- context[:permissions] = course ? course[:permissions] : {send_messages: true}
- else
- context[:permissions] ||= {}
- end
- end
-
- # filter out those that are explicitly excluded, inactive, restricted by
- # permissions, or which don't match the search
- result.reject! do |context|
- exclude.include?(context[:asset_string]) ||
- (!options[:include_inactive] && context[:state] == :inactive) ||
- (options[:messageable_only] && !context[:permissions].include?(:send_messages)) ||
- !terms.all?{ |part| context[:name].downcase.include?(part) }
- end
-
- # bulk count users in the remainder
- asset_strings = result.map{ |context| context[:asset_string] }
- user_counts = @current_user.address_book.count_in_contexts(asset_strings)
-
- # build up the final representations
- result.map{ |context|
- ret = {
- :id => context[:asset_string],
- :name => context[:name],
- :avatar_url => avatar_url,
- :type => :context,
- :user_count => user_counts[context[:asset_string]] || 0,
- :permissions => context[:permissions],
- }
- ret[:context_name] = context[:context_name] if context[:context_name] && context_name.nil?
- ret
- }
- end
-
- def course_for_section(section)
- @contexts[:courses][section[:parent][:course]]
- end
-
- def course_for_group(group)
- course_for_section(group)
- end
-
- def synthetic_contexts_for(course, context)
- # context is a string identifying a subset of the course
- @skip_users = true
- # TODO: move the aggregation entirely into the DB. we only select a little
- # bit of data per user, but this still isn't ideal
- users = @current_user.address_book.known_in_context(context)
- enrollment_counts = {:all => users.size}
- users.each do |user|
- common_courses = @current_user.address_book.common_courses(user)
- next unless common_courses.key?(course[:id])
- roles = common_courses[course[:id]].uniq
- roles.each do |role|
- enrollment_counts[role] ||= 0
- enrollment_counts[role] += 1
- end
- end
- avatar_url = avatar_url_for_group
- result = []
- synthetic_context = {:avatar_url => avatar_url, :type => :context, :permissions => course[:permissions]}
- result << synthetic_context.merge({:id => "#{context}_teachers", :name => t(:enrollments_teachers, "Teachers"), :user_count => enrollment_counts['TeacherEnrollment']}) if enrollment_counts['TeacherEnrollment'].to_i > 0
- result << synthetic_context.merge({:id => "#{context}_tas", :name => t(:enrollments_tas, "Teaching Assistants"), :user_count => enrollment_counts['TaEnrollment']}) if enrollment_counts['TaEnrollment'].to_i > 0
- result << synthetic_context.merge({:id => "#{context}_students", :name => t(:enrollments_students, "Students"), :user_count => enrollment_counts['StudentEnrollment']}) if enrollment_counts['StudentEnrollment'].to_i > 0
- result << synthetic_context.merge({:id => "#{context}_observers", :name => t(:enrollments_observers, "Observers"), :user_count => enrollment_counts['ObserverEnrollment']}) if enrollment_counts['ObserverEnrollment'].to_i > 0
- result
- end
-
- def context_state_ranks
- {:active => 0, :recently_active => 1, :inactive => 2}
- end
-
- def context_type_ranks
- {:course => 0, :section => 1, :group => 2}
- end
-
- def messageable_context_states
- {:active => true, :recently_active => true, :inactive => false}
- end
-
end
diff --git a/app/graphql/types/message_permissions_type.rb b/app/graphql/types/message_permissions_type.rb
new file mode 100644
index 00000000000..1e2309d28c6
--- /dev/null
+++ b/app/graphql/types/message_permissions_type.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+#
+# Copyright (C) 2019 - 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 .
+#
+
+module Types
+ class MessagePermissionsType < ApplicationObjectType
+ graphql_name 'MessagePermissions'
+
+ field :send_messages, Boolean, null: false
+ field :send_messages_all, Boolean, null: false
+ end
+end
diff --git a/app/graphql/types/messageable_context_type.rb b/app/graphql/types/messageable_context_type.rb
new file mode 100644
index 00000000000..4c24b04bfb0
--- /dev/null
+++ b/app/graphql/types/messageable_context_type.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+#
+# Copyright (C) 2019 - 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 .
+#
+
+module Types
+ class MessageableContextType < ApplicationObjectType
+ graphql_name 'MessageableContext'
+
+ implements GraphQL::Types::Relay::Node
+
+ field :id, ID, null: false
+ field :name, String, null: false
+ field :avatar_url, String, null: false
+ field :user_count, Int, null: true
+ field :item_count, Int, null: true
+ field :permissions, MessagePermissionsType, null: true
+ end
+end
diff --git a/app/graphql/types/messageable_user_type.rb b/app/graphql/types/messageable_user_type.rb
new file mode 100644
index 00000000000..4a47b0d795b
--- /dev/null
+++ b/app/graphql/types/messageable_user_type.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+#
+# Copyright (C) 2019 - 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 .
+#
+
+module Types
+ class MessageableUserType < ApplicationObjectType
+ graphql_name 'MessageableUser'
+
+ implements GraphQL::Types::Relay::Node
+ global_id_field :id # this is a relay-style "global" identifier
+ field :_id, ID, "legacy canvas id", method: :id, null: false
+
+ field :name, String, null: false
+
+ field :common_courses_connection, Types::EnrollmentType.connection_type, null: true
+ def common_courses_connection
+ Promise.all([
+ load_association(:enrollments).then do |enrollments|
+ enrollments.each do |enrollment|
+ Loaders::AssociationLoader.for(Enrollment, :course).load(enrollment)
+ end
+ end
+ ]).then { object.enrollments.where(course_id: object.common_courses.keys) }
+ end
+
+ field :common_groups_connection, Types::GroupType.connection_type, null: true
+ def common_groups_connection
+ load_association(:groups).then { object.groups.where(id: object.common_groups.keys) }
+ end
+ end
+end
diff --git a/app/graphql/types/recipients_type.rb b/app/graphql/types/recipients_type.rb
new file mode 100644
index 00000000000..3fa1d7d4fc1
--- /dev/null
+++ b/app/graphql/types/recipients_type.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+#
+# Copyright (C) 2019 - 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 .
+#
+
+module Types
+ class RecipientsType < ApplicationObjectType
+ graphql_name 'Recipients'
+
+ field :contexts_connection, Types::MessageableContextType.connection_type, null: true
+ field :users_connection, Types::MessageableUserType.connection_type, null: true
+ end
+end
diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb
index a82dd5f5874..6fa13b21ad6 100644
--- a/app/graphql/types/user_type.rb
+++ b/app/graphql/types/user_type.rb
@@ -36,6 +36,8 @@ module Types
#
graphql_name "User"
+ include SearchHelper
+
implements GraphQL::Types::Relay::Node
implements Interfaces::TimestampInterface
implements Interfaces::LegacyIDInterface
@@ -153,6 +155,61 @@ module Types
end
end
+ field :recipients, RecipientsType, null: true do
+ argument :search, String, required: false
+ argument :context, String, required: false
+ end
+ def recipients(search: nil, context: nil)
+ return nil unless object == self.context[:current_user]
+
+ @current_user = object
+ search_context = AddressBook.load_context(context)
+
+ load_all_contexts(
+ context: search_context,
+ permissions: [:send_messages, :send_messages_all],
+ base_url: self.context[:request].base_url
+ )
+
+ collections = search_contexts_and_users(
+ search: search,
+ context: context,
+ synthetic_contexts: true,
+ messageable_only: true,
+ base_url: self.context[:request].base_url
+ )
+
+ per_page = 100
+ contexts_collection = collections.select { |c| c[0] == 'contexts' }
+ contexts = []
+ if contexts_collection.count > 0
+ batch = contexts_collection[0][1].paginate(per_page: per_page)
+ contexts += batch
+ while batch.next_page
+ batch = contexts_collection[0][1].paginate(page: batch.next_page, per_page: per_page)
+ contexts += batch
+ end
+ end
+
+ users_collection = collections.select { |c| c[0] == 'participants' }
+ users = []
+ if users_collection.count > 0
+ batch = users_collection[0][1].paginate(per_page: per_page)
+ users += batch
+ while batch.next_page
+ batch = users_collection[0][1].paginate(page: batch.next_page, per_page: per_page)
+ users += batch
+ end
+ end
+
+ {
+ contexts_connection: contexts,
+ users_connection: users
+ }
+ rescue ActiveRecord::RecordNotFound
+ nil
+ end
+
# TODO: deprecate this
#
# we should probably have some kind of top-level field called `self` or
diff --git a/app/helpers/avatar_helper.rb b/app/helpers/avatar_helper.rb
index ffdfc00b64a..a88c253c9d4 100644
--- a/app/helpers/avatar_helper.rb
+++ b/app/helpers/avatar_helper.rb
@@ -85,8 +85,8 @@ module AvatarHelper
end
end
- def avatar_url_for_group
- request.base_url + "/images/messages/avatar-group-50.png" # always fall back to -50, it'll get scaled down if a smaller size is wanted
+ def avatar_url_for_group(base_url: nil)
+ (base_url || request.base_url) + "/images/messages/avatar-group-50.png" # always fall back to -50, it'll get scaled down if a smaller size is wanted
end
def self.avatars_enabled_for_user?(user, root_account: nil)
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index 1e14c19ed1c..33400ef0029 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -42,9 +42,10 @@ module SearchHelper
add_courses = lambda do |courses, type|
courses.each do |course|
+ course_url = options[:base_url] ? "#{options[:base_url]}/courses/#{course.id}" : course_url(course)
contexts[:courses][course.id] = {
:id => course.id,
- :url => course_url(course),
+ :url => course_url,
:name => course.name,
:type => :course,
:term => term_for_course.call(course),
@@ -54,9 +55,9 @@ module SearchHelper
}.tap do |hash|
hash[:permissions] =
if include_all_permissions
- course.rights_status(@current_user).select { |key, value| value }
+ course.rights_status(@current_user).select { |_key, value| value }
elsif permissions
- course.rights_status(@current_user, *permissions).select { |key, value| value }
+ course.rights_status(@current_user, *permissions).select { |_key, value| value }
else
{}
end
@@ -96,9 +97,9 @@ module SearchHelper
}.tap do |hash|
hash[:permissions] =
if include_all_permissions
- group.rights_status(@current_user).select { |key, value| value }
+ group.rights_status(@current_user).select { |_key, value| value }
elsif permissions
- group.rights_status(@current_user, *permissions).select { |key, value| value }
+ group.rights_status(@current_user, *permissions).select { |_key, value| value }
else
{}
end
@@ -110,9 +111,9 @@ module SearchHelper
add_courses.call [context], :current
visibility = context.enrollment_visibility_level_for(@current_user, context.section_visibilities_for(@current_user), true)
sections = case visibility
- when :sections, :sections_limited, :limited
+ when :sections, :sections_limited, :limited
context.sections_visible_to(@current_user)
- when :full
+ when :full
context.course_sections
else
[]
@@ -126,7 +127,7 @@ module SearchHelper
end
elsif context.is_a?(CourseSection)
visibility = context.course.enrollment_visibility_level_for(@current_user, context.course.section_visibilities_for(@current_user), true)
- sections = (visibility == :restricted) ? [] : [context]
+ sections = visibility == :restricted ? [] : [context]
add_courses.call [context.course], :current
add_sections.call context.course.sections_visible_to(@current_user, sections)
else
@@ -139,4 +140,233 @@ module SearchHelper
end
@contexts
end
+
+ def search_contexts_and_users(options = {})
+ types = (options[:types] || [] + [options[:type]]).compact
+ types |= [:course, :section, :group] if types.delete('context')
+ types = if types.present?
+ {user: types.delete('user').present?, context: types.present? && types.map(&:to_sym)}
+ else
+ {user: true, context: [:course, :section, :group]}
+ end
+
+ collections = []
+ exclude_users, exclude_contexts = AddressBook.partition_recipients(options[:exclude] || [])
+
+ if types[:context]
+ collections << ['contexts', search_messageable_contexts(
+ search: options[:search],
+ context: options[:context],
+ synthetic_contexts: options[:synthetic_contexts],
+ include_inactive: options[:include_inactive],
+ messageable_only: options[:messageable_only],
+ exclude_ids: exclude_contexts,
+ search_all_contexts: options[:search_all_contexts],
+ types: types[:context],
+ base_url: options[:base_url]
+ )]
+ end
+
+ if types[:user] && !@skip_users
+ collections << ['participants', @current_user.address_book.search_users(
+ search: options[:search],
+ exclude_ids: exclude_users,
+ context: options[:context],
+ weak_checks: options[:skip_visibility_checks]
+ )]
+ end
+
+ collections
+ end
+
+ def search_messageable_contexts(options={})
+ ContextBookmarker.wrap(matching_contexts(options))
+ end
+
+ def matching_contexts(options)
+ context_name = options[:context]
+ avatar_url = avatar_url_for_group(base_url: options[:base_url])
+ terms = options[:search].to_s.downcase.strip.split(/\s+/)
+ exclude = options[:exclude_ids] || []
+
+ result = []
+ if context_name.nil?
+ result = if terms.blank?
+ courses = @contexts[:courses].values
+ group_ids = @current_user.current_groups.shard(@current_user).pluck(:id)
+ groups = @contexts[:groups].slice(*group_ids).values
+ courses + groups
+ else
+ @contexts.values_at(*options[:types].map{|t| t.to_s.pluralize.to_sym}).compact.map(&:values).flatten
+ end
+ elsif options[:synthetic_contexts]
+ if context_name =~ /\Acourse_(\d+)(_(groups|sections))?\z/ && (course = @contexts[:courses][Regexp.last_match(1).to_i]) && messageable_context_states[course[:state]]
+ sections = @contexts[:sections].values.select{ |section| section[:parent] == {:course => course[:id]} }
+ groups = @contexts[:groups].values.select{ |group| group[:parent] == {:course => course[:id]} }
+ case context_name
+ when /\Acourse_\d+\z/
+ if terms.present? || options[:search_all_contexts] # search all groups and sections (and users)
+ result = sections + groups
+ else # otherwise we show synthetic contexts
+ result = synthetic_contexts_for(course, context_name, options[:base_url])
+ found_custom_sections = sections.any? { |s| s[:id] != course[:default_section_id] }
+ result << {:id => "#{context_name}_sections", :name => I18n.t(:course_sections, "Course Sections"), :item_count => sections.size, :type => :context} if found_custom_sections
+ result << {:id => "#{context_name}_groups", :name => I18n.t(:student_groups, "Student Groups"), :item_count => groups.size, :type => :context} if groups.size > 0
+ return result
+ end
+ when /\Acourse_\d+_groups\z/
+ @skip_users = true # whether searching or just enumerating, we just want groups
+ result = groups
+ when /\Acourse_\d+_sections\z/
+ @skip_users = true # ditto
+ result = sections
+ end
+ elsif context_name =~ /\Asection_(\d+)\z/ && (section = @contexts[:sections][Regexp.last_match(1).to_i]) && messageable_context_states[section[:state]]
+ if terms.present? # we'll just search the users
+ result = []
+ else
+ return synthetic_contexts_for(course_for_section(section), context_name, options[:base_url])
+ end
+ end
+ end
+
+ result = if options[:search].present?
+ result.sort_by do |context|
+ [
+ context_state_ranks[context[:state]],
+ context_type_ranks[context[:type]],
+ Canvas::ICU.collation_key(context[:name]),
+ context[:id]
+ ]
+ end
+ else
+ result.sort_by do |context|
+ [
+ Canvas::ICU.collation_key(context[:name]),
+ context[:id]
+ ]
+ end
+ end
+
+ # pre-calculate asset strings and permissions
+ result.each do |context|
+ context[:asset_string] = "#{context[:type]}_#{context[:id]}"
+ if context[:type] == :section
+ # TODO: have load_all_contexts actually return section-level
+ # permissions. but before we do that, sections will need to grant many
+ # more permission (possibly inherited from the course, like
+ # :send_messages_all)
+ context[:permissions] = course_for_section(context)[:permissions]
+ elsif context[:type] == :group && context[:parent]
+ course = course_for_group(context)
+ # People have groups in unpublished courses that they use for messaging.
+ # We should really train them to use subaccount-level groups.
+ context[:permissions] = course ? course[:permissions] : {send_messages: true}
+ else
+ context[:permissions] ||= {}
+ end
+ end
+
+ # filter out those that are explicitly excluded, inactive, restricted by
+ # permissions, or which don't match the search
+ result.reject! do |context|
+ exclude.include?(context[:asset_string]) ||
+ (!options[:include_inactive] && context[:state] == :inactive) ||
+ (options[:messageable_only] && !context[:permissions].include?(:send_messages)) ||
+ !terms.all?{ |part| context[:name].downcase.include?(part) }
+ end
+
+ # bulk count users in the remainder
+ asset_strings = result.map{ |context| context[:asset_string] }
+ user_counts = @current_user.address_book.count_in_contexts(asset_strings)
+
+ # build up the final representations
+ result.map do |context|
+ ret = {
+ :id => context[:asset_string],
+ :name => context[:name],
+ :avatar_url => avatar_url,
+ :type => :context,
+ :user_count => user_counts[context[:asset_string]] || 0,
+ :permissions => context[:permissions],
+ }
+ ret[:context_name] = context[:context_name] if context[:context_name] && context_name.nil?
+ ret
+ end
+ end
+end
+
+# stupid bookmarker that instantiates the whole collection and then
+# "bookmarks" subsets of the collection by index. will want to improve this
+# eventually, but for now it's no worse than the old way, and lets us compose
+# the messageable contexts and messageable users for pagination.
+class ContextBookmarker
+ def initialize(collection)
+ @collection = collection
+ end
+
+ def bookmark_for(item)
+ @collection.index(item)
+ end
+
+ def validate(bookmark)
+ bookmark.is_a?(Integer)
+ end
+
+ def self.wrap(collection)
+ BookmarkedCollection.build(self.new(collection)) do |pager|
+ page_start = pager.current_bookmark ? pager.current_bookmark + 1 : 0
+ page_end = page_start + pager.per_page
+ pager.replace collection[page_start, page_end]
+ pager.has_more! if collection.size > page_end
+ pager
+ end
+ end
+end
+
+def course_for_section(section)
+ @contexts[:courses][section[:parent][:course]]
+end
+
+def course_for_group(group)
+ course_for_section(group)
+end
+
+def synthetic_contexts_for(course, context, base_url)
+ # context is a string identifying a subset of the course
+ @skip_users = true
+ # TODO: move the aggregation entirely into the DB. we only select a little
+ # bit of data per user, but this still isn't ideal
+ users = @current_user.address_book.known_in_context(context)
+ enrollment_counts = {:all => users.size}
+ users.each do |user|
+ common_courses = @current_user.address_book.common_courses(user)
+ next unless common_courses.key?(course[:id])
+
+ roles = common_courses[course[:id]].uniq
+ roles.each do |role|
+ enrollment_counts[role] ||= 0
+ enrollment_counts[role] += 1
+ end
+ end
+ avatar_url = avatar_url_for_group(base_url: base_url)
+ result = []
+ synthetic_context = {:avatar_url => avatar_url, :type => :context, :permissions => course[:permissions]}
+ result << synthetic_context.merge({:id => "#{context}_teachers", :name => I18n.t(:enrollments_teachers, "Teachers"), :user_count => enrollment_counts['TeacherEnrollment']}) if enrollment_counts['TeacherEnrollment'].to_i > 0
+ result << synthetic_context.merge({:id => "#{context}_tas", :name => I18n.t(:enrollments_tas, "Teaching Assistants"), :user_count => enrollment_counts['TaEnrollment']}) if enrollment_counts['TaEnrollment'].to_i > 0
+ result << synthetic_context.merge({:id => "#{context}_students", :name => I18n.t(:enrollments_students, "Students"), :user_count => enrollment_counts['StudentEnrollment']}) if enrollment_counts['StudentEnrollment'].to_i > 0
+ result << synthetic_context.merge({:id => "#{context}_observers", :name => I18n.t(:enrollments_observers, "Observers"), :user_count => enrollment_counts['ObserverEnrollment']}) if enrollment_counts['ObserverEnrollment'].to_i > 0
+ result
+end
+
+def context_state_ranks
+ {:active => 0, :recently_active => 1, :inactive => 2}
+end
+
+def context_type_ranks
+ {:course => 0, :section => 1, :group => 2}
+end
+
+def messageable_context_states
+ {:active => true, :recently_active => true, :inactive => false}
end
diff --git a/spec/graphql/types/user_type_spec.rb b/spec/graphql/types/user_type_spec.rb
index eccb3d3bb0c..6ea18e655c6 100644
--- a/spec/graphql/types/user_type_spec.rb
+++ b/spec/graphql/types/user_type_spec.rb
@@ -320,4 +320,55 @@ describe Types::UserType do
).to be nil
end
end
+
+ context 'recipients' do
+ let(:type) do
+ GraphQLTypeTester.new(
+ @student,
+ current_user: @student,
+ domain_root_account: @course.account.root_account,
+ request: ActionDispatch::TestRequest.create
+ )
+ end
+
+ it 'returns nil if the user is not the current user' do
+ result = user_type.resolve('recipients { usersConnection { nodes { _id } } }')
+ expect(result).to be nil
+ end
+
+ it 'returns known users' do
+ known_users = @student.address_book.search_users().paginate(per_page: 3)
+ result = type.resolve('recipients { usersConnection { nodes { _id } } }')
+ expect(result).to match_array(known_users.pluck(:id).map(&:to_s))
+ end
+
+ it 'returns contexts' do
+ result = type.resolve('recipients { contextsConnection { nodes { name } } }')
+ expect(result[0]).to eq(@course.name)
+ end
+
+ it 'searches users' do
+ known_users = @student.address_book.search_users().paginate(per_page: 3)
+ User.find(known_users.first.id).update!(name: 'Matthew Lemon')
+ result = type.resolve('recipients(search: "lemon") { usersConnection { nodes { _id } } }')
+ expect(result[0]).to eq(known_users.first.id.to_s)
+
+ result = type.resolve('recipients(search: "morty") { usersConnection { nodes { _id } } }')
+ expect(result).to match_array([])
+ end
+
+ it 'searches contexts' do
+ result = type.resolve('recipients(search: "unnamed") { contextsConnection { nodes { name } } }')
+ expect(result[0]).to eq(@course.name)
+
+ result = type.resolve('recipients(search: "Lemon") { contextsConnection { nodes { name } } }')
+ expect(result).to match_array([])
+ end
+
+ it 'filters results based on context' do
+ known_users = @student.address_book.search_users(context: "course_#{@course.id}_students").paginate(per_page: 3)
+ result = type.resolve("recipients(context: \"course_#{@course.id}_students\") { usersConnection { nodes { _id } } }")
+ expect(result).to match_array(known_users.pluck(:id).map(&:to_s))
+ end
+ end
end