[MM-55017] Add API method to get users for Admin Reporting (#25499)

* Add store method to get reporting data

* Some store changes

* Added app layer

* Added API call, some miscellaneous fixes

* Fix lint

* Fix serialized check

* Add API docs

* Fix user store tests leaking users

* Fix test

* PR feedback

* Add filtering for role/team/activated user, filter out bot users

* Fix mock

* Fix test

* Oops

* Switch to using struct filter

* More PR feedback

* Fix gen

* Fix test

* Fix API docs

* Fix test

* Fix possible SQL injection, some query optimization

* Fix migrations

* Oops

* Add role to API

* Fix check

* Add Client4 API call for load testing

* Fix test

* Update server/channels/store/storetest/user_store.go

Co-authored-by: Ibrahim Serdar Acikgoz <serdaracikgoz86@gmail.com>

* PR feedback

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
Co-authored-by: Ibrahim Serdar Acikgoz <serdaracikgoz86@gmail.com>
Этот коммит содержится в:
Devin Binnie
2023-12-08 10:30:08 -05:00
коммит произвёл GitHub
родитель 7afc14de36
Коммит 109f4643c6
26 изменённых файлов: 2399 добавлений и 1 удалений

Просмотреть файл

@@ -3524,6 +3524,40 @@ components:
Description: Description:
description: A description for the CIDRBlock description: A description for the CIDRBlock
type: string type: string
UserReport:
type: object
properties:
id:
type: string
create_at:
description: The time in milliseconds a user was created
type: integer
format: int64
username:
type: string
email:
type: string
display_name:
type: string
description: Calculated display name based on user
last_login_at:
description: Last time the user was logged in
type: integer
format: int64
last_status_at:
description: Last time the user's status was updated
type: integer
format: int64
last_post_date:
description: Last time the user made a post within the given date range
type: integer
format: int64
days_active:
description: Total number of days a user posted within the given date range
type: integer
total_posts:
description: Total number of posts made by a user within the given date range
type: integer
Installation: Installation:
type: object type: object
properties: properties:

Просмотреть файл

@@ -3251,3 +3251,97 @@
$ref: "#/components/responses/Unauthorized" $ref: "#/components/responses/Unauthorized"
"403": "403":
$ref: "#/components/responses/Forbidden" $ref: "#/components/responses/Forbidden"
/api/v4/users/report:
get:
tags:
- users
summary: Get a list of paged and sorted users for admin reporting purposes
description: >
Get a list of paged users for admin reporting purposes, based on provided parameters.
Must be a system admin to invoke this API.
##### Permissions
Requires `sysconsole_read_user_management_users`.
operationId: GetUsersForReporting
parameters:
- name: sort_column
in: query
description: The column to sort the users by. Must be one of ("CreateAt", "Username", "FirstName", "LastName", "Nickname", "Email") or the API will return an error.
schema:
type: string
default: 'Username'
- name: sort_direction
in: query
description: The sorting direction. Must be one of ("asc", "desc"). Will default to 'asc' if not specified or the input is invalid.
schema:
type: string
default: 'asc'
- name: page_size
in: query
description: The maximum number of users to return.
schema:
type: integer
default: 50
minimum: 1
maximum: 100
- name: last_column_value
in: query
description: The value of the sorted column belonging to the last user returned in the page. Should be blank for the first page asked for.
schema:
type: string
- name: last_id
in: query
description: The value of the user id belonging to the last user returned in the page. Should be blank for the first page asked for.
schema:
type: string
- name: date_range
in: query
description: The date range of the post statistics to display. Must be one of ("last30days", "previousmonth", "last6months", "alltime"). Will default to 'alltime' if the input is not valid.
schema:
type: string
default: 'alltime'
- 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
responses:
"200":
description: User page retrieval successful
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/UserReport"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"500":
$ref: "#/components/responses/InternalServerError"

Просмотреть файл

@@ -108,6 +108,8 @@ func (api *API) InitUser() {
api.BaseRoutes.Users.Handle("/notify-admin", api.APISessionRequired(handleNotifyAdmin)).Methods("POST") api.BaseRoutes.Users.Handle("/notify-admin", api.APISessionRequired(handleNotifyAdmin)).Methods("POST")
api.BaseRoutes.Users.Handle("/trigger-notify-admin-posts", api.APISessionRequired(handleTriggerNotifyAdminPosts)).Methods("POST") api.BaseRoutes.Users.Handle("/trigger-notify-admin-posts", api.APISessionRequired(handleTriggerNotifyAdminPosts)).Methods("POST")
api.BaseRoutes.Users.Handle("/report", api.APISessionRequired(getUsersForReporting)).Methods("GET")
} }
func createUser(c *Context, w http.ResponseWriter, r *http.Request) { func createUser(c *Context, w http.ResponseWriter, r *http.Request) {
@@ -3443,3 +3445,59 @@ func getUsersWithInvalidEmails(c *Context, w http.ResponseWriter, r *http.Reques
c.Logger.Warn("Error writing response", mlog.Err(err)) c.Logger.Warn("Error writing response", mlog.Err(err))
} }
} }
func getUsersForReporting(c *Context, w http.ResponseWriter, r *http.Request) {
if !(c.IsSystemAdmin() && c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadUserManagementUsers)) {
c.SetPermissionError(model.PermissionSysconsoleReadUserManagementUsers)
return
}
sortColumn := "Username"
if r.URL.Query().Get("sort_column") != "" {
sortColumn = r.URL.Query().Get("sort_column")
}
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)
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.UserReportOptionsAPI{
UserReportOptionsWithoutDateRange: model.UserReportOptionsWithoutDateRange{
SortColumn: sortColumn,
SortDesc: r.URL.Query().Get("sort_direction") == "desc",
PageSize: pageSize,
Team: teamFilter,
LastSortColumnValue: r.URL.Query().Get("last_column_value"),
LastUserId: r.URL.Query().Get("last_id"),
Role: r.URL.Query().Get("role_filter"),
HasNoTeam: r.URL.Query().Get("has_no_team") == "true",
HideActive: hideActive,
HideInactive: hideInactive,
},
DateRange: r.URL.Query().Get("date_range"),
}
userReports, err := c.App.GetUsersForReporting(options.ToBaseOptions(time.Now()))
if err != nil {
c.Err = err
return
}
if jsonErr := json.NewEncoder(w).Encode(userReports); jsonErr != nil {
c.Logger.Warn("Error writing response", mlog.Err(jsonErr))
}
}

