compute total entries for UI course/user api index

This change will help prevent doing an n+1 of an expensive
query to fetch the next page in order to find the last page.
We are allowing Folio to compute the count for us _after_
we have made our expensive queries to filter down the result set
to be handed off for pagination. I made some optimizations
for particular cases when providing role ids for filtering user
role types in the user interface.

closes FOO-2931
flag = none

Test plan:
• You should *not* see the behavior outlined in FOO-2478
• Exercise both /accounts/1/users & /accounts/self api
  index actions and verify pagination works as expected
  (now /accounts/1/users will be populated with the last page
   since we compute the value and our UI sets it)

Change-Id: Ibd97406c6eb012c7f49c0a6a3968ca18b189a75b
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/294180
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Reviewed-by: Jacob Burroughs <jburroughs@instructure.com>
QA-Review: August Thornton <august@instructure.com>
Product-Review: August Thornton <august@instructure.com>
This commit is contained in:
August Thornton 2022-06-17 11:47:31 -06:00
parent 7429163d3f
commit 7644b994e7
8 changed files with 100 additions and 43 deletions

View File

@ -754,13 +754,18 @@ class AccountsController < ApplicationController
# We only want to return the permissions for single courses and not lists of courses. # We only want to return the permissions for single courses and not lists of courses.
# sections, needs_grading_count, and total_score not valid as enrollments are needed # sections, needs_grading_count, and total_score not valid as enrollments are needed
includes -= %w[permissions sections needs_grading_count total_scores] includes -= %w[permissions sections needs_grading_count total_scores]
# don't calculate a total count for this endpoint. total_entries: nil
all_precalculated_permissions = nil all_precalculated_permissions = nil
GuardRail.activate(:secondary) do
@courses = Api.paginate(@courses, self, api_v1_account_courses_url, { total_entries: nil })
ActiveRecord::Associations.preload(@courses, [:account, :root_account, course_account_associations: :account]) page_opts = { total_entries: nil }
if includes.include?("ui_invoked") && Setting.get("ui_invoked_count_pages", "true") == "true"
page_opts = {} # let Folio calculate total entries
includes.delete("ui_invoked")
end
GuardRail.activate(:secondary) do
@courses = Api.paginate(@courses, self, api_v1_account_courses_url, page_opts)
ActiveRecord::Associations.preload(@courses, [:account, :root_account, { course_account_associations: :account }])
preload_teachers(@courses) if includes.include?("teachers") preload_teachers(@courses) if includes.include?("teachers")
preload_teachers(@courses) if includes.include?("active_teachers") preload_teachers(@courses) if includes.include?("active_teachers")
ActiveRecord::Associations.preload(@courses, [:enrollment_term]) if includes.include?("term") || includes.include?("concluded") ActiveRecord::Associations.preload(@courses, [:enrollment_term]) if includes.include?("term") || includes.include?("concluded")

View File

@ -415,6 +415,9 @@ class UsersController < ApplicationController
get_context get_context
return unless authorized_action(@context, @current_user, :read_roster) return unless authorized_action(@context, @current_user, :read_roster)
includes = (params[:include] || []) & %w[avatar_url email last_login time_zone uuid ui_invoked]
includes << "last_login" if params[:sort] == "last_login" && !includes.include?("last_login")
search_term = params[:search_term].presence search_term = params[:search_term].presence
if search_term if search_term
users = UserSearch.for_user_in_context(search_term, @context, @current_user, session, users = UserSearch.for_user_in_context(search_term, @context, @current_user, session,
@ -424,15 +427,22 @@ class UsersController < ApplicationController
}) })
else else
users = UserSearch.scope_for(@context, @current_user, users = UserSearch.scope_for(@context, @current_user,
{ order: params[:order], sort: params[:sort], enrollment_role_id: params[:role_filter_id], {
enrollment_type: params[:enrollment_type] }) order: params[:order], sort: params[:sort], enrollment_role_id: params[:role_filter_id],
enrollment_type: params[:enrollment_type], ui_invoked: includes.include?("ui_invoked")
})
users = users.with_last_login if params[:sort] == "last_login" users = users.with_last_login if params[:sort] == "last_login"
end end
includes = (params[:include] || []) & %w[avatar_url email last_login time_zone uuid] page_opts = { total_entries: nil }
includes << "last_login" if params[:sort] == "last_login" && !includes.include?("last_login") if includes.include?("ui_invoked") && Setting.get("ui_invoked_count_pages", "true") == "true"
page_opts = {} # let Folio calculate total entries
includes.delete("ui_invoked")
end
GuardRail.activate(:secondary) do GuardRail.activate(:secondary) do
users = Api.paginate(users, self, api_v1_account_users_url, { total_entries: nil }) users = Api.paginate(users, self, api_v1_account_users_url, page_opts)
user_json_preloads(users, includes.include?("email")) user_json_preloads(users, includes.include?("email"))
User.preload_last_login(users, @context.resolved_root_account_id) if includes.include?("last_login") && params[:sort] != "last_login" User.preload_last_login(users, @context.resolved_root_account_id) if includes.include?("last_login") && params[:sort] != "last_login"
render json: users.map { |u| user_json(u, @current_user, session, includes) } render json: users.map { |u| user_json(u, @current_user, session, includes) }

