[MM-55726] Create batch report worker, add batch report job for exporting users to CSV (#25832)
* Split out migration logic and create generic BatchWorker * WIP * WIP * POC batch reporting * Oops * Job hookup * Working export to file * PR feedback * Merge'd * Fix error handling * Add API to start report, translations, couple fixes * Add DMs to send reports to users * Merge'd * Update types * A bit of cleanup * Some fixes * Add missing API doc * PR feedback * Fix generated * Fix bug with post creation * PR feedback * Add some tests * PR feedback * Fix lint * Some test changes * Fix tests * Add comment to explain why we forcibly stop * Rework of some tests * Batch report test * Restrict batch exports to Pro and Enterprise licenses * Fix erroneous comment --------- Co-authored-by: Mattermost Build <build@mattermost.com>
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
eac9a39677
Коммит
f7446d7443
@@ -484,6 +484,7 @@ type AppIface interface {
|
||||
CheckUserPostflightAuthenticationCriteria(rctx request.CTX, user *model.User) *model.AppError
|
||||
CheckUserPreflightAuthenticationCriteria(rctx request.CTX, user *model.User, mfaToken string) *model.AppError
|
||||
CheckWebConn(userID, connectionID string) *platform.CheckConnResult
|
||||
CleanupReportChunks(format string, prefix string, numberOfChunks int) *model.AppError
|
||||
ClearChannelMembersCache(c request.CTX, channelID string) error
|
||||
ClearLatestVersionCache(rctx request.CTX)
|
||||
ClearSessionCacheForAllUsers()
|
||||
@@ -497,6 +498,7 @@ type AppIface interface {
|
||||
Cluster() einterfaces.ClusterInterface
|
||||
CompareAndDeletePluginKey(pluginID string, key string, oldValue []byte) (bool, *model.AppError)
|
||||
CompareAndSetPluginKey(pluginID string, key string, oldValue, newValue []byte) (bool, *model.AppError)
|
||||
CompileReportChunks(format string, prefix string, numberOfChunks int, headers []string) *model.AppError
|
||||
CompleteOAuth(c request.CTX, service string, body io.ReadCloser, teamID string, props map[string]string, tokenUser *model.User) (*model.User, *model.AppError)
|
||||
CompleteOnboarding(c request.CTX, request *model.CompleteOnboardingRequest) *model.AppError
|
||||
CompleteSwitchWithOAuth(c request.CTX, service string, userData io.Reader, email string, tokenUser *model.User) (*model.User, *model.AppError)
|
||||
@@ -1023,6 +1025,7 @@ type AppIface interface {
|
||||
RestoreTeam(teamID string) *model.AppError
|
||||
RestrictUsersGetByPermissions(c request.CTX, userID string, options *model.UserGetOptions) (*model.UserGetOptions, *model.AppError)
|
||||
RestrictUsersSearchByPermissions(c request.CTX, userID string, options *model.UserSearchOptions) (*model.UserSearchOptions, *model.AppError)
|
||||
RetrieveBatchReport(reportID string, format string) (filestore.ReadCloseSeeker, string, *model.AppError)
|
||||
ReturnSessionToPool(session *model.Session)
|
||||
RevokeAccessToken(c request.CTX, token string) *model.AppError
|
||||
RevokeAllSessions(c request.CTX, userID string) *model.AppError
|
||||
@@ -1043,6 +1046,7 @@ type AppIface interface {
|
||||
SaveBrandImage(rctx request.CTX, imageData *multipart.FileHeader) *model.AppError
|
||||
SaveComplianceReport(rctx request.CTX, job *model.Compliance) (*model.Compliance, *model.AppError)
|
||||
SaveReactionForPost(c request.CTX, reaction *model.Reaction) (*model.Reaction, *model.AppError)
|
||||
SaveReportChunk(format string, prefix string, count int, reportData []model.ReportableObject) *model.AppError
|
||||
SaveSharedChannelRemote(remote *model.SharedChannelRemote) (*model.SharedChannelRemote, error)
|
||||
SaveUserTermsOfService(userID, termsOfServiceId string, accepted bool) *model.AppError
|
||||
SchemesIterator(scope string, batchSize int) func() []*model.Scheme
|
||||
@@ -1078,6 +1082,7 @@ type AppIface interface {
|
||||
SendPasswordReset(email string, siteURL string) (bool, *model.AppError)
|
||||
SendPaymentFailedEmail(failedPayment *model.FailedPayment) *model.AppError
|
||||
SendPersistentNotifications() error
|
||||
SendReportToUser(rctx request.CTX, userID string, jobId string, format string) *model.AppError
|
||||
SendTestPushNotification(deviceID string) string
|
||||
SendUpgradeConfirmationEmail(isYearly bool) *model.AppError
|
||||
ServeInterPluginRequest(w http.ResponseWriter, r *http.Request, sourcePluginId, destinationPluginId string)
|
||||
@@ -1122,6 +1127,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, 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)
|
||||
|
||||
@@ -1438,6 +1438,28 @@ func (a *OpenTracingAppLayer) CheckWebConn(userID string, connectionID string) *
|
||||
return resultVar0
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) CleanupReportChunks(format string, prefix string, numberOfChunks int) *model.AppError {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CleanupReportChunks")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store().SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store().SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0 := a.app.CleanupReportChunks(format, prefix, numberOfChunks)
|
||||
|
||||
if resultVar0 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar0))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) ClearChannelMembersCache(c request.CTX, channelID string) error {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.ClearChannelMembersCache")
|
||||
@@ -1669,6 +1691,28 @@ func (a *OpenTracingAppLayer) CompareAndSetPluginKey(pluginID string, key string
|
||||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) CompileReportChunks(format string, prefix string, numberOfChunks int, headers []string) *model.AppError {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CompileReportChunks")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store().SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store().SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0 := a.app.CompileReportChunks(format, prefix, numberOfChunks, headers)
|
||||
|
||||
if resultVar0 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar0))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) CompleteOAuth(c request.CTX, service string, body io.ReadCloser, teamID string, props map[string]string, tokenUser *model.User) (*model.User, *model.AppError) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CompleteOAuth")
|
||||
@@ -14618,6 +14662,28 @@ func (a *OpenTracingAppLayer) RestrictUsersSearchByPermissions(c request.CTX, us
|
||||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) RetrieveBatchReport(reportID string, format string) (filestore.ReadCloseSeeker, string, *model.AppError) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.RetrieveBatchReport")
|
||||
|
||||
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, resultVar2 := a.app.RetrieveBatchReport(reportID, format)
|
||||
|
||||
if resultVar2 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar2))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0, resultVar1, resultVar2
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) ReturnSessionToPool(session *model.Session) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.ReturnSessionToPool")
|
||||
@@ -15051,6 +15117,28 @@ func (a *OpenTracingAppLayer) SaveReactionForPost(c request.CTX, reaction *model
|
||||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) SaveReportChunk(format string, prefix string, count int, reportData []model.ReportableObject) *model.AppError {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SaveReportChunk")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store().SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store().SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0 := a.app.SaveReportChunk(format, prefix, count, reportData)
|
||||
|
||||
if resultVar0 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar0))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) SaveSharedChannelRemote(remote *model.SharedChannelRemote) (*model.SharedChannelRemote, error) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SaveSharedChannelRemote")
|
||||
@@ -15872,6 +15960,28 @@ func (a *OpenTracingAppLayer) SendPersistentNotifications() error {
|
||||
return resultVar0
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) SendReportToUser(rctx request.CTX, userID string, jobId string, format string) *model.AppError {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SendReportToUser")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store().SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store().SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0 := a.app.SendReportToUser(rctx, userID, jobId, format)
|
||||
|
||||
if resultVar0 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar0))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) SendSubscriptionHistoryEvent(userID string) (*model.SubscriptionHistory, error) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SendSubscriptionHistoryEvent")
|
||||
@@ -16813,6 +16923,28 @@ func (a *OpenTracingAppLayer) SoftDeleteTeam(teamID string) *model.AppError {
|
||||
return resultVar0
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) StartUsersBatchExport(rctx request.CTX, startAt int64, endAt int64) *model.AppError {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.StartUsersBatchExport")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store().SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store().SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0 := a.app.StartUsersBatchExport(rctx, startAt, endAt)
|
||||
|
||||
if resultVar0 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar0))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) SubmitInteractiveDialog(c request.CTX, request model.SubmitDialogRequest) (*model.SubmitDialogResponse, *model.AppError) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SubmitInteractiveDialog")
|
||||
|
||||
@@ -4,11 +4,143 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"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/platform/shared/filestore"
|
||||
)
|
||||
|
||||
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 headerBuf bytes.Buffer
|
||||
w := csv.NewWriter(&headerBuf)
|
||||
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)
|
||||
}
|
||||
_, appErr := a.WriteFile(&headerBuf, filePath)
|
||||
if appErr != nil {
|
||||
return appErr
|
||||
}
|
||||
|
||||
for i := 0; i < numberOfChunks; i++ {
|
||||
chunkFilePath := makeFilePath(prefix, i, "csv")
|
||||
chunk, err := a.ReadFile(chunkFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = a.AppendFile(bytes.NewReader(chunk), filePath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) SendReportToUser(rctx request.CTX, userID string, jobId string, format string) *model.AppError {
|
||||
systemBot, err := a.GetSystemBot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
channel, err := a.GetOrCreateDirectChannel(request.EmptyContext(a.Log()), userID, systemBot.UserId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
post := &model.Post{
|
||||
ChannelId: channel.Id,
|
||||
Message: i18n.T("app.report.send_report_to_user.export_finished", map[string]string{"Link": a.GetSiteURL() + "/api/v4/reports/export/" + jobId + "?format=" + format}),
|
||||
Type: model.PostTypeAdminReport,
|
||||
UserId: systemBot.UserId,
|
||||
Props: model.StringInterface{
|
||||
"reportId": jobId,
|
||||
"format": format,
|
||||
},
|
||||
}
|
||||
|
||||
_, err = a.CreatePost(rctx, post, channel, false, 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 := 0; i < numberOfChunks; i++ {
|
||||
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
|
||||
@@ -35,3 +167,83 @@ func (a *App) GetUserCountForReport(filter *model.UserReportOptions) (*int64, *m
|
||||
|
||||
return &count, nil
|
||||
}
|
||||
|
||||
func (a *App) StartUsersBatchExport(rctx request.CTX, 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,
|
||||
"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 {
|
||||
return err
|
||||
}
|
||||
for _, job := range pendingJobs {
|
||||
if job.Data["start_at"] == options["start_at"] && job.Data["end_at"] == options["end_at"] && 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["start_at"] == options["start_at"] && job.Data["end_at"] == options["end_at"] && 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.CreateJobOnce(rctx, model.JobTypeExportUsersToCSV, options)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
a.Srv().Go(func() {
|
||||
systemBot, err := a.GetSystemBot()
|
||||
if err != nil {
|
||||
rctx.Logger().Error("Failed to get the system bot", mlog.Err(err))
|
||||
return
|
||||
}
|
||||
|
||||
channel, err := a.GetOrCreateDirectChannel(request.EmptyContext(a.Log()), rctx.Session().UserId, systemBot.UserId)
|
||||
if err != nil {
|
||||
rctx.Logger().Error("Failed to get or create the DM", mlog.Err(err))
|
||||
return
|
||||
}
|
||||
|
||||
post := &model.Post{
|
||||
ChannelId: channel.Id,
|
||||
Message: i18n.T("app.report.start_users_batch_export.started_export"),
|
||||
Type: model.PostTypeDefault,
|
||||
UserId: systemBot.UserId,
|
||||
}
|
||||
|
||||
if _, err := a.CreatePost(rctx, post, channel, false, true); err != nil {
|
||||
rctx.Logger().Error("Failed to post batch export message", mlog.Err(err))
|
||||
}
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) RetrieveBatchReport(reportID string, format string) (filestore.ReadCloseSeeker, string, *model.AppError) {
|
||||
if license := a.Srv().License(); license == nil || (license.SkuShortName != model.LicenseShortSkuProfessional && license.SkuShortName != model.LicenseShortSkuEnterprise) {
|
||||
return nil, "", model.NewAppError("RetrieveBatchReport", "app.report.retrieve_batch_report.license_error", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
filePath := makeCompiledFilePath(reportID, format)
|
||||
reader, err := a.FileReader(filePath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
return reader, makeCompiledFilename(reportID, format), nil
|
||||
}
|
||||
|
||||
109
server/channels/app/report_test.go
Обычный файл
109
server/channels/app/report_test.go
Обычный файл
@@ -0,0 +1,109 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type MockReportable struct {
|
||||
TestField1 string
|
||||
TestField2 int
|
||||
TestField3 time.Time
|
||||
}
|
||||
|
||||
func (mr *MockReportable) ToReport() []string {
|
||||
return []string{
|
||||
mr.TestField1,
|
||||
strconv.Itoa(mr.TestField2),
|
||||
mr.TestField3.Format("2006-01-02"),
|
||||
}
|
||||
}
|
||||
|
||||
var testData []model.ReportableObject = []model.ReportableObject{
|
||||
&MockReportable{
|
||||
TestField1: "some-name",
|
||||
TestField2: 400,
|
||||
TestField3: time.Date(2024, 1, 1, 0, 0, 0, 0, time.Local),
|
||||
},
|
||||
&MockReportable{
|
||||
TestField1: "some-other-name",
|
||||
TestField2: 500,
|
||||
TestField3: time.Date(2023, 1, 1, 0, 0, 0, 0, time.Local),
|
||||
},
|
||||
&MockReportable{
|
||||
TestField1: "some-other-other-name",
|
||||
TestField2: 600,
|
||||
TestField3: time.Date(2022, 1, 1, 0, 0, 0, 0, time.Local),
|
||||
},
|
||||
}
|
||||
|
||||
func TestSaveReportChunk(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
t.Run("should write CSV chunk to file", func(t *testing.T) {
|
||||
prefix := model.NewId()
|
||||
err := th.App.SaveReportChunk("csv", prefix, 999, []model.ReportableObject{testData[0]})
|
||||
require.Nil(t, err)
|
||||
|
||||
filePath := fmt.Sprintf("admin_reports/batch_report_%s__999.csv", prefix)
|
||||
bytes, err := th.App.ReadFile(filePath)
|
||||
require.Nil(t, err)
|
||||
require.NotNil(t, bytes)
|
||||
require.Equal(t, "some-name,400,2024-01-01\n", string(bytes))
|
||||
})
|
||||
|
||||
t.Run("should fail if the report format is not supported", func(t *testing.T) {
|
||||
err := th.App.SaveReportChunk("zzz", model.NewId(), 999, []model.ReportableObject{testData[0]})
|
||||
require.NotNil(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompileReportChunks(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
prefix := model.NewId()
|
||||
err := th.App.SaveReportChunk("csv", prefix, 0, []model.ReportableObject{testData[0]})
|
||||
require.Nil(t, err)
|
||||
err = th.App.SaveReportChunk("csv", prefix, 1, []model.ReportableObject{testData[1]})
|
||||
require.Nil(t, err)
|
||||
err = th.App.SaveReportChunk("csv", prefix, 2, []model.ReportableObject{testData[2]})
|
||||
require.Nil(t, err)
|
||||
|
||||
t.Run("should compile a bunch of report chunks", func(t *testing.T) {
|
||||
compileErr := th.App.CompileReportChunks("csv", prefix, 3, []string{"Name", "NumPosts", "StartDate"})
|
||||
require.Nil(t, compileErr)
|
||||
|
||||
filePath := fmt.Sprintf("admin_reports/batch_report_%s.csv", prefix)
|
||||
bytes, readErr := th.App.ReadFile(filePath)
|
||||
require.Nil(t, readErr)
|
||||
require.NotNil(t, bytes)
|
||||
|
||||
expected :=
|
||||
`Name,NumPosts,StartDate
|
||||
some-name,400,2024-01-01
|
||||
some-other-name,500,2023-01-01
|
||||
some-other-other-name,600,2022-01-01
|
||||
`
|
||||
require.Equal(t, expected, string(bytes))
|
||||
})
|
||||
|
||||
t.Run("should fail if the report format is not supported", func(t *testing.T) {
|
||||
err = th.App.CompileReportChunks("zzz", prefix, 3, []string{"Name", "NumPosts", "StartDate"})
|
||||
require.NotNil(t, err)
|
||||
})
|
||||
|
||||
t.Run("should fail if a chunk is missing", func(t *testing.T) {
|
||||
err = th.App.CompileReportChunks("csv", prefix, 4, []string{"Name", "NumPosts", "StartDate"})
|
||||
require.NotNil(t, err)
|
||||
})
|
||||
}
|
||||
@@ -44,6 +44,7 @@ import (
|
||||
"github.com/mattermost/mattermost/server/v8/channels/jobs/expirynotify"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/jobs/export_delete"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/jobs/export_process"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/jobs/export_users_to_csv"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/jobs/extract_content"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/jobs/hosted_purchase_screening"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/jobs/import_delete"
|
||||
@@ -1677,6 +1678,12 @@ func (s *Server) initJobs() {
|
||||
refresh_post_stats.MakeScheduler(s.Jobs, *s.platform.Config().SqlSettings.DriverName),
|
||||
)
|
||||
|
||||
s.Jobs.RegisterJobType(
|
||||
model.JobTypeExportUsersToCSV,
|
||||
export_users_to_csv.MakeWorker(s.Jobs, s.Store(), New(ServerConnector(s.Channels()))),
|
||||
nil,
|
||||
)
|
||||
|
||||
s.platform.Jobs = s.Jobs
|
||||
}
|
||||
|
||||
|
||||
@@ -1991,8 +1991,6 @@ func TestGetUsersForReporting(t *testing.T) {
|
||||
CreateAt: 1000,
|
||||
FirstName: "Bob",
|
||||
LastName: "Bobson",
|
||||
},
|
||||
UserPostStats: model.UserPostStats{
|
||||
LastLogin: 1500,
|
||||
},
|
||||
},
|
||||
|
||||
Ссылка в новой задаче
Block a user