Просмотреть файл

@@ -828,6 +828,7 @@ type AppIface interface {
GetUsersByIds(userIDs []string, options *store.UserGetByIdsOpts) ([]*model.User, *model.AppError) GetUsersByIds(userIDs []string, options *store.UserGetByIdsOpts) ([]*model.User, *model.AppError)
GetUsersByUsernames(usernames []string, asAdmin bool, viewRestrictions *model.ViewUsersRestrictions) ([]*model.User, *model.AppError) GetUsersByUsernames(usernames []string, asAdmin bool, viewRestrictions *model.ViewUsersRestrictions) ([]*model.User, *model.AppError)
GetUsersEtag(restrictionsHash string) string GetUsersEtag(restrictionsHash string) string
GetUsersForReporting(filter *model.UserReportOptions) ([]*model.UserReport, *model.AppError)
GetUsersFromProfiles(options *model.UserGetOptions) ([]*model.User, *model.AppError) GetUsersFromProfiles(options *model.UserGetOptions) ([]*model.User, *model.AppError)
GetUsersInChannel(options *model.UserGetOptions) ([]*model.User, *model.AppError) GetUsersInChannel(options *model.UserGetOptions) ([]*model.User, *model.AppError)
GetUsersInChannelByAdmin(options *model.UserGetOptions) ([]*model.User, *model.AppError) GetUsersInChannelByAdmin(options *model.UserGetOptions) ([]*model.User, *model.AppError)

Просмотреть файл

@@ -10704,6 +10704,28 @@ func (a *OpenTracingAppLayer) GetUsersEtag(restrictionsHash string) string {
return resultVar0 return resultVar0
} }
func (a *OpenTracingAppLayer) GetUsersForReporting(filter *model.UserReportOptions) ([]*model.UserReport, *model.AppError) {
origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetUsersForReporting")
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.GetUsersForReporting(filter)
if resultVar1 != nil {
span.LogFields(spanlog.Error(resultVar1))
ext.Error.Set(span, true)
}
return resultVar0, resultVar1
}
func (a *OpenTracingAppLayer) GetUsersFromProfiles(options *model.UserGetOptions) ([]*model.User, *model.AppError) { func (a *OpenTracingAppLayer) GetUsersFromProfiles(options *model.UserGetOptions) ([]*model.User, *model.AppError) {
origCtx := a.ctx origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetUsersFromProfiles") span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetUsersFromProfiles")

Просмотреть файл

@@ -24,6 +24,7 @@ import (
"github.com/mattermost/mattermost/server/public/shared/i18n" "github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/mlog" "github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request" "github.com/mattermost/mattermost/server/public/shared/request"
pUtils "github.com/mattermost/mattermost/server/public/utils"
"github.com/mattermost/mattermost/server/v8/channels/app/email" "github.com/mattermost/mattermost/server/v8/channels/app/email"
"github.com/mattermost/mattermost/server/v8/channels/app/imaging" "github.com/mattermost/mattermost/server/v8/channels/app/imaging"
"github.com/mattermost/mattermost/server/v8/channels/app/users" "github.com/mattermost/mattermost/server/v8/channels/app/users"
@@ -2817,3 +2818,36 @@ func (a *App) UserIsFirstAdmin(user *model.User) bool {
return true return true
} }
func (a *App) GetUsersForReporting(filter *model.UserReportOptions) ([]*model.UserReport, *model.AppError) {
// Don't allow fetching more than 100 users at a time from the normal query endpoint
if filter.PageSize <= 0 || filter.PageSize > 100 {
return nil, model.NewAppError("GetUsersForReporting", "app.user.get_users_for_reporting.invalid_page_size", nil, "", http.StatusBadRequest)
}
// Validate date range
if filter.EndAt > 0 && filter.StartAt > filter.EndAt {
return nil, model.NewAppError("GetUsersForReporting", "app.user.get_users_for_reporting.bad_date_range", nil, "", http.StatusBadRequest)
}
return a.getUserReport(filter)
}
func (a *App) getUserReport(filter *model.UserReportOptions) ([]*model.UserReport, *model.AppError) {
// Validate against the columns we allow sorting for
if !pUtils.Contains(model.UserReportSortColumns, filter.SortColumn) {
return nil, model.NewAppError("GetUsersForReporting", "app.user.get_user_report.invalid_sort_column", nil, "", http.StatusBadRequest)
}
userReportQuery, err := a.Srv().Store().User().GetUserReport(filter)
if err != nil {
return nil, model.NewAppError("GetUsersForReporting", "app.user.get_user_report.store_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
userReports := make([]*model.UserReport, len(userReportQuery))
for i, user := range userReportQuery {
userReports[i] = user.ToReport()
}
return userReports, nil
}

Просмотреть файл

@@ -1932,3 +1932,96 @@ func TestSendSubscriptionHistoryEvent(t *testing.T) {
require.Equal(t, 10, subscriptionHistoryEvent.Seats, "Number of seats doesn't match") require.Equal(t, 10, subscriptionHistoryEvent.Seats, "Number of seats doesn't match")
}) })
} }
func TestGetUsersForReporting(t *testing.T) {
t.Run("should throw error on invalid page size", func(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
userReports, err := th.App.GetUsersForReporting(&model.UserReportOptions{
UserReportOptionsWithoutDateRange: model.UserReportOptionsWithoutDateRange{
SortColumn: "Username",
PageSize: 999,
},
})
require.Error(t, err)
require.Nil(t, userReports)
})
t.Run("should throw error on invalid date range", func(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
userReports, err := th.App.GetUsersForReporting(&model.UserReportOptions{
UserReportOptionsWithoutDateRange: model.UserReportOptionsWithoutDateRange{
SortColumn: "Username",
PageSize: 50,
},
StartAt: 1000,
EndAt: 500,
})
require.Error(t, err)
require.Nil(t, userReports)
})
t.Run("should throw error on bad sort column", func(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
userReports, err := th.App.GetUsersForReporting(&model.UserReportOptions{
UserReportOptionsWithoutDateRange: model.UserReportOptionsWithoutDateRange{
SortColumn: "FakeColumn",
PageSize: 50,
},
})
require.Error(t, err)
require.Nil(t, userReports)
})
t.Run("should return some formatted reporting data", func(t *testing.T) {
th := SetupWithStoreMock(t)
defer th.TearDown()
// Mock to get the user count
mockStore := th.App.Srv().Store().(*storemocks.Store)
mockUserStore := storemocks.UserStore{}
mockUserStore.On("GetUserReport",
mock.Anything,
mock.Anything,
mock.Anything,
mock.Anything,
mock.Anything,
mock.Anything,
mock.Anything,
mock.Anything,
mock.Anything,
mock.Anything,
mock.Anything,
mock.Anything,
).Return([]*model.UserReportQuery{
{
User: model.User{
Id: "some-id",
CreateAt: 1000,
FirstName: "Bob",
LastName: "Bobson",
},
UserPostStats: model.UserPostStats{
LastLogin: 1500,
},
},
}, nil)
mockStore.On("User").Return(&mockUserStore)
userReports, err := th.App.GetUsersForReporting(&model.UserReportOptions{
UserReportOptionsWithoutDateRange: model.UserReportOptionsWithoutDateRange{
SortColumn: "Username",
PageSize: 50,
},
})
require.Nil(t, err)
require.NotNil(t, userReports)
require.Equal(t, "Bob Bobson", userReports[0].DisplayName)
})
}