View File

@ -433,19 +433,6 @@ module Api
wrap_pagination_args!(pagination_args, controller) wrap_pagination_args!(pagination_args, controller)
begin begin
paginated = collection.paginate(pagination_args) paginated = collection.paginate(pagination_args)
# If we aren't told the last page (because the AR .count was suppressed), then see if we
# can figure it out based on the contents of the next page. if it is short, then that's
# definitely the last page. Notice that we can only do this trick if we can perform
# arithmetic on the page numbers vs the page size, so if the pages are bookmark: urls
# then we just can't do this. TODO: the real fix for this is in the Folio gem
if paginated.ordinal_pages? && paginated.last_page.nil?
page_size = paginated.per_page
look_ahead = collection.paginate(pagination_args.merge({ page: paginated.next_page }))
next_page_len = look_ahead.length
if next_page_len < page_size # the next page (or possibly even this one) is the very last one
paginated.total_entries = (look_ahead.current_page.pred * page_size) + next_page_len
end # if the next page is full-sized, then we still don't know what the last page is
end
rescue Folio::InvalidPage rescue Folio::InvalidPage
# Have to .try(:build_page) because we use some collections (like # Have to .try(:build_page) because we use some collections (like
# PaginatedCollection) that do not conform to the full will_paginate API. # PaginatedCollection) that do not conform to the full will_paginate API.

View File

@ -55,9 +55,9 @@ module UserSearch
end end
def scope_for(context, searcher, options = {}) def scope_for(context, searcher, options = {})
users_scope = context_scope(context, searcher, options.slice(:enrollment_state, :include_inactive_enrollments)) users_scope = context_scope(context, searcher, options.slice(:enrollment_state, :include_inactive_enrollments, :enrollment_role_id, :ui_invoked))
users_scope = order_scope(users_scope, context, options.slice(:order, :sort)) users_scope = roles_scope(users_scope, context, options.slice(:enrollment_role, :enrollment_role_id, :enrollment_type, :exclude_groups, :ui_invoked))
roles_scope(users_scope, context, options.slice(:enrollment_role, :enrollment_role_id, :enrollment_type, :exclude_groups)) order_scope(users_scope, context, options.slice(:order, :sort))
end end
def context_scope(context, searcher, options = {}) def context_scope(context, searcher, options = {})
@ -66,7 +66,17 @@ module UserSearch
include_inactive_enrollments = !!options[:include_inactive_enrollments] include_inactive_enrollments = !!options[:include_inactive_enrollments]
case context case context
when Account when Account
User.of_account(context).active if options[:enrollment_role_id].present? && options[:ui_invoked].present?
# specifically don't use multishard scope for enrollments
# filter to the appropriate account where the enrollment role resides
User.joins(:enrollments)
.where.not(enrollments: { workflow_state: %w[rejected deleted inactive] })
.joins(:all_courses)
.where(courses: { account_id: context.id })
.distinct
else
User.of_account(context).active
end
when Course when Course
context.users_visible_to(searcher, include_prior_enrollments, context.users_visible_to(searcher, include_prior_enrollments,
enrollment_state: enrollment_states, include_inactive: include_inactive_enrollments).distinct enrollment_state: enrollment_states, include_inactive: include_inactive_enrollments).distinct
@ -113,19 +123,21 @@ module UserSearch
exclude_groups = Array(options[:exclude_groups]) if options[:exclude_groups] exclude_groups = Array(options[:exclude_groups]) if options[:exclude_groups]
if enrollment_role_ids || enrollment_roles if enrollment_role_ids || enrollment_roles
users_scope = users_scope.joins(:not_removed_enrollments).distinct if context.is_a?(Account) if context.is_a?(Account) && options[:ui_invoked].blank?
roles = if enrollment_role_ids users_scope = users_scope.joins(:not_removed_enrollments).distinct
enrollment_role_ids.filter_map { |id| Role.get_role_by_id(id) } end
else role_ids = if enrollment_role_ids
enrollment_roles.filter_map do |name| enrollment_role_ids.filter_map { |id| Role.get_role_by_id(id).id }
if context.is_a?(Account) else
context.get_course_role_by_name(name) enrollment_roles.filter_map do |name|
else if context.is_a?(Account)
context.account.get_course_role_by_name(name) context.get_course_role_by_name(name).id
end else
end context.account.get_course_role_by_name(name).id
end end
users_scope = users_scope.where(enrollments: { role_id: roles.map(&:id) }) end
end
users_scope = users_scope.where(enrollments: { role_id: role_ids })
elsif enrollment_types elsif enrollment_types
enrollment_types = enrollment_types.map do |e| enrollment_types = enrollment_types.map do |e|
ce = e.camelize ce = e.camelize

View File

