mirror of
https://github.com/mattermost/mattermost.git
synced 2026-04-15 22:12:19 -04:00
Some checks are pending
API / build (push) Waiting to run
Server CI / Compute Go Version (push) Waiting to run
Server CI / Check mocks (push) Blocked by required conditions
Server CI / Check go mod tidy (push) Blocked by required conditions
Server CI / check-style (push) Blocked by required conditions
Server CI / Check serialization methods for hot structs (push) Blocked by required conditions
Server CI / Vet API (push) Blocked by required conditions
Server CI / Check migration files (push) Blocked by required conditions
Server CI / Generate email templates (push) Blocked by required conditions
Server CI / Check store layers (push) Blocked by required conditions
Server CI / Check mmctl docs (push) Blocked by required conditions
Server CI / Postgres with binary parameters (push) Blocked by required conditions
Server CI / Postgres (push) Blocked by required conditions
Server CI / Postgres (FIPS) (push) Blocked by required conditions
Server CI / Generate Test Coverage (push) Blocked by required conditions
Server CI / Run mmctl tests (push) Blocked by required conditions
Server CI / Run mmctl tests (FIPS) (push) Blocked by required conditions
Server CI / Build mattermost server app (push) Blocked by required conditions
Web App CI / check-lint (push) Waiting to run
Web App CI / check-i18n (push) Blocked by required conditions
Web App CI / check-external-links (push) Blocked by required conditions
Web App CI / check-types (push) Blocked by required conditions
Web App CI / test (platform) (push) Blocked by required conditions
Web App CI / test (mattermost-redux) (push) Blocked by required conditions
Web App CI / test (channels shard 1/4) (push) Blocked by required conditions
Web App CI / test (channels shard 2/4) (push) Blocked by required conditions
Web App CI / test (channels shard 3/4) (push) Blocked by required conditions
Web App CI / test (channels shard 4/4) (push) Blocked by required conditions
Web App CI / upload-coverage (push) Blocked by required conditions
Web App CI / build (push) Blocked by required conditions
* Add single-channel guests filter and channel count column to System Console Users - Add guest_filter query parameter to Reports API with store-level filtering by guest channel membership count (all, single_channel, multi_channel) - Add channel_count field to user report responses and CSV exports - Add grouped guest role filter options in the filter popover - Add toggleable Channel count column to the users table - Add GuestFilter and SearchTerm to Go client GetUsersForReporting - Add tests: API parsing, API integration, app job dedup, webapp utils, E2E column data rendering Made-with: Cursor * Fix gofmt alignment and isolate guest store tests - Align GuestFilter constants to satisfy gofmt - Move guest user/channel setup into a nested sub-test to avoid breaking existing ordering and role filter assertions Made-with: Cursor * Exclude archived channels from guest filter queries and ChannelCount The ChannelMembers subqueries for guest_filter (single/multi channel) and the ChannelCount column did not join with Channels to check DeleteAt = 0. Since channel archival soft-deletes (sets DeleteAt) but leaves ChannelMembers rows intact, archived channel memberships were incorrectly counted, potentially misclassifying guests between single-channel and multi-channel filters and inflating ChannelCount. - Join ChannelMembers with Channels (DeleteAt = 0) in all three subqueries in applyUserReportFilter and GetUserReport - Add store test covering archived channel exclusion - Tighten existing guest filter test assertions with found-flags and exact count checks Made-with: Cursor * Exclude DM/GM from guest channel counts, validate GuestFilter, fix dropdown divider - Scope ChannelCount and guest filter subqueries to Open/Private channel types only (exclude DM and GM), so a guest with one team channel plus a DM is correctly classified as single-channel - Add GuestFilter validation in UserReportOptions.IsValid with AllowedGuestFilters whitelist - Add API test for invalid guest_filter rejection (400) - Add store regression test for DM/GM exclusion - Fix role filter dropdown: hide the divider above the first group heading via CSS rule on DropDown__group:first-child - Update E2E test label to match "Guests in a single channel" wording Made-with: Cursor * Add store test coverage for private and GM channel types Private channels (type P) should be counted in ChannelCount and guest filters, while GM channels (type G) should not. Add a test that creates a guest with memberships in an open channel, a private channel, and a GM, then asserts ChannelCount = 2, multi-channel filter includes the guest, and single-channel filter excludes them. Made-with: Cursor * Add server i18n translation for invalid_guest_filter error The new error ID model.user_report_options.is_valid.invalid_guest_filter was missing from server/i18n/en.json, causing CI to fail. Made-with: Cursor * Make filter dropdown dividers full width Remove the horizontal inset from grouped dropdown separators so the system user role filter dividers span edge to edge across the menu. Leave the unrelated webapp/package-lock.json change uncommitted. Made-with: Cursor * Optimize guest channel report filters. Use per-user channel count subqueries for the single- and multi-channel guest filters so the report avoids aggregating all channel memberships before filtering guests.
342 lines
11 KiB
Go
342 lines
11 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/csv"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/mattermost/mattermost/server/public/model"
|
|
"github.com/mattermost/mattermost/server/public/shared/i18n"
|
|
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
|
"github.com/mattermost/mattermost/server/public/shared/request"
|
|
"github.com/mattermost/mattermost/server/v8/channels/store"
|
|
)
|
|
|
|
func (a *App) SaveReportChunk(format string, prefix string, count int, reportData []model.ReportableObject) *model.AppError {
|
|
switch format {
|
|
case "csv":
|
|
return a.saveCSVChunk(prefix, count, reportData)
|
|
}
|
|
return model.NewAppError("SaveReportChunk", "app.save_report_chunk.unsupported_format", nil, "unsupported report format", http.StatusBadRequest)
|
|
}
|
|
|
|
func (a *App) saveCSVChunk(prefix string, count int, reportData []model.ReportableObject) *model.AppError {
|
|
var buf bytes.Buffer
|
|
w := csv.NewWriter(&buf)
|
|
|
|
for _, report := range reportData {
|
|
err := w.Write(report.ToReport())
|
|
if err != nil {
|
|
return model.NewAppError("saveCSVChunk", "app.save_csv_chunk.write_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
}
|
|
|
|
w.Flush()
|
|
if err := w.Error(); err != nil {
|
|
return model.NewAppError("saveCSVChunk", "app.save_csv_chunk.write_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
_, appErr := a.WriteFile(&buf, makeFilePath(prefix, count, "csv"))
|
|
return appErr
|
|
}
|
|
|
|
func (a *App) CompileReportChunks(format string, prefix string, numberOfChunks int, headers []string) *model.AppError {
|
|
switch format {
|
|
case "csv":
|
|
return a.compileCSVChunks(prefix, numberOfChunks, headers)
|
|
}
|
|
return model.NewAppError("CompileReportChunks", "app.compile_report_chunks.unsupported_format", nil, "", http.StatusBadRequest)
|
|
}
|
|
|
|
func (a *App) compileCSVChunks(prefix string, numberOfChunks int, headers []string) *model.AppError {
|
|
filePath := makeCompiledFilePath(prefix, "csv")
|
|
|
|
var compiledBuf bytes.Buffer
|
|
w := csv.NewWriter(&compiledBuf)
|
|
err := w.Write(headers)
|
|
if err != nil {
|
|
return model.NewAppError("compileCSVChunks", "app.compile_csv_chunks.header_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
w.Flush()
|
|
if err = w.Error(); err != nil {
|
|
return model.NewAppError("saveCSVChunk", "app.save_csv_chunk.write_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
for i := range numberOfChunks {
|
|
chunkFilePath := makeFilePath(prefix, i, "csv")
|
|
chunk, err := a.ReadFile(chunkFilePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, writeErr := compiledBuf.Write(chunk)
|
|
if writeErr != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
_, appErr := a.WriteFile(&compiledBuf, filePath)
|
|
if appErr != nil {
|
|
return appErr
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (a *App) SendReportToUser(rctx request.CTX, job *model.Job, format string) *model.AppError {
|
|
requestingUserId := job.Data["requesting_user_id"]
|
|
if requestingUserId == "" {
|
|
return model.NewAppError("SendReportToUser", "app.report.send_report_to_user.missing_user_id", nil, "", http.StatusInternalServerError)
|
|
}
|
|
dateRange := job.Data["date_range"]
|
|
if dateRange == "" {
|
|
return model.NewAppError("SendReportToUser", "app.report.send_report_to_user.missing_date_range", nil, "", http.StatusInternalServerError)
|
|
}
|
|
|
|
systemBot, err := a.GetSystemBot(rctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
path := makeCompiledFilePath(job.Id, format)
|
|
size, err := a.FileSize(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fileInfo, fileErr := a.Srv().Store().FileInfo().Save(rctx, &model.FileInfo{
|
|
Name: makeCompiledFilename(job.Id, format),
|
|
Extension: format,
|
|
Size: size,
|
|
Path: path,
|
|
CreatorId: systemBot.UserId,
|
|
})
|
|
if fileErr != nil {
|
|
return model.NewAppError("SendReportToUser", "app.report.send_report_to_user.failed_to_save", nil, "", http.StatusInternalServerError).Wrap(fileErr)
|
|
}
|
|
|
|
channel, err := a.GetOrCreateDirectChannel(rctx, requestingUserId, systemBot.UserId)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
user, err := a.GetUser(requestingUserId)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
T := i18n.GetUserTranslations(user.Locale)
|
|
post := &model.Post{
|
|
ChannelId: channel.Id,
|
|
Message: T("app.report.send_report_to_user.export_finished", map[string]string{
|
|
"DateRange": getTranslatedDateRange(dateRange),
|
|
}),
|
|
Type: model.PostTypeDefault,
|
|
UserId: systemBot.UserId,
|
|
FileIds: []string{fileInfo.Id},
|
|
}
|
|
|
|
_, _, err = a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true})
|
|
return err
|
|
}
|
|
|
|
func (a *App) CleanupReportChunks(format string, prefix string, numberOfChunks int) *model.AppError {
|
|
switch format {
|
|
case "csv":
|
|
return a.cleanupCSVChunks(prefix, numberOfChunks)
|
|
}
|
|
return model.NewAppError("CompileReportChunks", "app.compile_report_chunks.unsupported_format", nil, "", http.StatusBadRequest)
|
|
}
|
|
|
|
func (a *App) cleanupCSVChunks(prefix string, numberOfChunks int) *model.AppError {
|
|
for i := range numberOfChunks {
|
|
chunkFilePath := makeFilePath(prefix, i, "csv")
|
|
if err := a.RemoveFile(chunkFilePath); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func makeFilePath(prefix string, count int, extension string) string {
|
|
return fmt.Sprintf("admin_reports/batch_report_%s__%d.%s", prefix, count, extension)
|
|
}
|
|
|
|
func makeCompiledFilePath(prefix string, extension string) string {
|
|
return fmt.Sprintf("admin_reports/%s", makeCompiledFilename(prefix, extension))
|
|
}
|
|
|
|
func makeCompiledFilename(prefix string, extension string) string {
|
|
return fmt.Sprintf("batch_report_%s.%s", prefix, extension)
|
|
}
|
|
|
|
func (a *App) GetUsersForReporting(filter *model.UserReportOptions) ([]*model.UserReport, *model.AppError) {
|
|
if appErr := filter.IsValid(); appErr != nil {
|
|
return nil, appErr
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
userReports := make([]*model.UserReport, len(userReportQuery))
|
|
for i, user := range userReportQuery {
|
|
userReports[i] = user.ToReport()
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func (a *App) StartUsersBatchExport(rctx request.CTX, ro *model.UserReportOptions, startAt int64, endAt int64) *model.AppError {
|
|
if !model.MinimumProfessionalLicense(a.Srv().License()) {
|
|
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": 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),
|
|
"guest_filter": ro.GuestFilter,
|
|
}
|
|
|
|
// Check for existing jobs
|
|
if err := a.checkForExistingJobs(rctx, options, model.JobTypeExportUsersToCSV); err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err := a.Srv().Jobs.CreateJob(rctx, model.JobTypeExportUsersToCSV, options)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
a.Srv().Go(func() {
|
|
systemBot, err := a.GetSystemBot(rctx)
|
|
if err != nil {
|
|
rctx.Logger().Error("Failed to get the system bot", mlog.Err(err))
|
|
return
|
|
}
|
|
|
|
channel, err := a.GetOrCreateDirectChannel(rctx, rctx.Session().UserId, systemBot.UserId)
|
|
if err != nil {
|
|
rctx.Logger().Error("Failed to get or create the DM", mlog.Err(err))
|
|
return
|
|
}
|
|
|
|
user, err := a.GetUser(rctx.Session().UserId)
|
|
if err != nil {
|
|
rctx.Logger().Error("Failed to get the user", mlog.Err(err))
|
|
return
|
|
}
|
|
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(ro.DateRange)}),
|
|
Type: model.PostTypeDefault,
|
|
UserId: systemBot.UserId,
|
|
}
|
|
|
|
if _, _, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); err != nil {
|
|
rctx.Logger().Error("Failed to post batch export message", mlog.Err(err))
|
|
}
|
|
})
|
|
|
|
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"] &&
|
|
job.Data["guest_filter"] == options["guest_filter"] {
|
|
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:
|
|
return i18n.T("app.report.date_range.last_30_days")
|
|
case model.ReportDurationPreviousMonth:
|
|
return i18n.T("app.report.date_range.previous_month")
|
|
case model.ReportDurationLast6Months:
|
|
return i18n.T("app.report.date_range.last_6_months")
|
|
default:
|
|
return i18n.T("app.report.date_range.all_time")
|
|
}
|
|
}
|
|
|
|
func (a *App) GetPostsForReporting(rctx request.CTX, queryParams model.ReportPostQueryParams, includeMetadata bool) (*model.ReportPostListResponse, *model.AppError) {
|
|
if !model.MinimumEnterpriseLicense(a.Srv().License()) {
|
|
return nil, model.NewAppError("GetPostsForReporting", "app.post.get_posts_for_reporting.license_error", nil, "", http.StatusBadRequest)
|
|
}
|
|
|
|
response, err := a.Srv().Store().Post().GetPostsForReporting(rctx, queryParams)
|
|
if err != nil {
|
|
var invErr *store.ErrInvalidInput
|
|
switch {
|
|
case errors.As(err, &invErr):
|
|
return nil, model.NewAppError("GetPostsForReporting", "app.post.get_posts_for_reporting.invalid_input_error", nil, "", http.StatusBadRequest).Wrap(err)
|
|
default:
|
|
return nil, model.NewAppError("GetPostsForReporting", "app.post.get_posts_for_reporting.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
}
|
|
|
|
// Optionally enrich posts with metadata if requested
|
|
if includeMetadata {
|
|
for i, post := range response.Posts {
|
|
// Use PreparePostForClient to add metadata (files, reactions counts, emojis, priority, acknowledgements)
|
|
// This does NOT include embeds or images - those are only for UI presentation
|
|
enrichedPost := a.PreparePostForClient(rctx, post, &model.PreparePostForClientOpts{
|
|
IncludePriority: true,
|
|
})
|
|
response.Posts[i] = enrichedPost
|
|
}
|
|
}
|
|
|
|
return response, nil
|
|
}
|