Просмотреть файл

@@ -232,6 +232,8 @@ channels/db/migrations/mysql/000116_create_outgoing_oauth_connections.down.sql
channels/db/migrations/mysql/000116_create_outgoing_oauth_connections.up.sql channels/db/migrations/mysql/000116_create_outgoing_oauth_connections.up.sql
channels/db/migrations/mysql/000117_msteams_shared_channels.down.sql channels/db/migrations/mysql/000117_msteams_shared_channels.down.sql
channels/db/migrations/mysql/000117_msteams_shared_channels.up.sql channels/db/migrations/mysql/000117_msteams_shared_channels.up.sql
channels/db/migrations/mysql/000118_create_index_poststats.down.sql
channels/db/migrations/mysql/000118_create_index_poststats.up.sql
channels/db/migrations/postgres/000001_create_teams.down.sql channels/db/migrations/postgres/000001_create_teams.down.sql
channels/db/migrations/postgres/000001_create_teams.up.sql channels/db/migrations/postgres/000001_create_teams.up.sql
channels/db/migrations/postgres/000002_create_team_members.down.sql channels/db/migrations/postgres/000002_create_team_members.down.sql
@@ -464,3 +466,5 @@ channels/db/migrations/postgres/000116_create_outgoing_oauth_connections.down.sq
channels/db/migrations/postgres/000116_create_outgoing_oauth_connections.up.sql channels/db/migrations/postgres/000116_create_outgoing_oauth_connections.up.sql
channels/db/migrations/postgres/000117_msteams_shared_channels.down.sql channels/db/migrations/postgres/000117_msteams_shared_channels.down.sql
channels/db/migrations/postgres/000117_msteams_shared_channels.up.sql channels/db/migrations/postgres/000117_msteams_shared_channels.up.sql
channels/db/migrations/postgres/000118_create_index_poststats.down.sql
channels/db/migrations/postgres/000118_create_index_poststats.up.sql

Просмотреть файл

@@ -0,0 +1 @@
-- Nothing to do for MySQL

Просмотреть файл

@@ -0,0 +1 @@
-- Nothing to do for MySQL

Просмотреть файл

@@ -0,0 +1 @@
DROP INDEX IF EXISTS idx_poststats_userid;

Просмотреть файл

@@ -0,0 +1,2 @@
-- morph:nontransactional
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_poststats_userid ON poststats(userid)

Просмотреть файл

@@ -11892,6 +11892,24 @@ func (s *OpenTracingLayerUserStore) GetUnreadCountForChannel(userID string, chan
return result, err 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")
s.Root.Store.SetContext(newCtx)
defer func() {
s.Root.Store.SetContext(origCtx)
}()
defer span.Finish()
result, err := s.UserStore.GetUserReport(filter)
if err != nil {
span.LogFields(spanlog.Error(err))
ext.Error.Set(span, true)
}
return result, err
}
func (s *OpenTracingLayerUserStore) GetUsersBatchForIndexing(startTime int64, startFileID string, limit int) ([]*model.UserForIndexing, error) { func (s *OpenTracingLayerUserStore) GetUsersBatchForIndexing(startTime int64, startFileID string, limit int) ([]*model.UserForIndexing, error) {
origCtx := s.Root.Store.Context() origCtx := s.Root.Store.Context()
span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "UserStore.GetUsersBatchForIndexing") span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "UserStore.GetUsersBatchForIndexing")

Просмотреть файл