@ -1085,6 +1085,17 @@ describe "Users API", type: :request do
expect(json.map { |r| r["id"] }).to eq [@student.id] expect(json.map { |r| r["id"] }).to eq [@student.id]
end end
it "sets pagination total_pages/last page link if includes ui_invoked is set" do
Setting.set("ui_invoked_count_pages", "true")
@account = Account.default
@user = @admin
api_call(:get, "/api/v1/accounts/#{@account.id}/users",
{ controller: "users", action: "api_index", format: "json", account_id: @account.id.to_param },
{ role_filter_id: student_role.id.to_s, include: ["ui_invoked"] })
expect(response).to be_successful
expect(response.headers["Link"]).to include("last")
end
context "includes last login info" do context "includes last login info" do
before :once do before :once do
@account = Account.default @account = Account.default
@ -1186,13 +1197,29 @@ describe "Users API", type: :request do
end end
end end
it "does not return a next-page link on the last page" do it "does return a next header on the last page" do
@account = Account.default @account = Account.default
u = User.create!(name: "test user") u = User.create!(name: "test user")
u.pseudonyms.create!(account: @account, unique_id: "user") u.pseudonyms.create!(account: @account, unique_id: "user")
json = api_call(:get, "/api/v1/accounts/#{@account.id}/users", { controller: "users", action: "api_index", format: "json", account_id: @account.id.to_param }, { search_term: u.id.to_s, per_page: "1", page: "1" }) json = api_call(:get, "/api/v1/accounts/#{@account.id}/users", { controller: "users", action: "api_index", format: "json", account_id: @account.id.to_param }, { search_term: u.id.to_s, per_page: "1", page: "1" })
expect(json.length).to eq 1 expect(json.length).to eq 1
expect(response.headers["Link"]).to include("rel=\"next\"")
json = api_call(:get, "/api/v1/accounts/#{@account.id}/users", { controller: "users", action: "api_index", format: "json", account_id: @account.id.to_param }, { search_term: u.id.to_s, per_page: "1", page: "2" })
expect(json).to be_empty
expect(response.headers["Link"]).to_not include("rel=\"next\"")
end
it "does not return a next-page link on the last page" do
Setting.set("ui_invoked_count_pages", "true")
@account = Account.default
u = User.create!(name: "test user")
u.pseudonyms.create!(account: @account, unique_id: "user")
json = api_call(:get, "/api/v1/accounts/#{@account.id}/users",
{ controller: "users", action: "api_index", format: "json", account_id: @account.id.to_param },
{ search_term: u.id.to_s, per_page: "1", page: "1", include: ["ui_invoked"] })
expect(json.length).to eq 1
expect(response.headers["Link"]).to_not include("rel=\"next\"") expect(response.headers["Link"]).to_not include("rel=\"next\"")
end end
end end

View File

@ -1301,6 +1301,15 @@ describe AccountsController do
expect(response.headers.to_a.find { |a| a.first == "Link" }.last).to_not include("last") expect(response.headers.to_a.find { |a| a.first == "Link" }.last).to_not include("last")
end end
it "sets pagination total_pages/last page link if includes ui_invoked is set" do
Setting.set("ui_invoked_count_pages", "true")
admin_logged_in(@account)
get "courses_api", params: { account_id: @account.id, per_page: 1, include: ["ui_invoked"] }
expect(response).to be_successful
expect(response.headers.to_a.find { |a| a.first == "Link" }.last).to include("last")
end
it "properly removes sections from includes" do it "properly removes sections from includes" do
@s1 = @course.course_sections.create! @s1 = @course.course_sections.create!
@course.enroll_student(user_factory(active_all: true), section: @s1, allow_multiple_enrollments: true) @course.enroll_student(user_factory(active_all: true), section: @s1, allow_multiple_enrollments: true)

View File

@ -28,7 +28,14 @@ export default createStore({
normalizeParams(originalParams) { normalizeParams(originalParams) {
const params = { const params = {
...originalParams, ...originalParams,
include: ['total_students', 'active_teachers', 'subaccount', 'term', 'concluded'], include: [
'total_students',
'active_teachers',
'subaccount',
'term',
'concluded',
'ui_invoked'
],
teacher_limit: 25, teacher_limit: 25,
per_page: COURSES_TO_FETCH_PER_PAGE, per_page: COURSES_TO_FETCH_PER_PAGE,
no_avatar_fallback: '1' no_avatar_fallback: '1'

View File

@ -21,7 +21,7 @@ import createStore from './createStore'
const USERS_TO_FETCH_PER_PAGE = 15 const USERS_TO_FETCH_PER_PAGE = 15
const defaultParms = { const defaultParms = {
include: ['last_login', 'avatar_url', 'email', 'time_zone'], include: ['last_login', 'avatar_url', 'email', 'time_zone', 'ui_invoked'],
per_page: USERS_TO_FETCH_PER_PAGE, per_page: USERS_TO_FETCH_PER_PAGE,
no_avatar_fallback: '1' no_avatar_fallback: '1'
} }