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