@@ -13577,6 +13577,27 @@ func (s *RetryLayerUserStore) GetUnreadCountForChannel(userID string, channelID
} }
func (s *RetryLayerUserStore) GetUserReport(filter *model.UserReportOptions) ([]*model.UserReportQuery, error) {
tries := 0
for {
result, err := s.UserStore.GetUserReport(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) GetUsersBatchForIndexing(startTime int64, startFileID string, limit int) ([]*model.UserForIndexing, error) { func (s *RetryLayerUserStore) GetUsersBatchForIndexing(startTime int64, startFileID string, limit int) ([]*model.UserForIndexing, error) {
tries := 0 tries := 0

Просмотреть файл

@@ -10,6 +10,7 @@ import (
"fmt" "fmt"
"sort" "sort"
"strings" "strings"
"time"
"unicode/utf8" "unicode/utf8"
sq "github.com/mattermost/squirrel" sq "github.com/mattermost/squirrel"
@@ -2266,3 +2267,102 @@ func (us SqlUserStore) RefreshPostStatsForUsers() error {
return nil return 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)
}
if isPostgres {
selectColumns = append(selectColumns,
"MAX(ps.LastPostDate) AS LastPostDate",
"COUNT(ps.Day) AS DaysActive",
"SUM(ps.NumPosts) AS TotalPosts",
)
} else {
selectColumns = append(selectColumns,
"MAX(p.CreateAt) AS LastPostDate",
"COUNT(DATE(FROM_UNIXTIME(p.CreateAt / 1000))) AS DaysActive",
"COUNT(p.Id) AS TotalPosts",
)
}
sortColumnValue := filter.SortColumn
if filter.SortDesc {
sortColumnValue += " DESC"
}
query := us.getQueryBuilder().
Select(selectColumns...).
From("Users u").
LeftJoin("Status s ON s.UserId = u.Id").
Where(sq.Or{
sq.Gt{filter.SortColumn: filter.LastSortColumnValue},
sq.And{
sq.Eq{filter.SortColumn: filter.LastSortColumnValue},
sq.Gt{"u.Id": filter.LastUserId},
},
}).
Where(sq.Expr("u.Id NOT IN (SELECT UserId FROM Bots)")).
GroupBy("u.Id").
OrderBy(sortColumnValue, "u.Id")
if filter.PageSize > 0 {
query = query.Limit(uint64(filter.PageSize))
}
if isPostgres {
query = query.LeftJoin("PostStats ps ON ps.UserId = u.Id")
if filter.StartAt > 0 {
startDate := time.UnixMilli(filter.StartAt)
query = query.Where(sq.Or{
sq.Expr("ps.UserId IS NULL"),
sq.GtOrEq{"ps.Day": startDate.Format("2006-01-02")},
})
}
if filter.EndAt > 0 {
endDate := time.UnixMilli(filter.EndAt)
query = query.Where(sq.Or{
sq.Expr("ps.UserId IS NULL"),
sq.Lt{"ps.Day": endDate.Format("2006-01-02")},
})
}
} else {
query = query.LeftJoin("Posts p on p.UserId = u.Id")
if filter.StartAt > 0 {
query = query.Where(sq.Or{
sq.Expr("p.UserId IS NULL"),
sq.GtOrEq{"p.CreateAt": filter.StartAt},
})
}
if filter.EndAt > 0 {
query = query.Where(sq.Or{
sq.Expr("p.UserId IS NULL"),
sq.Lt{"p.CreateAt": filter.EndAt},
})
}
}
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})
}
userResults := []*model.UserReportQuery{}
err := us.GetReplicaX().SelectBuilder(&userResults, query)
if err != nil {
return nil, errors.Wrap(err, "failed to get users for reporting")
}
return userResults, nil
}

Просмотреть файл

