Scope account/course user search better

fixes ADMIN-751

Test plan
- Specs pass
- Do smoke test on account and course
  search functions

Change-Id: I2c7e32c32bc52c1a364ed40a89db0b7dc9557ffb
Reviewed-on: https://gerrit.instructure.com/200682
Tested-by: Jenkins
Reviewed-by: Jeremy Stanley <jeremy@instructure.com>
QA-Review: Jeremy Stanley <jeremy@instructure.com>
Product-Review: Mysti Lilla <mysti@instructure.com>
This commit is contained in:
Mysti Lilla 2019-07-09 17:51:29 -06:00
parent ffd4743577
commit 0352326afa
3 changed files with 136 additions and 113 deletions

View File

@ -462,7 +462,7 @@ class UsersController < ApplicationController
includes = (params[:include] || []) & %w{avatar_url email last_login time_zone uuid}
includes << 'last_login' if params[:sort] == 'last_login' && !includes.include?('last_login')
users = users.with_last_login if includes.include?('last_login')
users = users.with_last_login if includes.include?('last_login') && !search_term
users = Api.paginate(users, self, api_v1_account_users_url, page_opts)
user_json_preloads(users, includes.include?('email'))
return render :json => users.map { |u| user_json(u, @current_user, session, includes)}

View File

