Skip to content

Commit

Permalink
Merge remote-tracking branch 'upstream/master' into batch_report_work…
Browse files Browse the repository at this point in the history
…er_poc
  • Loading branch information
devinbinnie committed Dec 8, 2023
2 parents 568bd88 + 109f464 commit c37b4a2
Show file tree
Hide file tree
Showing 34 changed files with 2,543 additions and 41 deletions.
34 changes: 34 additions & 0 deletions api/v4/source/definitions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3524,6 +3524,40 @@ components:
Description:
description: A description for the CIDRBlock
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:
type: object
properties:
Expand Down
94 changes: 94 additions & 0 deletions api/v4/source/users.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3251,3 +3251,97 @@
$ref: "#/components/responses/Unauthorized"
"403":
$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"
2 changes: 2 additions & 0 deletions e2e-tests/playwright/support/server/default_config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,8 @@ const defaultServerConfig: AdminConfig = {
SMTPPassword: '',
EmailAddress: '',
SMTPServerTimeout: 1800,
CustomSMTPServerName: '',
CustomSMTPPort: '25',
},
},
JobSettings: {
Expand Down
58 changes: 58 additions & 0 deletions server/channels/api4/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ func (api *API) InitUser() {

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("/report", api.APISessionRequired(getUsersForReporting)).Methods("GET")
}

func createUser(c *Context, w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -3443,3 +3445,59 @@ func getUsersWithInvalidEmails(c *Context, w http.ResponseWriter, r *http.Reques
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))
}
}
1 change: 1 addition & 0 deletions server/channels/app/app_iface.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 22 additions & 0 deletions server/channels/app/opentracing/opentracing_layer.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

34 changes: 34 additions & 0 deletions server/channels/app/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"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/imaging"
"github.com/mattermost/mattermost/server/v8/channels/app/users"
Expand Down Expand Up @@ -2817,3 +2818,36 @@ func (a *App) UserIsFirstAdmin(user *model.User) bool {

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
}
93 changes: 93 additions & 0 deletions server/channels/app/user_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1932,3 +1932,96 @@ func TestSendSubscriptionHistoryEvent(t *testing.T) {
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)
})
}
Loading

0 comments on commit c37b4a2

Please sign in to comment.