@@ -493,6 +493,7 @@ type UserStore interface {
GetUsersWithInvalidEmails(page int, perPage int, restrictedDomains string) ([]*model.User, error) GetUsersWithInvalidEmails(page int, perPage int, restrictedDomains string) ([]*model.User, error)
InsertUsers(users []*model.User) error InsertUsers(users []*model.User) error
RefreshPostStatsForUsers() error RefreshPostStatsForUsers() error
GetUserReport(filter *model.UserReportOptions) ([]*model.UserReportQuery, error)
} }
type BotStore interface { type BotStore interface {

Просмотреть файл

@@ -1153,6 +1153,32 @@ func (_m *UserStore) GetUnreadCountForChannel(userID string, channelID string) (
return r0, r1 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)
var r0 []*model.UserReportQuery
var r1 error
if rf, ok := ret.Get(0).(func(*model.UserReportOptions) ([]*model.UserReportQuery, error)); ok {
return rf(filter)
}
if rf, ok := ret.Get(0).(func(*model.UserReportOptions) []*model.UserReportQuery); ok {
r0 = rf(filter)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*model.UserReportQuery)
}
}
if rf, ok := ret.Get(1).(func(*model.UserReportOptions) error); ok {
r1 = rf(filter)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetUsersBatchForIndexing provides a mock function with given fields: startTime, startFileID, limit // GetUsersBatchForIndexing provides a mock function with given fields: startTime, startFileID, limit
func (_m *UserStore) GetUsersBatchForIndexing(startTime int64, startFileID string, limit int) ([]*model.UserForIndexing, error) { func (_m *UserStore) GetUsersBatchForIndexing(startTime int64, startFileID string, limit int) ([]*model.UserForIndexing, error) {
ret := _m.Called(startTime, startFileID, limit) ret := _m.Called(startTime, startFileID, limit)

Просмотреть файл

@@ -96,6 +96,7 @@ func TestUserStore(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore) {
t.Run("GetKnownUsers", func(t *testing.T) { testGetKnownUsers(t, rctx, ss) }) t.Run("GetKnownUsers", func(t *testing.T) { testGetKnownUsers(t, rctx, ss) })
t.Run("GetUsersWithInvalidEmails", func(t *testing.T) { testGetUsersWithInvalidEmails(t, rctx, ss) }) t.Run("GetUsersWithInvalidEmails", func(t *testing.T) { testGetUsersWithInvalidEmails(t, rctx, ss) })
t.Run("UpdateLastLogin", func(t *testing.T) { testUpdateLastLogin(t, rctx, ss) }) t.Run("UpdateLastLogin", func(t *testing.T) { testUpdateLastLogin(t, rctx, ss) })
t.Run("GetUserReport", func(t *testing.T) { testGetUserReport(t, rctx, ss) })
} }
func testUserStoreSave(t *testing.T, rctx request.CTX, ss store.Store) { func testUserStoreSave(t *testing.T, rctx request.CTX, ss store.Store) {
@@ -4904,6 +4905,7 @@ func testUserStoreGetUsersBatchForIndexing(t *testing.T, rctx request.CTX, ss st
CreateAt: model.GetMillis(), CreateAt: model.GetMillis(),
}) })
require.NoError(t, err) require.NoError(t, err)
defer func() { require.NoError(t, ss.User().PermanentDelete(u1.Id)) }()
time.Sleep(time.Millisecond) time.Sleep(time.Millisecond)
@@ -4913,6 +4915,7 @@ func testUserStoreGetUsersBatchForIndexing(t *testing.T, rctx request.CTX, ss st
CreateAt: model.GetMillis(), CreateAt: model.GetMillis(),
}) })
require.NoError(t, err) require.NoError(t, err)
defer func() { require.NoError(t, ss.User().PermanentDelete(u2.Id)) }()
_, nErr = ss.Team().SaveMember(&model.TeamMember{ _, nErr = ss.Team().SaveMember(&model.TeamMember{
UserId: u2.Id, UserId: u2.Id,
TeamId: t1.Id, TeamId: t1.Id,
@@ -4939,6 +4942,7 @@ func testUserStoreGetUsersBatchForIndexing(t *testing.T, rctx request.CTX, ss st
CreateAt: model.GetMillis(), CreateAt: model.GetMillis(),
}) })
require.NoError(t, err) require.NoError(t, err)
defer func() { require.NoError(t, ss.User().PermanentDelete(u3.Id)) }()
_, nErr = ss.Team().SaveMember(&model.TeamMember{ _, nErr = ss.Team().SaveMember(&model.TeamMember{
UserId: u3.Id, UserId: u3.Id,
TeamId: t1.Id, TeamId: t1.Id,
@@ -6182,3 +6186,297 @@ func testUpdateLastLogin(t *testing.T, rctx request.CTX, ss store.Store) {
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, int64(1234567890), user.LastLogin) require.Equal(t, int64(1234567890), user.LastLogin)
} }
func testGetUserReport(t *testing.T, rctx request.CTX, ss store.Store) {
now := time.Now()
u1 := &model.User{Username: "u1" + model.NewId(), DeleteAt: 0}
u1.Email = MakeEmail()
u1, err := ss.User().Save(u1)
require.NoError(t, err)
defer func() { require.NoError(t, ss.User().PermanentDelete(u1.Id)) }()
for i := 0; i < 5; i++ {
p := model.Post{UserId: u1.Id, ChannelId: model.NewId(), Message: NewTestId(), CreateAt: now.AddDate(0, 0, -i).UnixMilli()}
_, err = ss.Post().Save(&p)
require.NoError(t, err)
}
u2 := &model.User{Username: "u2" + model.NewId(), Roles: "system", DeleteAt: 0}
u2.Email = MakeEmail()
u2, err = ss.User().Save(u2)
require.NoError(t, err)
defer func() { require.NoError(t, ss.User().PermanentDelete(u2.Id)) }()
for i := 0; i < 5; i++ {
p := model.Post{UserId: u2.Id, ChannelId: model.NewId(), Message: NewTestId(), CreateAt: now.AddDate(0, 0, -i).UnixMilli()}
_, err = ss.Post().Save(&p)
require.NoError(t, err)
}
u3 := &model.User{Username: "u3" + model.NewId(), Roles: "guest", DeleteAt: now.UnixMilli()}
u3.Email = MakeEmail()
u3, err = ss.User().Save(u3)
require.NoError(t, err)
team, err := ss.Team().Save(&model.Team{
DisplayName: "DisplayName",
Name: NewTestId(),
Email: MakeEmail(),
Type: model.TeamOpen,
})
require.NoError(t, err)
_, err = ss.Team().SaveMember(&model.TeamMember{UserId: u3.Id, TeamId: team.Id}, 1)
require.NoError(t, err)
defer func() {
require.NoError(t, ss.Team().RemoveMember(rctx, team.Id, u3.Id))
require.NoError(t, ss.Team().PermanentDelete(team.Id))
}()
defer func() { require.NoError(t, ss.User().PermanentDelete(u3.Id)) }()
for i := 0; i < 5; i++ {
p := model.Post{UserId: u3.Id, ChannelId: model.NewId(), Message: NewTestId(), CreateAt: now.AddDate(0, 0, -i).UnixMilli()}
_, err = ss.Post().Save(&p)
require.NoError(t, err)
}
err = ss.User().RefreshPostStatsForUsers()
require.NoError(t, err)
t.Run("should return info for all the users", func(t *testing.T) {
userReport, err := ss.User().GetUserReport(&model.UserReportOptions{
UserReportOptionsWithoutDateRange: model.UserReportOptionsWithoutDateRange{
SortColumn: "Username",
PageSize: 50,
},
})
require.NoError(t, err)
require.NotNil(t, userReport)
require.Len(t, userReport, 3)
require.NotNil(t, userReport[0])
require.Equal(t, u1.Username, userReport[0].Username)
require.NotNil(t, userReport[1])
require.Equal(t, u2.Username, userReport[1].Username)
require.NotNil(t, userReport[2])
require.Equal(t, u3.Username, userReport[2].Username)
})
t.Run("should return in the correct order", func(t *testing.T) {
userReport, err := ss.User().GetUserReport(&model.UserReportOptions{
UserReportOptionsWithoutDateRange: model.UserReportOptionsWithoutDateRange{
SortColumn: "Username",
SortDesc: true,
PageSize: 50,
},
})
require.NoError(t, err)
require.NotNil(t, userReport)
require.Equal(t, 3, len(userReport))
require.NotNil(t, userReport[0])
require.Equal(t, u3.Username, userReport[0].Username)
require.NotNil(t, userReport[1])
require.Equal(t, u2.Username, userReport[1].Username)
require.NotNil(t, userReport[2])
require.Equal(t, u1.Username, userReport[2].Username)
})
t.Run("should fail on invalid sort column", func(t *testing.T) {
userReport, err := ss.User().GetUserReport(&model.UserReportOptions{
UserReportOptionsWithoutDateRange: model.UserReportOptionsWithoutDateRange{
SortColumn: "FakeColumn",
SortDesc: true,
PageSize: 50,
},
})
require.Error(t, err)
require.Nil(t, userReport)
})
t.Run("should only return amount of users in page", func(t *testing.T) {
userReport, err := ss.User().GetUserReport(&model.UserReportOptions{
UserReportOptionsWithoutDateRange: model.UserReportOptionsWithoutDateRange{
SortColumn: "Username",
PageSize: 2,
},
})
require.NoError(t, err)
require.NotNil(t, userReport)
require.Equal(t, 2, len(userReport))
require.NotNil(t, userReport[0])
require.Equal(t, u1.Username, userReport[0].Username)
require.NotNil(t, userReport[1])
require.Equal(t, u2.Username, userReport[1].Username)
})
t.Run("should return correct paging", func(t *testing.T) {
userReport, err := ss.User().GetUserReport(&model.UserReportOptions{
UserReportOptionsWithoutDateRange: model.UserReportOptionsWithoutDateRange{
SortColumn: "Username",
PageSize: 50,
LastSortColumnValue: u2.Username,
LastUserId: u2.Id,
},
})
require.NoError(t, err)
require.NotNil(t, userReport)
require.Equal(t, 1, len(userReport))
require.NotNil(t, userReport[0])
require.Equal(t, u3.Username, userReport[0].Username)
})
t.Run("should return accurate post stats for various date ranges", func(t *testing.T) {
userReport, err := ss.User().GetUserReport(&model.UserReportOptions{
UserReportOptionsWithoutDateRange: model.UserReportOptionsWithoutDateRange{
SortColumn: "Username",
PageSize: 50,
},
})
require.NoError(t, err)
require.Len(t, userReport, 3)
require.Equal(t, 5, *userReport[0].TotalPosts)
require.Equal(t, 5, *userReport[0].DaysActive)
require.Equal(t, now.UnixMilli(), *userReport[0].LastPostDate)
require.Equal(t, 5, *userReport[1].TotalPosts)
require.Equal(t, 5, *userReport[1].DaysActive)
require.Equal(t, now.UnixMilli(), *userReport[1].LastPostDate)
require.Equal(t, 5, *userReport[2].TotalPosts)
require.Equal(t, 5, *userReport[2].DaysActive)
require.Equal(t, now.UnixMilli(), *userReport[2].LastPostDate)
userReport, err = ss.User().GetUserReport(&model.UserReportOptions{
UserReportOptionsWithoutDateRange: model.UserReportOptionsWithoutDateRange{
SortColumn: "Username",
PageSize: 50,
},
StartAt: now.AddDate(0, 0, -2).UnixMilli(),
})
require.NoError(t, err)
require.Len(t, userReport, 3)
require.Equal(t, 3, *userReport[0].TotalPosts)
require.Equal(t, 3, *userReport[0].DaysActive)
require.Equal(t, now.UnixMilli(), *userReport[0].LastPostDate)
require.Equal(t, 3, *userReport[1].TotalPosts)
require.Equal(t, 3, *userReport[1].DaysActive)
require.Equal(t, now.UnixMilli(), *userReport[1].LastPostDate)
require.Equal(t, 3, *userReport[2].TotalPosts)
require.Equal(t, 3, *userReport[2].DaysActive)
require.Equal(t, now.UnixMilli(), *userReport[2].LastPostDate)
userReport, err = ss.User().GetUserReport(&model.UserReportOptions{
UserReportOptionsWithoutDateRange: model.UserReportOptionsWithoutDateRange{
SortColumn: "Username",
PageSize: 50,
},
EndAt: now.AddDate(0, 0, -2).UnixMilli(),
})
require.NoError(t, err)
require.Len(t, userReport, 3)
require.Equal(t, 2, *userReport[0].TotalPosts)
require.Equal(t, 2, *userReport[0].DaysActive)
require.Equal(t, now.AddDate(0, 0, -3).UnixMilli(), *userReport[0].LastPostDate)
require.Equal(t, 2, *userReport[1].TotalPosts)
require.Equal(t, 2, *userReport[1].DaysActive)
require.Equal(t, now.AddDate(0, 0, -3).UnixMilli(), *userReport[1].LastPostDate)
require.Equal(t, 2, *userReport[2].TotalPosts)
require.Equal(t, 2, *userReport[2].DaysActive)
require.Equal(t, now.AddDate(0, 0, -3).UnixMilli(), *userReport[2].LastPostDate)
userReport, err = ss.User().GetUserReport(&model.UserReportOptions{
UserReportOptionsWithoutDateRange: model.UserReportOptionsWithoutDateRange{
SortColumn: "Username",
PageSize: 50,
},
StartAt: now.AddDate(0, 0, -3).UnixMilli(),
EndAt: now.AddDate(0, 0, -2).UnixMilli(),
})
require.NoError(t, err)
require.Len(t, userReport, 3)
require.Equal(t, 1, *userReport[0].TotalPosts)
require.Equal(t, 1, *userReport[0].DaysActive)
require.Equal(t, now.AddDate(0, 0, -3).UnixMilli(), *userReport[0].LastPostDate)
require.Equal(t, 1, *userReport[1].TotalPosts)
require.Equal(t, 1, *userReport[1].DaysActive)
require.Equal(t, now.AddDate(0, 0, -3).UnixMilli(), *userReport[1].LastPostDate)
require.Equal(t, 1, *userReport[2].TotalPosts)
require.Equal(t, 1, *userReport[2].DaysActive)
require.Equal(t, now.AddDate(0, 0, -3).UnixMilli(), *userReport[2].LastPostDate)
})
t.Run("should filter on roles", func(t *testing.T) {
userReport, err := ss.User().GetUserReport(&model.UserReportOptions{
UserReportOptionsWithoutDateRange: model.UserReportOptionsWithoutDateRange{
SortColumn: "Username",
PageSize: 50,
Role: "system",
},
})
require.NoError(t, err)
require.Len(t, userReport, 1)
require.Equal(t, u2.Id, userReport[0].Id)
require.Equal(t, u2.Roles, "system")
})
t.Run("should filter on teams", func(t *testing.T) {
userReport, err := ss.User().GetUserReport(&model.UserReportOptions{
UserReportOptionsWithoutDateRange: model.UserReportOptionsWithoutDateRange{
SortColumn: "Username",
PageSize: 50,
HasNoTeam: true,
},
})
require.NoError(t, err)
require.Len(t, userReport, 2)
require.Equal(t, u1.Id, userReport[0].Id)
require.Equal(t, u2.Id, userReport[1].Id)
userReport, err = ss.User().GetUserReport(&model.UserReportOptions{
UserReportOptionsWithoutDateRange: model.UserReportOptionsWithoutDateRange{
SortColumn: "Username",
PageSize: 50,
Team: team.Id,
},
})
require.NoError(t, err)
require.Len(t, userReport, 1)
require.Equal(t, u3.Id, userReport[0].Id)
})
t.Run("should filter on activation", func(t *testing.T) {
userReport, err := ss.User().GetUserReport(&model.UserReportOptions{
UserReportOptionsWithoutDateRange: model.UserReportOptionsWithoutDateRange{
SortColumn: "Username",
PageSize: 50,
HideInactive: true,
},
})
require.NoError(t, err)
require.Len(t, userReport, 2)
require.Equal(t, u1.Id, userReport[0].Id)
require.Equal(t, u2.Id, userReport[1].Id)
userReport, err = ss.User().GetUserReport(&model.UserReportOptions{
UserReportOptionsWithoutDateRange: model.UserReportOptionsWithoutDateRange{
SortColumn: "Username",
PageSize: 50,
HideActive: true,
},
})
require.NoError(t, err)
require.Len(t, userReport, 1)
require.Equal(t, u3.Id, userReport[0].Id)
})
}

Просмотреть файл

@@ -10709,6 +10709,22 @@ func (s *TimerLayerUserStore) GetUnreadCountForChannel(userID string, channelID
return result, err return result, err
} }
func (s *TimerLayerUserStore) GetUserReport(filter *model.UserReportOptions) ([]*model.UserReportQuery, error) {
start := time.Now()
result, err := s.UserStore.GetUserReport(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.GetUserReport", success, elapsed)
}
return result, err
}
func (s *TimerLayerUserStore) GetUsersBatchForIndexing(startTime int64, startFileID string, limit int) ([]*model.UserForIndexing, error) { func (s *TimerLayerUserStore) GetUsersBatchForIndexing(startTime int64, startFileID string, limit int) ([]*model.UserForIndexing, error) {
start := time.Now() start := time.Now()

Просмотреть файл

@@ -2016,6 +2016,14 @@
"id": "api.getThreadsForUser.bad_params", "id": "api.getThreadsForUser.bad_params",
"translation": "Before and After parameters to getThreadsForUser are mutually exclusive" "translation": "Before and After parameters to getThreadsForUser are mutually exclusive"
}, },
{
"id": "api.getUsersForReporting.invalid_active_filter",
"translation": "Cannot hide both active and inactive users."
},
{
"id": "api.getUsersForReporting.invalid_team_filter",
"translation": "Invalid team id provided."
},
{ {
"id": "api.image.get.app_error", "id": "api.image.get.app_error",
"translation": "Requested image url cannot be parsed." "translation": "Requested image url cannot be parsed."
@@ -7054,10 +7062,26 @@
"id": "app.user.get_unread_count.app_error", "id": "app.user.get_unread_count.app_error",
"translation": "We could not get the unread message count for the user." "translation": "We could not get the unread message count for the user."
}, },
{
"id": "app.user.get_user_report.invalid_sort_column",
"translation": "Provided sort column is not valid."
},
{
"id": "app.user.get_user_report.store_error",
"translation": "There was an error querying the user report."
},
{ {
"id": "app.user.get_users_batch_for_indexing.get_users.app_error", "id": "app.user.get_users_batch_for_indexing.get_users.app_error",
"translation": "Unable to get the users batch for indexing." "translation": "Unable to get the users batch for indexing."
}, },
{
"id": "app.user.get_users_for_reporting.bad_date_range",
"translation": "Date range provided is invalid."
},
{
"id": "app.user.get_users_for_reporting.invalid_page_size",
"translation": "Page size is invalid or too large."
},
{ {
"id": "app.user.missing_account.const", "id": "app.user.missing_account.const",
"translation": "Unable to find the user." "translation": "Unable to find the user."

Просмотреть файл

@@ -1918,6 +1918,54 @@ func (c *Client4) EnableUserAccessToken(ctx context.Context, tokenId string) (*R
return BuildResponse(r), nil return BuildResponse(r), nil
} }
func (c *Client4) GetUsersForReporting(ctx context.Context, options *UserReportOptionsAPI) ([]*UserReport, *Response, error) {
values := url.Values{}
if options.SortColumn != "" {
values.Set("sort_column", options.SortColumn)
}
if options.PageSize > 0 {
values.Set("page_size", strconv.Itoa(options.PageSize))
}
if options.Team != "" {
values.Set("team_filter", options.Team)
}
if options.HideActive {
values.Set("hide_active", "true")
}
if options.HideInactive {
values.Set("hide_inactive", "true")
}
if options.SortDesc {
values.Set("sort_direction", "desc")
}
if options.LastSortColumnValue != "" {
values.Set("last_column_value", options.LastSortColumnValue)
}
if options.LastUserId != "" {
values.Set("last_id", options.LastUserId)
}
if options.Role != "" {
values.Set("role_filter", options.Role)
}
if options.HasNoTeam {
values.Set("has_no_team", "true")
}
if options.DateRange != "" {
values.Set("date_range", options.DateRange)
}
r, err := c.DoAPIGet(ctx, c.usersRoute()+"/report?"+values.Encode(), "")
if err != nil {
return nil, BuildResponse(r), err
}
defer closeBody(r)
var list []*UserReport
if err := json.NewDecoder(r.Body).Decode(&list); err != nil {
return nil, nil, NewAppError("GetUsersForReporting", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return list, BuildResponse(r), nil
}
// Bots section // Bots section
// CreateBot creates a bot in the system based on the provided bot struct. // CreateBot creates a bot in the system based on the provided bot struct.

Просмотреть файл

@@ -48,6 +48,10 @@ const (
PushThreadsNotifyProp = "push_threads" PushThreadsNotifyProp = "push_threads"
EmailThreadsNotifyProp = "email_threads" EmailThreadsNotifyProp = "email_threads"
ReportDurationLast30Days = "last_30_days"
ReportDurationPreviousMonth = "previous_month"
ReportDurationLast6Months = "last_6_months"
DefaultLocale = "en" DefaultLocale = "en"
UserAuthServiceEmail = "email" UserAuthServiceEmail = "email"
@@ -67,6 +71,10 @@ const (
DesktopTokenTTL = time.Minute * 3 DesktopTokenTTL = time.Minute * 3
) )
var (
UserReportSortColumns = []string{"CreateAt", "Username", "FirstName", "LastName", "Nickname", "Email", "Roles"}
)
//msgp:tuple User //msgp:tuple User
// User contains the details about the user. // User contains the details about the user.
@@ -1007,3 +1015,82 @@ type UsersWithGroupsAndCount struct {
Users []*UserWithGroups `json:"users"` Users []*UserWithGroups `json:"users"`
Count int64 `json:"total_count"` Count int64 `json:"total_count"`
} }
type UserPostStats struct {
LastLogin int64 `json:"last_login_at,omitempty"`
LastStatusAt *int64 `json:"last_status_at,omitempty"`
LastPostDate *int64 `json:"last_post_date,omitempty"`
DaysActive *int `json:"days_active,omitempty"`
TotalPosts *int `json:"total_posts,omitempty"`
}
type UserReportQuery struct {
User
UserPostStats
}
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"`
UserPostStats
}
type UserReportOptionsWithoutDateRange struct {
SortColumn string
SortDesc bool
PageSize int
LastSortColumnValue string
LastUserId string
Role string
Team string
HasNoTeam bool
HideActive bool
HideInactive bool
}
type UserReportOptions struct {
UserReportOptionsWithoutDateRange
StartAt int64
EndAt int64
}
type UserReportOptionsAPI struct {
UserReportOptionsWithoutDateRange
DateRange string
}
func (u *UserReportOptionsAPI) ToBaseOptions(now time.Time) *UserReportOptions {
startAt := int64(0)
endAt := int64(0)
if u.DateRange == ReportDurationLast30Days {
startAt = now.AddDate(0, 0, -30).UnixMilli()
} else if u.DateRange == ReportDurationPreviousMonth {
startOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.Local)
startAt = startOfMonth.AddDate(0, -1, 0).UnixMilli()
endAt = startOfMonth.UnixMilli()
} else if u.DateRange == ReportDurationLast6Months {
startAt = now.AddDate(0, -6, -0).UnixMilli()
}
return &UserReportOptions{
UserReportOptionsWithoutDateRange: u.UserReportOptionsWithoutDateRange,
StartAt: startAt,
EndAt: endAt,
}
}
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,
UserPostStats: u.UserPostStats,
}
}

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Просмотреть файл

