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:
Matthew Lemon 2020-11-17 16:28:33 -07:00
parent 0d5cae5c81
commit 1454d80d8e
9 changed files with 504 additions and 243 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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