diff --git a/server/channels/api4/report.go b/server/channels/api4/report.go index a2e4a90efa..e4642c7eeb 100644 --- a/server/channels/api4/report.go +++ b/server/channels/api4/report.go @@ -28,6 +28,7 @@ func getUsersForReporting(c *Context, w http.ResponseWriter, r *http.Request) { baseOptions := fillReportingBaseOptions(r.URL.Query()) options, err := fillUserReportOptions(r.URL.Query()) + if err != nil { c.Err = err return @@ -80,13 +81,21 @@ func startUsersBatchExport(c *Context, w http.ResponseWriter, r *http.Request) { return } - dateRange := r.URL.Query().Get("date_range") + baseOptions := fillReportingBaseOptions(r.URL.Query()) + options, err := fillUserReportOptions(r.URL.Query()) + + if err != nil { + c.Err = err + return + } + options.ReportingBaseOptions = baseOptions + dateRange := options.ReportingBaseOptions.DateRange if dateRange == "" { dateRange = "all_time" } startAt, endAt := model.GetReportDateRange(dateRange, time.Now()) - if err := c.App.StartUsersBatchExport(c.AppContext, dateRange, startAt, endAt); err != nil { + if err := c.App.StartUsersBatchExport(c.AppContext, options, startAt, endAt); err != nil { c.Err = err return } @@ -136,7 +145,6 @@ func fillUserReportOptions(values url.Values) (*model.UserReportOptions, *model. } return &model.UserReportOptions{ - Team: teamFilter, Role: values.Get("role_filter"), HasNoTeam: values.Get("has_no_team") == "true", diff --git a/server/channels/api4/report_test.go b/server/channels/api4/report_test.go new file mode 100644 index 0000000000..88e0a22d7a --- /dev/null +++ b/server/channels/api4/report_test.go @@ -0,0 +1,154 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "context" + "net/url" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost/server/public/model" +) + +func TestGetUsersForReporting(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + client := th.Client + + defaultRolePermissions := th.SaveDefaultRolePermissions() + defer th.RestoreDefaultRolePermissions(defaultRolePermissions) + + t.Run("should return forbidden error when user lacks permission", func(t *testing.T) { + th.RemovePermissionFromRole(model.PermissionSysconsoleReadUserManagementUsers.Id, model.SystemUserRoleId) + + _, resp, err := client.GetUsersForReporting(context.Background(), &model.UserReportOptions{}) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + }) + + t.Run("should return user reports when user has permission", func(t *testing.T) { + th.AddPermissionToRole(model.PermissionSysconsoleReadUserManagementUsers.Id, model.SystemUserRoleId) + + options := &model.UserReportOptions{ + ReportingBaseOptions: model.ReportingBaseOptions{ + PageSize: 10, + }, + } + + userReports, resp, err := client.GetUsersForReporting(context.Background(), options) + require.NoError(t, err) + require.NotNil(t, userReports) + require.GreaterOrEqual(t, len(userReports), 1) + CheckOKStatus(t, resp) + }) + + t.Run("should return bad request on invalid parameters", func(t *testing.T) { + th.AddPermissionToRole(model.PermissionSysconsoleReadUserManagementUsers.Id, model.SystemUserRoleId) + + options := &model.UserReportOptions{ + Team: "invalid_team_id", + } + + _, resp, err := client.GetUsersForReporting(context.Background(), options) + require.Error(t, err) + CheckBadRequestStatus(t, resp) + }) +} + +func TestFillReportingBaseOptions(t *testing.T) { + t.Run("default values", func(t *testing.T) { + values := url.Values{} + + options := fillReportingBaseOptions(values) + + require.Equal(t, "Username", options.SortColumn) + require.Equal(t, "next", options.Direction) + require.Equal(t, false, options.SortDesc) + require.Equal(t, 50, options.PageSize) + require.Equal(t, "", options.FromColumnValue) + require.Equal(t, "", options.FromId) + require.Equal(t, "", options.DateRange) + }) + + t.Run("custom values", func(t *testing.T) { + values := url.Values{} + values.Set("sort_column", "Email") + values.Set("direction", "prev") + values.Set("sort_direction", "desc") + values.Set("page_size", "25") + values.Set("from_column_value", "some_value") + values.Set("from_id", "some_id") + values.Set("date_range", "last_seven") + + options := fillReportingBaseOptions(values) + + require.Equal(t, "Email", options.SortColumn) + require.Equal(t, "prev", options.Direction) + require.Equal(t, true, options.SortDesc) + require.Equal(t, 25, options.PageSize) + require.Equal(t, "some_value", options.FromColumnValue) + require.Equal(t, "some_id", options.FromId) + require.Equal(t, "last_seven", options.DateRange) + }) + + t.Run("invalid page_size", func(t *testing.T) { + values := url.Values{} + values.Set("page_size", "an_very_invalid_number") + + options := fillReportingBaseOptions(values) + + require.Equal(t, 50, options.PageSize) + }) + + t.Run("invalid direction", func(t *testing.T) { + values := url.Values{} + values.Set("direction", "a_crazy_direction") + + options := fillReportingBaseOptions(values) + + require.Equal(t, "next", options.Direction) + }) +} + +func TestFillUserReportOptions(t *testing.T) { + validTeamID := model.NewId() + + t.Run("default values", func(t *testing.T) { + values := url.Values{} + values.Set("team_filter", validTeamID) + + options, _ := fillUserReportOptions(values) + + expected := &model.UserReportOptions{ + Team: validTeamID, + Role: "", + HasNoTeam: false, + HideActive: false, + HideInactive: false, + SearchTerm: "", + } + + require.Equal(t, expected, options) + }) + + t.Run("empty team_filter", func(t *testing.T) { + values := url.Values{} + values.Set("team_filter", "") + + options, _ := fillUserReportOptions(values) + + require.Equal(t, "", options.Team) + }) + + t.Run("valid team_filter", func(t *testing.T) { + values := url.Values{} + values.Set("team_filter", validTeamID) + + options, _ := fillUserReportOptions(values) + + require.Equal(t, validTeamID, options.Team) + }) +} diff --git a/server/channels/app/app_iface.go b/server/channels/app/app_iface.go index 4e0083f609..4dce7026d0 100644 --- a/server/channels/app/app_iface.go +++ b/server/channels/app/app_iface.go @@ -1146,7 +1146,7 @@ type AppIface interface { SlackImport(c request.CTX, fileData multipart.File, fileSize int64, teamID string) (*model.AppError, *bytes.Buffer) SoftDeleteTeam(teamID string) *model.AppError Srv() *Server - StartUsersBatchExport(rctx request.CTX, dateRange string, startAt int64, endAt int64) *model.AppError + StartUsersBatchExport(rctx request.CTX, ro *model.UserReportOptions, startAt int64, endAt int64) *model.AppError SubmitInteractiveDialog(c request.CTX, request model.SubmitDialogRequest) (*model.SubmitDialogResponse, *model.AppError) SwitchEmailToLdap(c request.CTX, email, password, code, ldapLoginId, ldapPassword string) (string, *model.AppError) SwitchEmailToOAuth(c request.CTX, w http.ResponseWriter, r *http.Request, email, password, code, service string) (string, *model.AppError) diff --git a/server/channels/app/opentracing/opentracing_layer.go b/server/channels/app/opentracing/opentracing_layer.go index 89b16a619c..8655c1bc4b 100644 --- a/server/channels/app/opentracing/opentracing_layer.go +++ b/server/channels/app/opentracing/opentracing_layer.go @@ -17236,7 +17236,7 @@ func (a *OpenTracingAppLayer) SoftDeleteTeam(teamID string) *model.AppError { return resultVar0 } -func (a *OpenTracingAppLayer) StartUsersBatchExport(rctx request.CTX, dateRange string, startAt int64, endAt int64) *model.AppError { +func (a *OpenTracingAppLayer) StartUsersBatchExport(rctx request.CTX, ro *model.UserReportOptions, startAt int64, endAt int64) *model.AppError { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.StartUsersBatchExport") @@ -17248,7 +17248,7 @@ func (a *OpenTracingAppLayer) StartUsersBatchExport(rctx request.CTX, dateRange }() defer span.Finish() - resultVar0 := a.app.StartUsersBatchExport(rctx, dateRange, startAt, endAt) + resultVar0 := a.app.StartUsersBatchExport(rctx, ro, startAt, endAt) if resultVar0 != nil { span.LogFields(spanlog.Error(resultVar0)) diff --git a/server/channels/app/report.go b/server/channels/app/report.go index ff838d8800..c0e8064600 100644 --- a/server/channels/app/report.go +++ b/server/channels/app/report.go @@ -198,41 +198,28 @@ func (a *App) GetUserCountForReport(filter *model.UserReportOptions) (*int64, *m return &count, nil } -func (a *App) StartUsersBatchExport(rctx request.CTX, dateRange string, startAt int64, endAt int64) *model.AppError { +func (a *App) StartUsersBatchExport(rctx request.CTX, ro *model.UserReportOptions, startAt int64, endAt int64) *model.AppError { if license := a.Srv().License(); license == nil || (license.SkuShortName != model.LicenseShortSkuProfessional && license.SkuShortName != model.LicenseShortSkuEnterprise) { return model.NewAppError("StartUsersBatchExport", "app.report.start_users_batch_export.license_error", nil, "", http.StatusBadRequest) } options := map[string]string{ "requesting_user_id": rctx.Session().UserId, - "date_range": dateRange, + "date_range": ro.DateRange, + "role": ro.Role, + "team": ro.Team, + "hide_active": strconv.FormatBool(ro.HideActive), + "hide_inactive": strconv.FormatBool(ro.HideInactive), "start_at": strconv.FormatInt(startAt, 10), "end_at": strconv.FormatInt(endAt, 10), } - // Check for existing job - // TODO: Maybe make this a reusable function? - pendingJobs, err := a.Srv().Jobs.GetJobsByTypeAndStatus(rctx, model.JobTypeExportUsersToCSV, model.JobStatusPending) - if err != nil { + // Check for existing jobs + if err := a.checkForExistingJobs(rctx, options, model.JobTypeExportUsersToCSV); err != nil { return err } - for _, job := range pendingJobs { - if job.Data["date_range"] == options["date_range"] && job.Data["requesting_user_id"] == rctx.Session().UserId { - return model.NewAppError("StartUsersBatchExport", "app.report.start_users_batch_export.job_exists", nil, "", http.StatusBadRequest) - } - } - inProgressJobs, err := a.Srv().Jobs.GetJobsByTypeAndStatus(rctx, model.JobTypeExportUsersToCSV, model.JobStatusInProgress) - if err != nil { - return err - } - for _, job := range inProgressJobs { - if job.Data["date_range"] == options["date_range"] && job.Data["requesting_user_id"] == rctx.Session().UserId { - return model.NewAppError("StartUsersBatchExport", "app.report.start_users_batch_export.job_exists", nil, "", http.StatusBadRequest) - } - } - - _, err = a.Srv().Jobs.CreateJob(rctx, model.JobTypeExportUsersToCSV, options) + _, err := a.Srv().Jobs.CreateJob(rctx, model.JobTypeExportUsersToCSV, options) if err != nil { return err } @@ -258,7 +245,7 @@ func (a *App) StartUsersBatchExport(rctx request.CTX, dateRange string, startAt T := i18n.GetUserTranslations(user.Locale) post := &model.Post{ ChannelId: channel.Id, - Message: T("app.report.start_users_batch_export.started_export", map[string]string{"DateRange": getTranslatedDateRange(dateRange)}), + Message: T("app.report.start_users_batch_export.started_export", map[string]string{"DateRange": getTranslatedDateRange(ro.DateRange)}), Type: model.PostTypeDefault, UserId: systemBot.UserId, } @@ -271,6 +258,41 @@ func (a *App) StartUsersBatchExport(rctx request.CTX, dateRange string, startAt return nil } +// Helper function to check for existing or pending jobs +func (a *App) checkForExistingJobs(rctx request.CTX, options map[string]string, jobType string) *model.AppError { + checkJobExists := func(jobs []*model.Job, options map[string]string) bool { + for _, job := range jobs { + if job.Data["date_range"] == options["date_range"] && + job.Data["requesting_user_id"] == options["requesting_user_id"] && + job.Data["role"] == options["role"] && + job.Data["team"] == options["team"] && + job.Data["hide_active"] == options["hide_active"] && + job.Data["hide_inactive"] == options["hide_inactive"] { + return true + } + } + return false + } + + pendingJobs, err := a.Srv().Jobs.GetJobsByTypeAndStatus(rctx, jobType, model.JobStatusPending) + if err != nil { + return err + } + if checkJobExists(pendingJobs, options) { + return model.NewAppError("StartUsersBatchExport", "app.report.start_users_batch_export.job_exists", nil, "", http.StatusBadRequest) + } + + inProgressJobs, err := a.Srv().Jobs.GetJobsByTypeAndStatus(rctx, jobType, model.JobStatusInProgress) + if err != nil { + return err + } + if checkJobExists(inProgressJobs, options) { + return model.NewAppError("StartUsersBatchExport", "app.report.start_users_batch_export.job_exists", nil, "", http.StatusBadRequest) + } + + return nil +} + func getTranslatedDateRange(dateRange string) string { switch dateRange { case model.ReportDurationLast30Days: diff --git a/server/channels/app/report_test.go b/server/channels/app/report_test.go index b966a4d86c..0758b17842 100644 --- a/server/channels/app/report_test.go +++ b/server/channels/app/report_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/request" "github.com/stretchr/testify/require" ) @@ -107,3 +108,100 @@ some-other-other-name,600,2022-01-01 require.NotNil(t, err) }) } + +func TestCheckForExistingJobs(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + t.Run("should return error if job with same options exists in pending jobs", func(t *testing.T) { + app := th.App + rctx := request.TestContext(t) + options := map[string]string{ + "date_range": "last_30_days", + "requesting_user_id": th.BasicUser.Id, + "role": "user", + "team": "", + "hide_active": "false", + "hide_inactive": "false", + } + + jobType := model.JobTypeExportUsersToCSV + + // Create a pending job with same options + job, err := app.Srv().Jobs.CreateJob(rctx, jobType, options) + defer func() { + _ = app.Srv().Jobs.RequestCancellation(rctx, job.Id) + }() + require.Nil(t, err) + require.NotNil(t, job) + + // checkForExistingJobs + appErr := app.checkForExistingJobs(rctx, options, jobType) + require.NotNil(t, appErr) + require.Equal(t, "app.report.start_users_batch_export.job_exists", appErr.Id) + }) + + t.Run("should return error if job with same options exists in in-progress jobs", func(t *testing.T) { + app := th.App + rctx := request.TestContext(t) + options := map[string]string{ + "date_range": "last_30_days", + "requesting_user_id": th.BasicUser.Id, + "role": "user", + "team": "", + "hide_active": "false", + "hide_inactive": "false", + } + + jobType := model.JobTypeExportUsersToCSV + + // Create an in-progress job with same options + job, err := app.Srv().Jobs.CreateJob(rctx, jobType, options) + defer func() { + _ = app.Srv().Jobs.RequestCancellation(rctx, job.Id) + }() + require.Nil(t, err) + require.NotNil(t, job) + + // Manually set job status to in-progress + err = app.Srv().Jobs.SetJobProgress(job, 60) + require.Nil(t, err) + + // Call checkForExistingJobs + appErr := app.checkForExistingJobs(rctx, options, jobType) + require.NotNil(t, appErr) + require.Equal(t, "app.report.start_users_batch_export.job_exists", appErr.Id) + }) + + t.Run("should not return error if existing jobs have different options", func(t *testing.T) { + app := th.App + rctx := request.TestContext(t) + options := map[string]string{ + "date_range": "last_30_days", + "requesting_user_id": th.BasicUser.Id, + "role": "user", + "team": "", + "hide_active": "false", + "hide_inactive": "false", + } + + jobType := model.JobTypeExportUsersToCSV + + differentOptions := map[string]string{ + "date_range": "all_time", + "requesting_user_id": th.BasicUser2.Id, + "role": "admin", + "team": "", + "hide_active": "false", + "hide_inactive": "false", + } + + job, err := app.Srv().Jobs.CreateJob(rctx, jobType, differentOptions) + require.Nil(t, err) + require.NotNil(t, job) + + // Call checkForExistingJobs + appErr := app.checkForExistingJobs(rctx, options, jobType) + require.Nil(t, appErr) + }) +} diff --git a/server/channels/jobs/export_users_to_csv/export_users_to_csv.go b/server/channels/jobs/export_users_to_csv/export_users_to_csv.go index 90e1ff9704..f76320181f 100644 --- a/server/channels/jobs/export_users_to_csv/export_users_to_csv.go +++ b/server/channels/jobs/export_users_to_csv/export_users_to_csv.go @@ -4,6 +4,7 @@ package export_users_to_csv import ( + "fmt" "strconv" "time" @@ -60,6 +61,22 @@ func parseJobMetadata(data model.StringMap) (*model.UserReportOptions, error) { return nil, err } + hideInactive := false + if val, ok := data["hide_inactive"]; ok && val != "" { + hideInactive, err = strconv.ParseBool(val) + if err != nil { + return nil, fmt.Errorf("failed to parse hide_inactive: %w", err) + } + } + + hideActive := false + if val, ok := data["hide_active"]; ok && val != "" { + hideActive, err = strconv.ParseBool(val) + if err != nil { + return nil, fmt.Errorf("failed to parse hide_active: %w", err) + } + } + options := model.UserReportOptions{ ReportingBaseOptions: model.ReportingBaseOptions{ SortColumn: "Username", @@ -69,6 +86,10 @@ func parseJobMetadata(data model.StringMap) (*model.UserReportOptions, error) { StartAt: startAt, EndAt: endAt, }, + HideInactive: hideInactive, + HideActive: hideActive, + Role: data["role"], + Team: data["team"], } return &options, nil diff --git a/webapp/channels/src/actions/views/admin.ts b/webapp/channels/src/actions/views/admin.ts index 0fddb9f956..71829f5b88 100644 --- a/webapp/channels/src/actions/views/admin.ts +++ b/webapp/channels/src/actions/views/admin.ts @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import type {ServerError} from '@mattermost/types/errors'; -import type {UserReportOptions, UserReport, UserReportFilter, ReportDuration} from '@mattermost/types/reports'; +import type {UserReportOptions, UserReport, UserReportFilter} from '@mattermost/types/reports'; import {logError} from 'mattermost-redux/actions/errors'; import {forceLogoutIfNecessary} from 'mattermost-redux/actions/helpers'; @@ -67,10 +67,10 @@ export function getUserCountForReporting(filter = {} as UserReportFilter): Actio }; } -export function startUsersBatchExport(dateRange: ReportDuration): ActionFuncAsync { +export function startUsersBatchExport(tableFilters = {} as UserReportOptions): ActionFuncAsync { return async (dispatch, getState) => { try { - await Client4.startUsersBatchExport(dateRange); + await Client4.startUsersBatchExport(tableFilters); } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); dispatch(logError(error)); diff --git a/webapp/channels/src/components/admin_console/system_users/system_users.tsx b/webapp/channels/src/components/admin_console/system_users/system_users.tsx index 13b1999ced..d8c9cc54de 100644 --- a/webapp/channels/src/components/admin_console/system_users/system_users.tsx +++ b/webapp/channels/src/components/admin_console/system_users/system_users.tsx @@ -564,7 +564,7 @@ function SystemUsers(props: Props) { /> diff --git a/webapp/channels/src/components/admin_console/system_users/system_users_export/export_user_data_modal.tsx b/webapp/channels/src/components/admin_console/system_users/system_users_export/export_user_data_modal.tsx index 191e6445e1..78cd06ce93 100644 --- a/webapp/channels/src/components/admin_console/system_users/system_users_export/export_user_data_modal.tsx +++ b/webapp/channels/src/components/admin_console/system_users/system_users_export/export_user_data_modal.tsx @@ -17,7 +17,8 @@ type Props = { } export function ExportUserDataModal({onConfirm, onExited}: Props) { - const dateRange = useSelector(getAdminConsoleUserManagementTableProperties).dateRange ?? ReportDuration.AllTime; + const tableFilterProps = useSelector(getAdminConsoleUserManagementTableProperties); + const dateRange = tableFilterProps.dateRange ?? ReportDuration.AllTime; const title = ( + {message} +

+ +

+ + ); + } + const exportDataButton = ( get(state, Preferences.CATEGORY_REPORTING, Preferences.HIDE_BATCH_EXPORT_CONFIRM_MODAL, '')) === 'true'; + const tableFilterProps = useSelector(getAdminConsoleUserManagementTableProperties); + const tableOptionsToUserReport = convertTableOptionsToUserReportOptions(tableFilterProps); + if (tableOptionsToUserReport.date_range === undefined) { + tableOptionsToUserReport.date_range = ReportDuration.AllTime; + } const license = useSelector(getLicense); const isLicensed = license.IsLicensed === 'true' && (license.SkuShortName === LicenseSkus.Professional || license.SkuShortName === LicenseSkus.Enterprise); async function doExport(checked?: boolean) { - const {error} = await dispatch(startUsersBatchExport(props.dateRange)); + const {error} = await dispatch(startUsersBatchExport(tableOptionsToUserReport)); if (error) { dispatch(openModal({ modalId: ModalIdentifiers.EXPORT_ERROR_MODAL, @@ -64,6 +71,9 @@ export function SystemUsersExport(props: Props) { } function handleExport() { + if (!props.usersLenght) { + return; + } if (!isLicensed) { dispatch(openModal({ modalId: ModalIdentifiers.UPGRADE_EXPORT_DATA_MODAL, @@ -89,6 +99,7 @@ export function SystemUsersExport(props: Props) {