add address book graphql types
fixes VICE-868 flag=react_inbox TEST PLAN: - navigate to /graphiql and run the following query ``` query MyQuery { legacyNode(_id: <user_id>, type: User) { ... on User { id email recipients { contextsConnection { nodes { id name avatarUrl permissions { sendMessages sendMessagesAll } userCount } } usersConnection { nodes { name commonCoursesConnection { nodes { type course { name } } } commonGroupsConnection { nodes { name } } } } } } } } ``` - The query should return some results - You can also play around with pagination to ensure that works as well Change-Id: I63666a7225e3ac051990f24c11199a9a40569edc Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/253043 Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com> Reviewed-by: Rob Orton <rob@instructure.com> QA-Review: Rob Orton <rob@instructure.com> Product-Review: Rob Orton <rob@instructure.com>
This commit is contained in:
parent
0d5cae5c81
commit
1454d80d8e
|
@ -1,3 +1,5 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
#
|
#
|
||||||
# Copyright (C) 2011 - present Instructure, Inc.
|
# Copyright (C) 2011 - present Instructure, Inc.
|
||||||
#
|
#
|
||||||
|
@ -26,10 +28,18 @@ class SearchController < ApplicationController
|
||||||
before_action :get_context, except: :recipients
|
before_action :get_context, except: :recipients
|
||||||
|
|
||||||
def rubrics
|
def rubrics
|
||||||
contexts = @current_user.management_contexts rescue []
|
contexts = begin
|
||||||
|
@current_user.management_contexts
|
||||||
|
rescue
|
||||||
|
[]
|
||||||
|
end
|
||||||
res = []
|
res = []
|
||||||
contexts.each do |context|
|
contexts.each do |context|
|
||||||
res += context.rubrics rescue []
|
res += begin
|
||||||
|
context.rubrics
|
||||||
|
rescue
|
||||||
|
[]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
res += Rubric.publicly_reusable.matching(params[:q])
|
res += Rubric.publicly_reusable.matching(params[:q])
|
||||||
res = res.select{|r| r.title.downcase.match(params[:q].downcase) }
|
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]
|
permissions << :send_messages if params[:messageable_only]
|
||||||
load_all_contexts :context => search_context, :permissions => permissions
|
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
|
params[:per_page] = nil if params[:per_page].to_i <= 0
|
||||||
exclude = params[:exclude] || []
|
|
||||||
|
|
||||||
recipients = []
|
recipients = []
|
||||||
if params[:user_id]
|
if params[:user_id]
|
||||||
known = @current_user.address_book.known_user(
|
known = @current_user.address_book.known_user(
|
||||||
params[:user_id],
|
params[:user_id],
|
||||||
context: params[:context],
|
context: params[:context],
|
||||||
conversation_id: params[:from_conversation_id])
|
conversation_id: params[:from_conversation_id]
|
||||||
|
)
|
||||||
recipients << known if known
|
recipients << known if known
|
||||||
elsif params[:context] || params[:search]
|
elsif params[:context] || params[:search]
|
||||||
collections = []
|
collections = search_contexts_and_users(params)
|
||||||
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
|
|
||||||
|
|
||||||
recipients = BookmarkedCollection.concat(*collections)
|
recipients = BookmarkedCollection.concat(*collections)
|
||||||
recipients = Api.paginate(recipients, self, api_v1_search_recipients_url)
|
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.
|
# Only return courses that allow self enrollment. Defaults to false.
|
||||||
#
|
#
|
||||||
def all_courses
|
def all_courses
|
||||||
@courses = Course.where(root_account_id: @domain_root_account)
|
@courses = Course.where(root_account_id: @domain_root_account).
|
||||||
.where(indexed: true)
|
where(indexed: true).
|
||||||
.where(workflow_state: 'available')
|
where(workflow_state: 'available').
|
||||||
.order('created_at')
|
order('created_at')
|
||||||
@search = params[:search]
|
@search = params[:search]
|
||||||
if @search.present?
|
if @search.present?
|
||||||
@courses = @courses.where(@courses.wildcard('name', @search.to_s))
|
@courses = @courses.where(@courses.wildcard('name', @search.to_s))
|
||||||
|
@ -222,197 +201,4 @@ class SearchController < ApplicationController
|
||||||
return render :html => @contentHTML
|
return render :html => @contentHTML
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
|
||||||
|
module Types
|
||||||
|
class MessagePermissionsType < ApplicationObjectType
|
||||||
|
graphql_name 'MessagePermissions'
|
||||||
|
|
||||||
|
field :send_messages, Boolean, null: false
|
||||||
|
field :send_messages_all, Boolean, null: false
|
||||||
|
end
|
||||||
|
end
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
|
||||||
|
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
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
|
||||||
|
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
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
|
||||||
|
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
|
|
@ -36,6 +36,8 @@ module Types
|
||||||
#
|
#
|
||||||
graphql_name "User"
|
graphql_name "User"
|
||||||
|
|
||||||
|
include SearchHelper
|
||||||
|
|
||||||
implements GraphQL::Types::Relay::Node
|
implements GraphQL::Types::Relay::Node
|
||||||
implements Interfaces::TimestampInterface
|
implements Interfaces::TimestampInterface
|
||||||
implements Interfaces::LegacyIDInterface
|
implements Interfaces::LegacyIDInterface
|
||||||
|
@ -153,6 +155,61 @@ module Types
|
||||||
end
|
end
|
||||||
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
|
# TODO: deprecate this
|
||||||
#
|
#
|
||||||
# we should probably have some kind of top-level field called `self` or
|
# we should probably have some kind of top-level field called `self` or
|
||||||
|
|
|
@ -85,8 +85,8 @@ module AvatarHelper
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def avatar_url_for_group
|
def avatar_url_for_group(base_url: nil)
|
||||||
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
|
(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
|
end
|
||||||
|
|
||||||
def self.avatars_enabled_for_user?(user, root_account: nil)
|
def self.avatars_enabled_for_user?(user, root_account: nil)
|
||||||
|
|
|
@ -42,9 +42,10 @@ module SearchHelper
|
||||||
|
|
||||||
add_courses = lambda do |courses, type|
|
add_courses = lambda do |courses, type|
|
||||||
courses.each do |course|
|
courses.each do |course|
|
||||||
|
course_url = options[:base_url] ? "#{options[:base_url]}/courses/#{course.id}" : course_url(course)
|
||||||
contexts[:courses][course.id] = {
|
contexts[:courses][course.id] = {
|
||||||
:id => course.id,
|
:id => course.id,
|
||||||
:url => course_url(course),
|
:url => course_url,
|
||||||
:name => course.name,
|
:name => course.name,
|
||||||
:type => :course,
|
:type => :course,
|
||||||
:term => term_for_course.call(course),
|
:term => term_for_course.call(course),
|
||||||
|
@ -54,9 +55,9 @@ module SearchHelper
|
||||||
}.tap do |hash|
|
}.tap do |hash|
|
||||||
hash[:permissions] =
|
hash[:permissions] =
|
||||||
if include_all_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
|
elsif permissions
|
||||||
course.rights_status(@current_user, *permissions).select { |key, value| value }
|
course.rights_status(@current_user, *permissions).select { |_key, value| value }
|
||||||
else
|
else
|
||||||
{}
|
{}
|
||||||
end
|
end
|
||||||
|
@ -96,9 +97,9 @@ module SearchHelper
|
||||||
}.tap do |hash|
|
}.tap do |hash|
|
||||||
hash[:permissions] =
|
hash[:permissions] =
|
||||||
if include_all_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
|
elsif permissions
|
||||||
group.rights_status(@current_user, *permissions).select { |key, value| value }
|
group.rights_status(@current_user, *permissions).select { |_key, value| value }
|
||||||
else
|
else
|
||||||
{}
|
{}
|
||||||
end
|
end
|
||||||
|
@ -110,9 +111,9 @@ module SearchHelper
|
||||||
add_courses.call [context], :current
|
add_courses.call [context], :current
|
||||||
visibility = context.enrollment_visibility_level_for(@current_user, context.section_visibilities_for(@current_user), true)
|
visibility = context.enrollment_visibility_level_for(@current_user, context.section_visibilities_for(@current_user), true)
|
||||||
sections = case visibility
|
sections = case visibility
|
||||||
when :sections, :sections_limited, :limited
|
when :sections, :sections_limited, :limited
|
||||||
context.sections_visible_to(@current_user)
|
context.sections_visible_to(@current_user)
|
||||||
when :full
|
when :full
|
||||||
context.course_sections
|
context.course_sections
|
||||||
else
|
else
|
||||||
[]
|
[]
|
||||||
|
@ -126,7 +127,7 @@ module SearchHelper
|
||||||
end
|
end
|
||||||
elsif context.is_a?(CourseSection)
|
elsif context.is_a?(CourseSection)
|
||||||
visibility = context.course.enrollment_visibility_level_for(@current_user, context.course.section_visibilities_for(@current_user), true)
|
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_courses.call [context.course], :current
|
||||||
add_sections.call context.course.sections_visible_to(@current_user, sections)
|
add_sections.call context.course.sections_visible_to(@current_user, sections)
|
||||||
else
|
else
|
||||||
|
@ -139,4 +140,233 @@ module SearchHelper
|
||||||
end
|
end
|
||||||
@contexts
|
@contexts
|
||||||
end
|
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
|
end
|
||||||
|
|
|
@ -320,4 +320,55 @@ describe Types::UserType do
|
||||||
).to be nil
|
).to be nil
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
Loading…
Reference in New Issue