@ -28,31 +28,19 @@ module UserSearch
@include_sis = context.grants_any_right?(searcher, session, :read_sis, :manage_sis)
context.shard.activate do
base_scope = scope_for(context, searcher, options.slice(:enrollment_type, :enrollment_role,
:enrollment_role_id, :exclude_groups, :enrollment_state, :include_inactive_enrollments, :sort, :order))
users_scope = context_scope(context, searcher, options.slice(:enrollment_state, :include_inactive_enrollments))
# TODO: Need to optimize this as it's not using the base scope filter for the conditions statement query
base_scope.where(conditions_statement(search_term, context.root_account))
users_scope = users_scope.from("(#{conditions_statement(search_term, context.root_account, users_scope)}) AS users")
users_scope = order_scope(users_scope, context, options.slice(:order, :sort))
roles_scope(users_scope, context, options.slice(:enrollment_type, :enrollment_role,
:enrollment_role_id, :exclude_groups))
end
end
def self.complex_search?
@include_login || @include_email || @include_sis || @is_id
end
def self.conditions_statement(search_term, root_account, options={})
def self.conditions_statement(search_term, root_account, users_scope)
pattern = like_string_for(search_term)
conditions = []
if complex_search_enabled? && complex_search?
# db_id is only used if the search_term.to_s =~ Api::ID_REGEX
params = {pattern: pattern, account: root_account, path_type: CommunicationChannel::TYPE_EMAIL, db_id: search_term}
conditions << complex_sql << params
else
conditions << like_condition('users.name') << {pattern: pattern}
end
conditions
params = {pattern: pattern, account: root_account, path_type: CommunicationChannel::TYPE_EMAIL, db_id: search_term}
complex_sql(users_scope, params)
end
def self.like_string_for(search_term)
@ -61,74 +49,76 @@ module UserSearch
end
def self.scope_for(context, searcher, options={})
enrollment_roles = Array(options[:enrollment_role]) if options[:enrollment_role]
enrollment_role_ids = Array(options[:enrollment_role_id]) if options[:enrollment_role_id]
enrollment_types = Array(options[:enrollment_type]) if options[:enrollment_type]
users_scope = context_scope(context, searcher, options.slice(:enrollment_state, :include_inactive_enrollments))
users_scope = users_scope.select("users.*")
users_scope = order_scope(users_scope, context, options.slice(:order, :sort))
roles_scope(users_scope, context, options.slice(:enrollment_role, :enrollment_role_id, :enrollment_type, :exclude_groups))
end
def self.context_scope(context, searcher, options={})
enrollment_states = Array(options[:enrollment_state]) if options[:enrollment_state]
include_prior_enrollments = !options[:enrollment_state].nil?
include_inactive_enrollments = !!options[:include_inactive_enrollments]
if context.is_a?(Account)
User.of_account(context).active
elsif context.is_a?(Course)
context.users_visible_to(searcher, include_prior_enrollments,
enrollment_state: enrollment_states, include_inactive: include_inactive_enrollments).distinct
else
context.users_visible_to(searcher).distinct
end
end
def self.order_scope(users_scope, context, options={})
order = ' DESC NULLS LAST, id DESC' if options[:order] == 'desc'
if options[:sort] == "last_login"
users_scope.order(Arel.sql("last_login#{order}"))
elsif options[:sort] == "username"
users_scope.order_by_sortable_name(direction: options[:order] == 'desc' ? :descending : :ascending)
elsif options[:sort] == "email"
users_scope = users_scope.select("users.*, (SELECT path FROM #{CommunicationChannel.quoted_table_name}
WHERE communication_channels.user_id = users.id AND
communication_channels.path_type = 'email' AND
communication_channels.workflow_state <> 'retired'
ORDER BY communication_channels.position ASC
LIMIT 1)
AS email")
users_scope.order(Arel.sql("email#{order}"))
elsif options[:sort] == "sis_id"
users_scope = users_scope.select(User.send(:sanitize_sql, [
"users.*, (SELECT sis_user_id FROM #{Pseudonym.quoted_table_name}
WHERE pseudonyms.user_id = users.id AND
pseudonyms.workflow_state <> 'deleted' AND
pseudonyms.account_id = ?
LIMIT 1) AS sis_user_id",
context.root_account_id || context.id
]))
users_scope.order(Arel.sql("sis_user_id#{order}"))
else
users_scope.order_by_sortable_name
end
end
def self.roles_scope(users_scope, context, options={})
enrollment_roles = Array(options[:enrollment_role]) if options[:enrollment_role]
enrollment_role_ids = Array(options[:enrollment_role_id]) if options[:enrollment_role_id]
enrollment_types = Array(options[:enrollment_type]) if options[:enrollment_type]
exclude_groups = Array(options[:exclude_groups]) if options[:exclude_groups]
users = if context.is_a?(Account)
User.of_account(context).active
elsif context.is_a?(Course)
context.users_visible_to(searcher, include_prior_enrollments,
enrollment_state: enrollment_states, include_inactive: include_inactive_enrollments).distinct
else
context.users_visible_to(searcher).distinct
end
users = if options[:sort] == "last_login"
if options[:order] == 'desc'
users.order(Arel.sql("MAX(current_login_at) DESC NULLS LAST, id DESC"))
else
users.order(Arel.sql("MAX(current_login_at), id"))
end
elsif options[:sort] == "username"
if options[:order] == 'desc'
users.order_by_sortable_name(direction: :descending)
else
users.order_by_sortable_name
end
elsif options[:sort] == "email"
users = users.select("users.*, (SELECT path FROM #{CommunicationChannel.quoted_table_name}
WHERE communication_channels.user_id = users.id AND
communication_channels.path_type = 'email' AND
communication_channels.workflow_state <> 'retired'
ORDER BY communication_channels.position ASC
LIMIT 1)
AS email")
if options[:order] == 'desc'
users.order(Arel.sql("email DESC, id DESC"))
else
users.order(Arel.sql("email"))
end
elsif options[:sort] == "sis_id"
users = users.select(User.send(:sanitize_sql, [
"users.*, (SELECT sis_user_id FROM #{Pseudonym.quoted_table_name}
WHERE pseudonyms.user_id = users.id AND
pseudonyms.workflow_state <> 'deleted' AND
pseudonyms.account_id = ?
LIMIT 1) AS sis_user_id",
context.root_account_id || context.id]))
if options[:order] == 'desc'
users.order(Arel.sql("sis_user_id DESC, id DESC"))
else
users.order(Arel.sql("sis_user_id"))
end
else
users.order_by_sortable_name
end
if enrollment_role_ids || enrollment_roles
users = users.joins(:not_removed_enrollments).distinct if context.is_a?(Account)
users_scope = users_scope.joins(:not_removed_enrollments).distinct if context.is_a?(Account)
roles = if enrollment_role_ids
enrollment_role_ids.map{|id| Role.get_role_by_id(id)}.compact
else
enrollment_roles.map{|name| context.is_a?(Account) ? context.get_course_role_by_name(name) :
context.account.get_course_role_by_name(name)}.compact
enrollment_roles.map do |name|
if context.is_a?(Account)
context.get_course_role_by_name(name)
else
context.account.get_course_role_by_name(name)
end
end.compact
end
users = users.where("role_id IN (?)", roles.map(&:id))
users_scope = users_scope.where("role_id IN (?)", roles.map(&:id))
elsif enrollment_types
enrollment_types = enrollment_types.map { |e| "#{e.camelize}Enrollment" }
if enrollment_types.any?{ |et| !Enrollment.readable_types.keys.include?(et) }
@ -138,56 +128,74 @@ module UserSearch
if context.is_a?(Account)
# for example, one user can have multiple teacher enrollments, but
# we only want one such a user record in results
users = users.joins(:enrollments).merge(Enrollment.active.where(type: enrollment_types)).distinct
users_scope = users_scope.joins(:enrollments).merge(Enrollment.active.where(type: enrollment_types)).distinct
else
if context.is_a?(Group) && context.context_type == "Course"
users = users.joins(:enrollments).where(:enrollments => {:course_id => context.context_id})
users_scope = users_scope.joins(:enrollments).where(:enrollments => {:course_id => context.context_id})
end
users = users.where(:enrollments => { :type => enrollment_types })
users_scope = users_scope.where(:enrollments => { :type => enrollment_types })
end
end
if exclude_groups
users = users.where(Group.not_in_group_sql_fragment(exclude_groups))
users_scope = users_scope.where(Group.not_in_group_sql_fragment(exclude_groups))
end
users
users_scope
end
private
def self.complex_sql
id_queries = ["SELECT id FROM #{User.quoted_table_name} WHERE (#{like_condition('users.name')})"]
if @include_login || @include_sis
pseudonym_conditions = []
pseudonym_conditions << like_condition('pseudonyms.unique_id') if @include_login
pseudonym_conditions << like_condition('pseudonyms.sis_user_id') if @include_sis
id_queries << <<-SQL
SELECT user_id FROM #{Pseudonym.quoted_table_name}
WHERE (#{pseudonym_conditions.join(' OR ')})
AND pseudonyms.workflow_state='active'
AND pseudonyms.account_id=:account
SQL
def self.complex_sql(users_scope, params)
users_scope = users_scope.group(:id)
queries = [name_sql(users_scope, params)]
if complex_search_enabled?
queries << id_sql(users_scope, params) if @is_id
queries << login_sql(users_scope, params) if @include_login
queries << sis_sql(users_scope, params) if @include_sis
queries << email_sql(users_scope, params) if @include_email
end
queries.map(&:to_sql).join("\nUNION\n")
end
if @is_id
id_queries << "SELECT id FROM #{User.quoted_table_name} WHERE users.id IN (:db_id)"
end
def self.id_sql(users_scope, params)
users_scope.select("users.*, MAX(current_login_at) as last_login").
joins("LEFT JOIN #{Pseudonym.quoted_table_name} ON pseudonyms.user_id = users.id
AND pseudonyms.account_id = #{User.connection.quote(params[:account])}
AND pseudonyms.workflow_state = 'active'").
where(id: params[:db_id]).
group(:id)
end
if @include_email
id_queries << <<-SQL
SELECT communication_channels.user_id FROM #{CommunicationChannel.quoted_table_name}
WHERE EXISTS (SELECT 1 FROM #{UserAccountAssociation.quoted_table_name} AS uaa
WHERE uaa.account_id= :account
AND uaa.user_id=communication_channels.user_id)
AND communication_channels.path_type = :path_type
AND #{like_condition('communication_channels.path')}
AND communication_channels.workflow_state IN ('active', 'unconfirmed')
SQL
end
def self.name_sql(users_scope, params)
users_scope.select("users.*, MAX(current_login_at) as last_login").
joins("LEFT JOIN #{Pseudonym.quoted_table_name} ON pseudonyms.user_id = users.id
AND pseudonyms.account_id = #{User.connection.quote(params[:account])}
AND pseudonyms.workflow_state = 'active'").
where(like_condition('users.name'), pattern: params[:pattern])
end
"users.id IN (#{id_queries.join("\nUNION\n")})"
def self.login_sql(users_scope, params)
users_scope.select("users.*, MAX(current_login_at) as last_login").
joins(:pseudonyms).
where(pseudonyms: {account_id: params[:account], workflow_state: 'active'}).
where(like_condition('pseudonyms.unique_id'), pattern: params[:pattern])
end
def self.sis_sql(users_scope, params)
users_scope.select("users.*, MAX(current_login_at) as last_login").
joins(:pseudonyms).
where(pseudonyms: {account_id: params[:account], workflow_state: 'active'}).
where(like_condition('pseudonyms.sis_user_id'), pattern: params[:pattern])
end
def self.email_sql(users_scope, params)
users_scope.select("users.*, MAX(current_login_at) as last_login").
joins(:communication_channels).
joins("LEFT JOIN #{Pseudonym.quoted_table_name} ON pseudonyms.user_id = users.id
AND pseudonyms.account_id = #{User.connection.quote(params[:account])}
AND pseudonyms.workflow_state = 'active'").
where(communication_channels: {workflow_state: ['active', 'unconfirmed'], path_type: params[:path_type]}).
where(like_condition('communication_channels.path'), pattern: params[:pattern])
end
def self.gist_search_enabled?

View File

@ -206,6 +206,14 @@ describe UserSearch do
expect(results).to include(other_user)
end
it 'sorts by sis id' do
User.find_by(name: 'Rose Tyler').pseudonyms.create!(unique_id: 'rose.tyler@example.com',
sis_user_id: '25rose', account_id: course.root_account_id)
User.find_by(name: 'Tyler Pickett').pseudonyms.create!(unique_id: 'tyler.pickett@example.com',
sis_user_id: '1tyler', account_id: course.root_account_id)
users = UserSearch.for_user_in_context('Tyler', course, user, nil, sort: 'sis_id')
expect(users.map(&:name)).to eq ['Tyler Pickett', 'Rose Tyler', 'Tyler Teacher']
end
end
describe 'searching on emails' do
@ -246,6 +254,13 @@ describe UserSearch do
user.communication_channels.create!(path: 'unconfirmed@example.com')
expect(UserSearch.for_user_in_context("unconfirmed", course, user)).to eq [user]
end
it 'sorts by email' do
User.find_by(name: 'Tyler Pickett').communication_channels.create!(path: '1tyler@example.com')
User.find_by(name: 'Tyler Teacher').communication_channels.create!(path: '25teacher@example.com')
users = UserSearch.for_user_in_context('Tyler', course, user, nil, sort: 'email')
expect(users.map(&:name)).to eq ['Tyler Pickett', 'Tyler Teacher', 'Rose Tyler']
end
end
describe 'searching by a DB ID' do