From 0a4e9eeb92d1fbcb6519c243da4a7ac4ab88743a Mon Sep 17 00:00:00 2001 From: Devin Binnie <52460000+devinbinnie@users.noreply.github.com> Date: Wed, 10 Jan 2024 09:08:23 -0500 Subject: [PATCH] [MM-56399][MM-56397][MM-56456][MM-56269] Various changes for user reporting for admins (#25839) * [MM-56399] Add user count endpoint for reporting * [MM-56397] Added search term to user report filter * Missing translation * [MM-56456] Rename up/down to prev/next for reporting cursoring * [MM-56269] Add DeleteAt, MfaActive and AuthService fields to UserReport * PR feedback * Fix test --------- Co-authored-by: Mattermost Build --- api/v4/source/reports.yaml | 66 +++++++++ server/channels/api4/report.go | 125 ++++++++++++------ server/channels/app/app_iface.go | 1 + .../app/opentracing/opentracing_layer.go | 22 +++ server/channels/app/report.go | 13 +- server/channels/app/user_test.go | 1 - .../opentracinglayer/opentracinglayer.go | 18 +++ .../channels/store/retrylayer/retrylayer.go | 21 +++ server/channels/store/sqlstore/user_store.go | 70 +++++++--- server/channels/store/store.go | 1 + .../store/storetest/mocks/UserStore.go | 24 ++++ server/channels/store/storetest/user_store.go | 30 ++++- .../channels/store/timerlayer/timerlayer.go | 16 +++ server/i18n/en.json | 4 + server/public/model/report.go | 17 +-- webapp/platform/client/src/client4.ts | 10 +- webapp/platform/types/src/client4.ts | 16 ++- webapp/platform/types/src/users.ts | 7 +- 18 files changed, 365 insertions(+), 97 deletions(-) diff --git a/api/v4/source/reports.yaml b/api/v4/source/reports.yaml index a13a9a57c37..59bfa05777b 100644 --- a/api/v4/source/reports.yaml +++ b/api/v4/source/reports.yaml @@ -81,6 +81,11 @@ description: If true, show only users that are active. Cannot be used at the same time as "hide_active" schema: type: boolean + - name: search_term + in: query + description: A filtering search term that allows filtering by Username, FirstName, LastName, Nickname or Email + schema: + type: string responses: "200": description: User page retrieval successful @@ -98,3 +103,64 @@ $ref: "#/components/responses/Forbidden" "500": $ref: "#/components/responses/InternalServerError" + /api/v4/reports/users/count: + get: + tags: + - users + summary: Gets the full count of users that match the filter. + description: > + Get the full count of users admin reporting purposes, based on provided parameters. + + Must be a system admin to invoke this API. + + ##### Permissions + + Requires `sysconsole_read_user_management_users`. + + operationId: GetUserCountForReporting + parameters: + - name: role_filter + in: query + description: Filter users by their role. + schema: + type: string + - name: team_filter + in: query + description: Filter users by a specified team ID. + schema: + type: string + - name: has_no_team + in: query + description: If true, show only users that have no team. Will ignore provided "team_filter" if true. + schema: + type: boolean + - name: hide_active + in: query + description: If true, show only users that are inactive. Cannot be used at the same time as "hide_inactive" + schema: + type: boolean + - name: hide_inactive + in: query + description: If true, show only users that are active. Cannot be used at the same time as "hide_active" + schema: + type: boolean + - name: search_term + in: query + description: A filtering search term that allows filtering by Username, FirstName, LastName, Nickname or Email + schema: + type: string + responses: + "200": + description: User count retrieval successful + content: + application/json: + schema: + type: number + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "500": + $ref: "#/components/responses/InternalServerError" diff --git a/server/channels/api4/report.go b/server/channels/api4/report.go index 9c532def59b..f226ee05a98 100644 --- a/server/channels/api4/report.go +++ b/server/channels/api4/report.go @@ -6,6 +6,7 @@ package api4 import ( "encoding/json" "net/http" + "net/url" "strconv" "time" @@ -15,6 +16,7 @@ import ( func (api *API) InitReports() { api.BaseRoutes.Reports.Handle("/users", api.APISessionRequired(getUsersForReporting)).Methods("GET") + api.BaseRoutes.Reports.Handle("/users/count", api.APISessionRequired(getUserCountForReporting)).Methods("GET") } func getUsersForReporting(c *Context, w http.ResponseWriter, r *http.Request) { @@ -23,51 +25,13 @@ func getUsersForReporting(c *Context, w http.ResponseWriter, r *http.Request) { return } - sortColumn := "Username" - if r.URL.Query().Get("sort_column") != "" { - sortColumn = r.URL.Query().Get("sort_column") - } - - direction := "down" - if r.URL.Query().Get("direction") == "up" { - direction = "up" - } - - pageSize := 50 - if pageSizeStr, err := strconv.ParseInt(r.URL.Query().Get("page_size"), 10, 64); err == nil { - pageSize = int(pageSizeStr) - } - - teamFilter := r.URL.Query().Get("team_filter") - if !(teamFilter == "" || model.IsValidId(teamFilter)) { - c.Err = model.NewAppError("getUsersForReporting", "api.getUsersForReporting.invalid_team_filter", nil, "", http.StatusBadRequest) + baseOptions := fillReportingBaseOptions(r.URL.Query()) + options, err := fillUserReportOptions(r.URL.Query()) + if err != nil { + c.Err = err return } - - hideActive := r.URL.Query().Get("hide_active") == "true" - hideInactive := r.URL.Query().Get("hide_inactive") == "true" - if hideActive && hideInactive { - c.Err = model.NewAppError("getUsersForReporting", "api.getUsersForReporting.invalid_active_filter", nil, "", http.StatusBadRequest) - return - } - - options := &model.UserReportOptions{ - ReportingBaseOptions: model.ReportingBaseOptions{ - Direction: direction, - SortColumn: sortColumn, - SortDesc: r.URL.Query().Get("sort_direction") == "desc", - PageSize: pageSize, - FromColumnValue: r.URL.Query().Get("from_column_value"), - FromId: r.URL.Query().Get("from_id"), - DateRange: r.URL.Query().Get("date_range"), - }, - Team: teamFilter, - Role: r.URL.Query().Get("role_filter"), - HasNoTeam: r.URL.Query().Get("has_no_team") == "true", - HideActive: hideActive, - HideInactive: hideInactive, - } - options.PopulateDateRange(time.Now()) + options.ReportingBaseOptions = baseOptions // Don't allow fetching more than 100 users at a time from the normal query endpoint if options.PageSize <= 0 || options.PageSize > model.ReportingMaxPageSize { @@ -85,3 +49,78 @@ func getUsersForReporting(c *Context, w http.ResponseWriter, r *http.Request) { c.Logger.Warn("Error writing response", mlog.Err(jsonErr)) } } + +func getUserCountForReporting(c *Context, w http.ResponseWriter, r *http.Request) { + if !(c.IsSystemAdmin() && c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadUserManagementUsers)) { + c.SetPermissionError(model.PermissionSysconsoleReadUserManagementUsers) + return + } + + options, err := fillUserReportOptions(r.URL.Query()) + if err != nil { + c.Err = err + return + } + + count, err := c.App.GetUserCountForReport(options) + if err != nil { + c.Err = err + return + } + + if jsonErr := json.NewEncoder(w).Encode(count); jsonErr != nil { + c.Logger.Warn("Error writing response", mlog.Err(jsonErr)) + } +} + +func fillReportingBaseOptions(values url.Values) model.ReportingBaseOptions { + sortColumn := "Username" + if values.Get("sort_column") != "" { + sortColumn = values.Get("sort_column") + } + + direction := "next" + if values.Get("direction") == "prev" { + direction = "prev" + } + + pageSize := 50 + if pageSizeStr, err := strconv.ParseInt(values.Get("page_size"), 10, 64); err == nil { + pageSize = int(pageSizeStr) + } + + return model.ReportingBaseOptions{ + Direction: direction, + SortColumn: sortColumn, + SortDesc: values.Get("sort_direction") == "desc", + PageSize: pageSize, + FromColumnValue: values.Get("from_column_value"), + FromId: values.Get("from_id"), + DateRange: values.Get("date_range"), + } +} + +func fillUserReportOptions(values url.Values) (*model.UserReportOptions, *model.AppError) { + teamFilter := values.Get("team_filter") + if !(teamFilter == "" || model.IsValidId(teamFilter)) { + return nil, model.NewAppError("getUsersForReporting", "api.getUsersForReporting.invalid_team_filter", nil, "", http.StatusBadRequest) + } + + hideActive := values.Get("hide_active") == "true" + hideInactive := values.Get("hide_inactive") == "true" + if hideActive && hideInactive { + return nil, model.NewAppError("getUsersForReporting", "api.getUsersForReporting.invalid_active_filter", nil, "", http.StatusBadRequest) + } + + options := &model.UserReportOptions{ + + Team: teamFilter, + Role: values.Get("role_filter"), + HasNoTeam: values.Get("has_no_team") == "true", + HideActive: hideActive, + HideInactive: hideInactive, + SearchTerm: values.Get("search_term"), + } + options.PopulateDateRange(time.Now()) + return options, nil +} diff --git a/server/channels/app/app_iface.go b/server/channels/app/app_iface.go index 3eb69228ce2..050d2cf9d64 100644 --- a/server/channels/app/app_iface.go +++ b/server/channels/app/app_iface.go @@ -838,6 +838,7 @@ type AppIface interface { GetUserByEmail(email string) (*model.User, *model.AppError) GetUserByRemoteID(remoteID string) (*model.User, *model.AppError) GetUserByUsername(username string) (*model.User, *model.AppError) + GetUserCountForReport(filter *model.UserReportOptions) (*int64, *model.AppError) GetUserForLogin(c request.CTX, id, loginId string) (*model.User, *model.AppError) GetUserLimits() (*model.UserLimits, *model.AppError) GetUserTermsOfService(userID string) (*model.UserTermsOfService, *model.AppError) diff --git a/server/channels/app/opentracing/opentracing_layer.go b/server/channels/app/opentracing/opentracing_layer.go index dce7cfd9b23..b7809de9c70 100644 --- a/server/channels/app/opentracing/opentracing_layer.go +++ b/server/channels/app/opentracing/opentracing_layer.go @@ -10511,6 +10511,28 @@ func (a *OpenTracingAppLayer) GetUserByUsername(username string) (*model.User, * return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) GetUserCountForReport(filter *model.UserReportOptions) (*int64, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetUserCountForReport") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.GetUserCountForReport(filter) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) GetUserForLogin(c request.CTX, id string, loginId string) (*model.User, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetUserForLogin") diff --git a/server/channels/app/report.go b/server/channels/app/report.go index e86478e0fe1..e0507b1d891 100644 --- a/server/channels/app/report.go +++ b/server/channels/app/report.go @@ -14,10 +14,6 @@ func (a *App) GetUsersForReporting(filter *model.UserReportOptions) ([]*model.Us return nil, appErr } - return a.getUserReport(filter) -} - -func (a *App) getUserReport(filter *model.UserReportOptions) ([]*model.UserReport, *model.AppError) { userReportQuery, err := a.Srv().Store().User().GetUserReport(filter) if err != nil { return nil, model.NewAppError("GetUsersForReporting", "app.report.get_user_report.store_error", nil, "", http.StatusInternalServerError).Wrap(err) @@ -30,3 +26,12 @@ func (a *App) getUserReport(filter *model.UserReportOptions) ([]*model.UserRepor return userReports, nil } + +func (a *App) GetUserCountForReport(filter *model.UserReportOptions) (*int64, *model.AppError) { + count, err := a.Srv().Store().User().GetUserCountForReport(filter) + if err != nil { + return nil, model.NewAppError("GetUserCountForReport", "app.report.get_user_count_for_report.store_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + return &count, nil +} diff --git a/server/channels/app/user_test.go b/server/channels/app/user_test.go index 2ab1b989b65..d50d2468e3f 100644 --- a/server/channels/app/user_test.go +++ b/server/channels/app/user_test.go @@ -2008,6 +2008,5 @@ func TestGetUsersForReporting(t *testing.T) { }) require.Nil(t, err) require.NotNil(t, userReports) - require.Equal(t, "Bob Bobson", userReports[0].DisplayName) }) } diff --git a/server/channels/store/opentracinglayer/opentracinglayer.go b/server/channels/store/opentracinglayer/opentracinglayer.go index 384a2795262..ccaedaadaa4 100644 --- a/server/channels/store/opentracinglayer/opentracinglayer.go +++ b/server/channels/store/opentracinglayer/opentracinglayer.go @@ -11892,6 +11892,24 @@ func (s *OpenTracingLayerUserStore) GetUnreadCountForChannel(userID string, chan return result, err } +func (s *OpenTracingLayerUserStore) GetUserCountForReport(filter *model.UserReportOptions) (int64, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "UserStore.GetUserCountForReport") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.UserStore.GetUserCountForReport(filter) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + func (s *OpenTracingLayerUserStore) GetUserReport(filter *model.UserReportOptions) ([]*model.UserReportQuery, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "UserStore.GetUserReport") diff --git a/server/channels/store/retrylayer/retrylayer.go b/server/channels/store/retrylayer/retrylayer.go index 7a4a2189a47..2a5d3ac47a8 100644 --- a/server/channels/store/retrylayer/retrylayer.go +++ b/server/channels/store/retrylayer/retrylayer.go @@ -13577,6 +13577,27 @@ func (s *RetryLayerUserStore) GetUnreadCountForChannel(userID string, channelID } +func (s *RetryLayerUserStore) GetUserCountForReport(filter *model.UserReportOptions) (int64, error) { + + tries := 0 + for { + result, err := s.UserStore.GetUserCountForReport(filter) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerUserStore) GetUserReport(filter *model.UserReportOptions) ([]*model.UserReportQuery, error) { tries := 0 diff --git a/server/channels/store/sqlstore/user_store.go b/server/channels/store/sqlstore/user_store.go index 7209779f85f..efa91f4ba5c 100644 --- a/server/channels/store/sqlstore/user_store.go +++ b/server/channels/store/sqlstore/user_store.go @@ -2268,12 +2268,56 @@ func (us SqlUserStore) RefreshPostStatsForUsers() error { return nil } +func applyUserReportFilter(query sq.SelectBuilder, filter *model.UserReportOptions, isPostgres bool) sq.SelectBuilder { + query = applyRoleFilter(query, filter.Role, isPostgres) + if filter.HasNoTeam { + query = query.Where(sq.Expr("u.Id NOT IN (SELECT UserId FROM TeamMembers WHERE DeleteAt = 0)")) + } else if filter.Team != "" { + query = query.Join("TeamMembers tm ON (tm.UserId = u.Id AND tm.DeleteAt = 0)"). + Where(sq.Eq{"tm.TeamId": filter.Team}) + } + if filter.HideActive { + query = query.Where(sq.Gt{"u.DeleteAt": 0}) + } + if filter.HideInactive { + query = query.Where(sq.Eq{"u.DeleteAt": 0}) + } + + if strings.TrimSpace(filter.SearchTerm) != "" { + query = generateSearchQuery(query, strings.Fields(sanitizeSearchTerm(filter.SearchTerm, "*")), UserSearchTypeAll, isPostgres) + } + + return query +} + +func (us SqlUserStore) GetUserCountForReport(filter *model.UserReportOptions) (int64, error) { + isPostgres := us.DriverName() == model.DatabaseDriverPostgres + query := us.getQueryBuilder(). + Select("COUNT(u.Id)"). + From("Users u") + + if isPostgres { + query = query.LeftJoin("Bots ON u.Id = Bots.UserId").Where("Bots.UserId IS NULL") + } else { + query = query.Where(sq.Expr("u.Id NOT IN (SELECT UserId FROM Bots)")) + } + + query = applyUserReportFilter(query, filter, isPostgres) + queryStr, args, err := query.ToSql() + if err != nil { + return 0, errors.Wrap(err, "user_count_report_tosql") + } + var v int64 + err = us.GetReplicaX().Get(&v, queryStr, args...) + if err != nil { + return 0, errors.Wrap(err, "failed to count Users for report") + } + return v, nil +} + func (us SqlUserStore) GetUserReport(filter *model.UserReportOptions) ([]*model.UserReportQuery, error) { isPostgres := us.DriverName() == model.DatabaseDriverPostgres - selectColumns := []string{"u.Id", "u.LastLogin", "MAX(s.LastActivityAt) AS LastStatusAt"} - for _, column := range model.UserReportSortColumns { - selectColumns = append(selectColumns, "u."+column) - } + selectColumns := []string{"u.*", "MAX(s.LastActivityAt) AS LastStatusAt"} if isPostgres { selectColumns = append(selectColumns, "MAX(ps.LastPostDate) AS LastPostDate", @@ -2303,7 +2347,7 @@ func (us SqlUserStore) GetUserReport(filter *model.UserReportOptions) ([]*model. // no need to apply any filtering and pagination if there are no // previous element ID and value provided. if filter.FromId != "" && filter.FromColumnValue != "" { - if (filter.Direction == "up" && !filter.SortDesc) || (filter.Direction == "down" && filter.SortDesc) { + if (filter.Direction == "prev" && !filter.SortDesc) || (filter.Direction == "next" && filter.SortDesc) { sortDirection = "DESC" query = query.Where(sq.Or{ @@ -2364,19 +2408,7 @@ func (us SqlUserStore) GetUserReport(filter *model.UserReportOptions) ([]*model. } } - query = applyRoleFilter(query, filter.Role, isPostgres) - if filter.HasNoTeam { - query = query.Where(sq.Expr("u.Id NOT IN (SELECT UserId FROM TeamMembers WHERE DeleteAt = 0)")) - } else if filter.Team != "" { - query = query.Join("TeamMembers tm ON (tm.UserId = u.Id AND tm.DeleteAt = 0)"). - Where(sq.Eq{"tm.TeamId": filter.Team}) - } - if filter.HideActive { - query = query.Where(sq.Gt{"u.DeleteAt": 0}) - } - if filter.HideInactive { - query = query.Where(sq.Eq{"u.DeleteAt": 0}) - } + query = applyUserReportFilter(query, filter, isPostgres) parentQuery := query // If we're going a page back... @@ -2384,7 +2416,7 @@ func (us SqlUserStore) GetUserReport(filter *model.UserReportOptions) ([]*model. // The way pagination works, we get the previous page's rows // in reverse order. So, we use parent query on it to // reverse the order in database itself. - if filter.Direction == "up" { + if filter.Direction == "prev" { reverseSortDirection := "ASC" if sortDirection == "ASC" { reverseSortDirection = "DESC" diff --git a/server/channels/store/store.go b/server/channels/store/store.go index 18e58a6fa0f..e8f0a6398cb 100644 --- a/server/channels/store/store.go +++ b/server/channels/store/store.go @@ -481,6 +481,7 @@ type UserStore interface { InsertUsers(users []*model.User) error RefreshPostStatsForUsers() error GetUserReport(filter *model.UserReportOptions) ([]*model.UserReportQuery, error) + GetUserCountForReport(filter *model.UserReportOptions) (int64, error) } type BotStore interface { diff --git a/server/channels/store/storetest/mocks/UserStore.go b/server/channels/store/storetest/mocks/UserStore.go index 7a957d71261..ac85268140b 100644 --- a/server/channels/store/storetest/mocks/UserStore.go +++ b/server/channels/store/storetest/mocks/UserStore.go @@ -1153,6 +1153,30 @@ func (_m *UserStore) GetUnreadCountForChannel(userID string, channelID string) ( return r0, r1 } +// GetUserCountForReport provides a mock function with given fields: filter +func (_m *UserStore) GetUserCountForReport(filter *model.UserReportOptions) (int64, error) { + ret := _m.Called(filter) + + var r0 int64 + var r1 error + if rf, ok := ret.Get(0).(func(*model.UserReportOptions) (int64, error)); ok { + return rf(filter) + } + if rf, ok := ret.Get(0).(func(*model.UserReportOptions) int64); ok { + r0 = rf(filter) + } else { + r0 = ret.Get(0).(int64) + } + + if rf, ok := ret.Get(1).(func(*model.UserReportOptions) error); ok { + r1 = rf(filter) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetUserReport provides a mock function with given fields: filter func (_m *UserStore) GetUserReport(filter *model.UserReportOptions) ([]*model.UserReportQuery, error) { ret := _m.Called(filter) diff --git a/server/channels/store/storetest/user_store.go b/server/channels/store/storetest/user_store.go index 966d0a3f9a5..02f1cdbcf45 100644 --- a/server/channels/store/storetest/user_store.go +++ b/server/channels/store/storetest/user_store.go @@ -6336,7 +6336,7 @@ func testGetUserReport(t *testing.T, rctx request.CTX, ss store.Store) { userReport, err := ss.User().GetUserReport(&model.UserReportOptions{ ReportingBaseOptions: model.ReportingBaseOptions{ SortColumn: "Username", - Direction: "down", + Direction: "next", PageSize: 50, FromColumnValue: users[10].Username, FromId: users[10].Id, @@ -6353,7 +6353,7 @@ func testGetUserReport(t *testing.T, rctx request.CTX, ss store.Store) { ReportingBaseOptions: model.ReportingBaseOptions{ SortColumn: "Username", SortDesc: true, - Direction: "down", + Direction: "next", PageSize: 50, FromColumnValue: users[10].Username, FromId: users[10].Id, @@ -6369,7 +6369,7 @@ func testGetUserReport(t *testing.T, rctx request.CTX, ss store.Store) { userReport, err = ss.User().GetUserReport(&model.UserReportOptions{ ReportingBaseOptions: model.ReportingBaseOptions{ SortColumn: "Username", - Direction: "up", + Direction: "prev", PageSize: 50, FromColumnValue: users[10].Username, FromId: users[10].Id, @@ -6386,7 +6386,7 @@ func testGetUserReport(t *testing.T, rctx request.CTX, ss store.Store) { ReportingBaseOptions: model.ReportingBaseOptions{ SortColumn: "Username", SortDesc: true, - Direction: "up", + Direction: "prev", PageSize: 50, FromColumnValue: users[10].Username, FromId: users[10].Id, @@ -6541,4 +6541,26 @@ func testGetUserReport(t *testing.T, rctx request.CTX, ss store.Store) { require.NoError(t, err) require.Len(t, userReport, 15) }) + + t.Run("should filter on search term", func(t *testing.T) { + userReport, err := ss.User().GetUserReport(&model.UserReportOptions{ + ReportingBaseOptions: model.ReportingBaseOptions{ + SortColumn: "Username", + PageSize: 50, + }, + SearchTerm: "username_1", + }) + require.NoError(t, err) + require.Len(t, userReport, 26) + + userReport, err = ss.User().GetUserReport(&model.UserReportOptions{ + ReportingBaseOptions: model.ReportingBaseOptions{ + SortColumn: "Username", + PageSize: 50, + }, + SearchTerm: "username_2", + }) + require.NoError(t, err) + require.Len(t, userReport, 11) + }) } diff --git a/server/channels/store/timerlayer/timerlayer.go b/server/channels/store/timerlayer/timerlayer.go index 72f35e57061..b352c8a2527 100644 --- a/server/channels/store/timerlayer/timerlayer.go +++ b/server/channels/store/timerlayer/timerlayer.go @@ -10709,6 +10709,22 @@ func (s *TimerLayerUserStore) GetUnreadCountForChannel(userID string, channelID return result, err } +func (s *TimerLayerUserStore) GetUserCountForReport(filter *model.UserReportOptions) (int64, error) { + start := time.Now() + + result, err := s.UserStore.GetUserCountForReport(filter) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("UserStore.GetUserCountForReport", success, elapsed) + } + return result, err +} + func (s *TimerLayerUserStore) GetUserReport(filter *model.UserReportOptions) ([]*model.UserReportQuery, error) { start := time.Now() diff --git a/server/i18n/en.json b/server/i18n/en.json index 6c95f086fe5..88048502236 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -6622,6 +6622,10 @@ "id": "app.recover.save.app_error", "translation": "Unable to save the token." }, + { + "id": "app.report.get_user_count_for_report.store_error", + "translation": "Failed to fetch user count." + }, { "id": "app.report.get_user_report.store_error", "translation": "Failed to fetch user report." diff --git a/server/public/model/report.go b/server/public/model/report.go index f65bbf597b5..86a06997edf 100644 --- a/server/public/model/report.go +++ b/server/public/model/report.go @@ -24,7 +24,7 @@ var ( type ReportingBaseOptions struct { SortDesc bool - Direction string // Accepts only "up" or "down" + Direction string // Accepts only "prev" or "next" PageSize int SortColumn string FromColumnValue string @@ -66,12 +66,7 @@ type UserReportQuery struct { } type UserReport struct { - Id string `json:"id"` - Username string `json:"username"` - Email string `json:"email"` - CreateAt int64 `json:"create_at,omitempty"` - DisplayName string `json:"display_name"` - Roles string `json:"roles"` + User UserPostStats } @@ -82,6 +77,7 @@ type UserReportOptions struct { HasNoTeam bool HideActive bool HideInactive bool + SearchTerm string } func (u *UserReportOptions) IsValid() *AppError { @@ -99,12 +95,7 @@ func (u *UserReportOptions) IsValid() *AppError { func (u *UserReportQuery) ToReport() *UserReport { return &UserReport{ - Id: u.Id, - Username: u.Username, - Email: u.Email, - CreateAt: u.CreateAt, - DisplayName: u.GetDisplayName(ShowNicknameFullName), - Roles: u.Roles, + User: u.User, UserPostStats: u.UserPostStats, } } diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index ea6e7e83eca..3d864d016f4 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -52,7 +52,7 @@ import { ChannelSearchOpts, ServerChannel, } from '@mattermost/types/channels'; -import {Options, StatusOK, ClientResponse, LogLevel, FetchPaginatedThreadOptions, UserReportOptions} from '@mattermost/types/client4'; +import {Options, StatusOK, ClientResponse, LogLevel, FetchPaginatedThreadOptions, UserReportOptions, UserReportFilter} from '@mattermost/types/client4'; import {Compliance} from '@mattermost/types/compliance'; import { ClientConfig, @@ -999,6 +999,14 @@ export default class Client4 { ); } + getUserCountForReporting = (filter: UserReportFilter) => { + const queryString = buildQueryString(filter); + return this.doFetch( + `${this.getReportsRoute()}/users/count${queryString}`, + {method: 'get'}, + ); + } + /** * @deprecated */ diff --git a/webapp/platform/types/src/client4.ts b/webapp/platform/types/src/client4.ts index 78e362a9daa..7f04551d4e5 100644 --- a/webapp/platform/types/src/client4.ts +++ b/webapp/platform/types/src/client4.ts @@ -44,7 +44,16 @@ export enum ReportDuration { Last6Months = 'last_6_months', } -export type UserReportOptions = { +export type UserReportFilter = { + role_filter?: string, + has_no_team?: boolean, + team_filter?: string, + hide_active?: boolean, + hide_inactive?: boolean, + searchTerm?: string, +} + +export type UserReportOptions = UserReportFilter & { sort_column: 'CreateAt' | 'Username' | 'FirstName' | 'LastName' | 'Nickname' | 'Email', page_size: number, sort_direction?: 'asc' | 'desc', @@ -52,9 +61,4 @@ export type UserReportOptions = { date_range?: ReportDuration, from_column_value?: string, from_id?: string, - role_filter?: string, - has_no_team?: boolean, - team_filter?: string, - hide_active?: boolean, - hide_inactive?: boolean, } diff --git a/webapp/platform/types/src/users.ts b/webapp/platform/types/src/users.ts index e122e2f831e..08c064ea8e9 100644 --- a/webapp/platform/types/src/users.ts +++ b/webapp/platform/types/src/users.ts @@ -144,12 +144,7 @@ export type AuthChangeResponse = { follow_link: string; }; -export type UserReport = { - id: string; - username: string; - email: string; - create_at: number; - display_name: string; +export type UserReport = UserProfile & { last_login_at: number; last_status_at?: number; last_post_date?: number;