@@ -52,7 +52,7 @@ import {
ChannelSearchOpts, ChannelSearchOpts,
ServerChannel, ServerChannel,
} from '@mattermost/types/channels'; } from '@mattermost/types/channels';
import {Options, StatusOK, ClientResponse, LogLevel, FetchPaginatedThreadOptions} from '@mattermost/types/client4'; import {Options, StatusOK, ClientResponse, LogLevel, FetchPaginatedThreadOptions, UserReportOptions} from '@mattermost/types/client4';
import {Compliance} from '@mattermost/types/compliance'; import {Compliance} from '@mattermost/types/compliance';
import { import {
ClientConfig, ClientConfig,
@@ -133,6 +133,7 @@ import {
UserStatus, UserStatus,
GetFilteredUsersStatsOpts, GetFilteredUsersStatsOpts,
UserCustomStatus, UserCustomStatus,
UserReport,
} from '@mattermost/types/users'; } from '@mattermost/types/users';
import {DeepPartial, RelationOneToOne} from '@mattermost/types/utilities'; import {DeepPartial, RelationOneToOne} from '@mattermost/types/utilities';
import {ProductNotices} from '@mattermost/types/product_notices'; import {ProductNotices} from '@mattermost/types/product_notices';
@@ -982,6 +983,14 @@ export default class Client4 {
); );
}; };
getUsersForReporting = (filter: UserReportOptions) => {
const queryString = buildQueryString(filter);
return this.doFetch<UserReport[]>(
`${this.getUsersRoute()}/report${queryString}`,
{method: 'get'},
);
}
/** /**
* @deprecated * @deprecated
*/ */

Просмотреть файл

@@ -37,3 +37,23 @@ export type FetchPaginatedThreadOptions = {
fromCreateAt?: number; fromCreateAt?: number;
fromPost?: string; fromPost?: string;
} }
export enum ReportDuration {
Last30Days = 'last_30_days',
PreviousMonth = 'previous_month',
Last6Months = 'last_6_months',
}
export type UserReportOptions = {
sort_column: 'CreateAt' | 'Username' | 'FirstName' | 'LastName' | 'Nickname' | 'Email',
page_size: number,
sort_direction?: 'asc' | 'desc',
date_range?: ReportDuration,
last_column_value?: string,
last_id?: string,
role_filter?: string,
has_no_team?: boolean,
team_filter?: string,
hide_active?: boolean,
hide_inactive?: boolean,
}

Просмотреть файл

@@ -143,3 +143,16 @@ export type GetFilteredUsersStatsOpts = {
export type AuthChangeResponse = { export type AuthChangeResponse = {
follow_link: string; follow_link: string;
}; };
export type UserReport = {
id: string;
username: string;
email: string;
create_at: number;
display_name: string;
last_login_at: number;
last_status_at?: number;
last_post_date?: number;
days_active?: number;
total_posts?: number;
}