mirror of
https://github.com/mattermost/mattermost.git
synced 2025-02-25 18:55:24 -06:00
[MM-55014][MM-55015] Add last login timestamp for users, add materialized view and refresh job to keep track of post stats for Postgres (#25152)
* [MM-55014][MM-55015] Add last login timestamp for users, add materialized view and refresh job for Postgres * Check fixes * Fix type issue * Add verification that lastlogin was updated * PR feedback * Morge'd * Morge'd again * Merge'd * Update admin setting strings * WIP * PR feedback * Oops * Fix i18n --------- Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
parent
41c08a3715
commit
1bd72bdb99
@ -179,6 +179,7 @@ const defaultServerConfig: AdminConfig = {
|
||||
EnableCustomGroups: true,
|
||||
SelfHostedPurchase: true,
|
||||
AllowSyncedDrafts: true,
|
||||
RefreshPostStatsRunTime: '00:00',
|
||||
},
|
||||
TeamSettings: {
|
||||
SiteName: 'Mattermost',
|
||||
|
@ -213,9 +213,14 @@ func (a *App) DoLogin(c request.CTX, w http.ResponseWriter, r *http.Request, use
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if updateErr := a.Srv().Store().User().UpdateLastLogin(user.Id, session.CreateAt); updateErr != nil {
|
||||
return nil, model.NewAppError("DoLogin", "app.login.doLogin.updateLastLogin.error", nil, updateErr.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
w.Header().Set(model.HeaderToken, session.Token)
|
||||
|
||||
c = c.WithSession(session)
|
||||
|
||||
if a.Srv().License() != nil && *a.Srv().License().Features.LDAP && a.Ldap() != nil {
|
||||
userVal := *user
|
||||
sessionVal := *session
|
||||
|
@ -55,6 +55,7 @@ import (
|
||||
"github.com/mattermost/mattermost/server/v8/channels/jobs/plugins"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/jobs/post_persistent_notifications"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/jobs/product_notices"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/jobs/refresh_post_stats"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/jobs/resend_invitation_email"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/jobs/s3_path_migration"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/product"
|
||||
@ -1663,10 +1664,16 @@ func (s *Server) initJobs() {
|
||||
|
||||
s.Jobs.RegisterJobType(
|
||||
model.JobTypeCleanupDesktopTokens,
|
||||
cleanup_desktop_tokens.MakeWorker(s.Jobs, s.Store()),
|
||||
cleanup_desktop_tokens.MakeWorker(s.Jobs),
|
||||
cleanup_desktop_tokens.MakeScheduler(s.Jobs),
|
||||
)
|
||||
|
||||
s.Jobs.RegisterJobType(
|
||||
model.JobTypeRefreshPostStats,
|
||||
refresh_post_stats.MakeWorker(s.Jobs, *s.platform.Config().SqlSettings.DriverName),
|
||||
refresh_post_stats.MakeScheduler(s.Jobs, *s.platform.Config().SqlSettings.DriverName),
|
||||
)
|
||||
|
||||
s.platform.Jobs = s.Jobs
|
||||
}
|
||||
|
||||
|
@ -226,6 +226,8 @@ channels/db/migrations/mysql/000113_create_retentionidsfordeletion_table.down.sq
|
||||
channels/db/migrations/mysql/000113_create_retentionidsfordeletion_table.up.sql
|
||||
channels/db/migrations/mysql/000114_sharedchannelremotes_drop_nextsyncat_description.down.sql
|
||||
channels/db/migrations/mysql/000114_sharedchannelremotes_drop_nextsyncat_description.up.sql
|
||||
channels/db/migrations/mysql/000115_user_reporting_changes.down.sql
|
||||
channels/db/migrations/mysql/000115_user_reporting_changes.up.sql
|
||||
channels/db/migrations/postgres/000001_create_teams.down.sql
|
||||
channels/db/migrations/postgres/000001_create_teams.up.sql
|
||||
channels/db/migrations/postgres/000002_create_team_members.down.sql
|
||||
@ -452,3 +454,5 @@ channels/db/migrations/postgres/000113_create_retentionidsfordeletion_table.down
|
||||
channels/db/migrations/postgres/000113_create_retentionidsfordeletion_table.up.sql
|
||||
channels/db/migrations/postgres/000114_sharedchannelremotes_drop_nextsyncat_description.down.sql
|
||||
channels/db/migrations/postgres/000114_sharedchannelremotes_drop_nextsyncat_description.up.sql
|
||||
channels/db/migrations/postgres/000115_user_reporting_changes.down.sql
|
||||
channels/db/migrations/postgres/000115_user_reporting_changes.up.sql
|
||||
|
@ -0,0 +1,14 @@
|
||||
SET @preparedStatement = (SELECT IF(
|
||||
EXISTS(
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE table_name = 'Users'
|
||||
AND table_schema = DATABASE()
|
||||
AND column_name = 'LastLogin'
|
||||
) > 0,
|
||||
'ALTER TABLE Users DROP COLUMN LastLogin;',
|
||||
'SELECT 1;'
|
||||
));
|
||||
|
||||
PREPARE removeColumnIfExists FROM @preparedStatement;
|
||||
EXECUTE removeColumnIfExists;
|
||||
DEALLOCATE PREPARE removeColumnIfExists;
|
@ -0,0 +1,14 @@
|
||||
SET @preparedStatement = (SELECT IF(
|
||||
NOT EXISTS(
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE table_name = 'Users'
|
||||
AND table_schema = DATABASE()
|
||||
AND column_name = 'LastLogin'
|
||||
),
|
||||
'ALTER TABLE Users ADD COLUMN LastLogin bigint NOT NULL DEFAULT 0;',
|
||||
'SELECT 1;'
|
||||
));
|
||||
|
||||
PREPARE addColumnIfNotExists FROM @preparedStatement;
|
||||
EXECUTE addColumnIfNotExists;
|
||||
DEALLOCATE PREPARE addColumnIfNotExists;
|
@ -0,0 +1,3 @@
|
||||
DROP MATERIALIZED VIEW IF EXISTS poststats;
|
||||
|
||||
ALTER TABLE users DROP COLUMN IF EXISTS lastlogin;
|
@ -0,0 +1,7 @@
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS lastlogin bigint NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS poststats AS
|
||||
SELECT userid, to_timestamp(createat/1000)::date as day, COUNT(*) as numposts, MAX(CreateAt) as lastpostdate
|
||||
FROM posts
|
||||
GROUP BY userid, day
|
||||
;
|
@ -9,28 +9,19 @@ import (
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/jobs"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store"
|
||||
"github.com/mattermost/mattermost/server/v8/platform/services/configservice"
|
||||
)
|
||||
|
||||
const jobName = "CleanupDesktopTokens"
|
||||
const maxAge = 5 * time.Minute
|
||||
|
||||
type AppIface interface {
|
||||
configservice.ConfigService
|
||||
ListDirectory(path string) ([]string, *model.AppError)
|
||||
FileModTime(path string) (time.Time, *model.AppError)
|
||||
RemoveFile(path string) *model.AppError
|
||||
}
|
||||
|
||||
func MakeWorker(jobServer *jobs.JobServer, store store.Store) *jobs.SimpleWorker {
|
||||
func MakeWorker(jobServer *jobs.JobServer) *jobs.SimpleWorker {
|
||||
isEnabled := func(cfg *model.Config) bool {
|
||||
return true
|
||||
}
|
||||
execute := func(logger mlog.LoggerIFace, job *model.Job) error {
|
||||
defer jobServer.HandleJobPanic(logger, job)
|
||||
|
||||
return store.DesktopTokens().DeleteOlderThan(time.Now().Add(-maxAge).Unix())
|
||||
return jobServer.Store.DesktopTokens().DeleteOlderThan(time.Now().Add(-maxAge).Unix())
|
||||
}
|
||||
worker := jobs.NewSimpleWorker(jobName, jobServer, execute, isEnabled)
|
||||
return worker
|
||||
|
25
server/channels/jobs/refresh_post_stats/scheduler.go
Normal file
25
server/channels/jobs/refresh_post_stats/scheduler.go
Normal file
@ -0,0 +1,25 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package refresh_post_stats
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/jobs"
|
||||
)
|
||||
|
||||
func MakeScheduler(jobServer *jobs.JobServer, sqlDriverName string) *jobs.DailyScheduler {
|
||||
startTime := func(cfg *model.Config) *time.Time {
|
||||
parsedTime, err := time.Parse("15:04", *cfg.ServiceSettings.RefreshPostStatsRunTime)
|
||||
if err == nil {
|
||||
return &parsedTime
|
||||
}
|
||||
return nil
|
||||
}
|
||||
isEnabled := func(cfg *model.Config) bool {
|
||||
return sqlDriverName == model.DatabaseDriverPostgres
|
||||
}
|
||||
return jobs.NewDailyScheduler(jobServer, model.JobTypeRefreshPostStats, startTime, isEnabled)
|
||||
}
|
25
server/channels/jobs/refresh_post_stats/worker.go
Normal file
25
server/channels/jobs/refresh_post_stats/worker.go
Normal file
@ -0,0 +1,25 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package refresh_post_stats
|
||||
|
||||
import (
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/jobs"
|
||||
)
|
||||
|
||||
const jobName = "RefreshPostStats"
|
||||
|
||||
func MakeWorker(jobServer *jobs.JobServer, sqlDriverName string) *jobs.SimpleWorker {
|
||||
isEnabled := func(cfg *model.Config) bool {
|
||||
return sqlDriverName == model.DatabaseDriverPostgres
|
||||
}
|
||||
execute := func(logger mlog.LoggerIFace, job *model.Job) error {
|
||||
defer jobServer.HandleJobPanic(logger, job)
|
||||
|
||||
return jobServer.Store.User().RefreshPostStatsForUsers()
|
||||
}
|
||||
worker := jobs.NewSimpleWorker(jobName, jobServer, execute, isEnabled)
|
||||
return worker
|
||||
}
|
@ -11921,6 +11921,24 @@ func (s *OpenTracingLayerUserStore) PromoteGuestToUser(userID string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *OpenTracingLayerUserStore) RefreshPostStatsForUsers() error {
|
||||
origCtx := s.Root.Store.Context()
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "UserStore.RefreshPostStatsForUsers")
|
||||
s.Root.Store.SetContext(newCtx)
|
||||
defer func() {
|
||||
s.Root.Store.SetContext(origCtx)
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
err := s.UserStore.RefreshPostStatsForUsers()
|
||||
if err != nil {
|
||||
span.LogFields(spanlog.Error(err))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *OpenTracingLayerUserStore) ResetAuthDataToEmailForUsers(service string, userIDs []string, includeDeleted bool, dryRun bool) (int, error) {
|
||||
origCtx := s.Root.Store.Context()
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "UserStore.ResetAuthDataToEmailForUsers")
|
||||
@ -12155,6 +12173,24 @@ func (s *OpenTracingLayerUserStore) UpdateFailedPasswordAttempts(userID string,
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *OpenTracingLayerUserStore) UpdateLastLogin(userID string, lastLogin int64) error {
|
||||
origCtx := s.Root.Store.Context()
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "UserStore.UpdateLastLogin")
|
||||
s.Root.Store.SetContext(newCtx)
|
||||
defer func() {
|
||||
s.Root.Store.SetContext(origCtx)
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
err := s.UserStore.UpdateLastLogin(userID, lastLogin)
|
||||
if err != nil {
|
||||
span.LogFields(spanlog.Error(err))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *OpenTracingLayerUserStore) UpdateLastPictureUpdate(userID string) error {
|
||||
origCtx := s.Root.Store.Context()
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "UserStore.UpdateLastPictureUpdate")
|
||||
|
@ -13585,6 +13585,27 @@ func (s *RetryLayerUserStore) PromoteGuestToUser(userID string) error {
|
||||
|
||||
}
|
||||
|
||||
func (s *RetryLayerUserStore) RefreshPostStatsForUsers() error {
|
||||
|
||||
tries := 0
|
||||
for {
|
||||
err := s.UserStore.RefreshPostStatsForUsers()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !isRepeatableError(err) {
|
||||
return err
|
||||
}
|
||||
tries++
|
||||
if tries >= 3 {
|
||||
err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures")
|
||||
return err
|
||||
}
|
||||
timepkg.Sleep(100 * timepkg.Millisecond)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (s *RetryLayerUserStore) ResetAuthDataToEmailForUsers(service string, userIDs []string, includeDeleted bool, dryRun bool) (int, error) {
|
||||
|
||||
tries := 0
|
||||
@ -13858,6 +13879,27 @@ func (s *RetryLayerUserStore) UpdateFailedPasswordAttempts(userID string, attemp
|
||||
|
||||
}
|
||||
|
||||
func (s *RetryLayerUserStore) UpdateLastLogin(userID string, lastLogin int64) error {
|
||||
|
||||
tries := 0
|
||||
for {
|
||||
err := s.UserStore.UpdateLastLogin(userID, lastLogin)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !isRepeatableError(err) {
|
||||
return err
|
||||
}
|
||||
tries++
|
||||
if tries >= 3 {
|
||||
err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures")
|
||||
return err
|
||||
}
|
||||
timepkg.Sleep(100 * timepkg.Millisecond)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (s *RetryLayerUserStore) UpdateLastPictureUpdate(userID string) error {
|
||||
|
||||
tries := 0
|
||||
|
@ -54,7 +54,7 @@ func newSqlUserStore(sqlStore *SqlStore, metrics einterfaces.MetricsInterface) s
|
||||
// note: we are providing field names explicitly here to maintain order of columns (needed when using raw queries)
|
||||
us.usersQuery = us.getQueryBuilder().
|
||||
Select("u.Id", "u.CreateAt", "u.UpdateAt", "u.DeleteAt", "u.Username", "u.Password", "u.AuthData", "u.AuthService", "u.Email", "u.EmailVerified", "u.Nickname", "u.FirstName", "u.LastName", "u.Position", "u.Roles", "u.AllowMarketing", "u.Props", "u.NotifyProps", "u.LastPasswordUpdate", "u.LastPictureUpdate", "u.FailedAttempts", "u.Locale", "u.Timezone", "u.MfaActive", "u.MfaSecret",
|
||||
"b.UserId IS NOT NULL AS IsBot", "COALESCE(b.Description, '') AS BotDescription", "COALESCE(b.LastIconUpdate, 0) AS BotLastIconUpdate", "u.RemoteId").
|
||||
"b.UserId IS NOT NULL AS IsBot", "COALESCE(b.Description, '') AS BotDescription", "COALESCE(b.LastIconUpdate, 0) AS BotLastIconUpdate", "u.RemoteId", "u.LastLogin").
|
||||
From("Users u").
|
||||
LeftJoin("Bots b ON ( b.UserId = u.Id )")
|
||||
|
||||
@ -193,6 +193,7 @@ func (us SqlUserStore) Update(user *model.User, trustedUpdateData bool) (*model.
|
||||
user.FailedAttempts = oldUser.FailedAttempts
|
||||
user.MfaSecret = oldUser.MfaSecret
|
||||
user.MfaActive = oldUser.MfaActive
|
||||
user.LastLogin = oldUser.LastLogin
|
||||
|
||||
if !trustedUpdateData {
|
||||
user.Roles = oldUser.Roles
|
||||
@ -222,7 +223,7 @@ func (us SqlUserStore) Update(user *model.User, trustedUpdateData bool) (*model.
|
||||
AllowMarketing=:AllowMarketing, Props=:Props, NotifyProps=:NotifyProps,
|
||||
LastPasswordUpdate=:LastPasswordUpdate, LastPictureUpdate=:LastPictureUpdate,
|
||||
FailedAttempts=:FailedAttempts,Locale=:Locale, Timezone=:Timezone, MfaActive=:MfaActive,
|
||||
MfaSecret=:MfaSecret, RemoteId=:RemoteId
|
||||
MfaSecret=:MfaSecret, RemoteId=:RemoteId, LastLogin=:LastLogin
|
||||
WHERE Id=:Id`
|
||||
|
||||
user.Props = wrapBinaryParamStringMap(us.IsBinaryParamEnabled(), user.Props)
|
||||
@ -355,6 +356,27 @@ func (us SqlUserStore) UpdateAuthData(userId string, service string, authData *s
|
||||
return userId, nil
|
||||
}
|
||||
|
||||
func (us SqlUserStore) UpdateLastLogin(userId string, lastLogin int64) error {
|
||||
updateAt := model.GetMillis()
|
||||
|
||||
updateQuery := us.getQueryBuilder().
|
||||
Update("Users").
|
||||
Set("LastLogin", lastLogin).
|
||||
Set("UpdateAt", updateAt).
|
||||
Where(sq.Eq{"Id": userId})
|
||||
|
||||
queryString, args, err := updateQuery.ToSql()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "update_last_login_tosql")
|
||||
}
|
||||
|
||||
if _, err := us.GetMasterX().Exec(queryString, args...); err != nil {
|
||||
return errors.Wrapf(err, "failed to update User with userId=%s", userId)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetAuthDataToEmailForUsers resets the AuthData of users whose AuthService
|
||||
// is |service| to their Email. If userIDs is non-empty, only the users whose
|
||||
// IDs are in userIDs will be affected. If dryRun is true, only the number
|
||||
@ -449,7 +471,7 @@ func (us SqlUserStore) Get(ctx context.Context, id string) (*model.User, error)
|
||||
&user.Nickname, &user.FirstName, &user.LastName, &user.Position, &user.Roles,
|
||||
&user.AllowMarketing, &props, ¬ifyProps, &user.LastPasswordUpdate, &user.LastPictureUpdate,
|
||||
&user.FailedAttempts, &user.Locale, &timezone, &user.MfaActive, &user.MfaSecret,
|
||||
&user.IsBot, &user.BotDescription, &user.BotLastIconUpdate, &user.RemoteId)
|
||||
&user.IsBot, &user.BotDescription, &user.BotLastIconUpdate, &user.RemoteId, &user.LastLogin)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, store.NewErrNotFound("User", id)
|
||||
@ -851,7 +873,7 @@ func (us SqlUserStore) GetAllProfilesInChannel(ctx context.Context, channelID st
|
||||
for rows.Next() {
|
||||
var user model.User
|
||||
var props, notifyProps, timezone []byte
|
||||
if err = rows.Scan(&user.Id, &user.CreateAt, &user.UpdateAt, &user.DeleteAt, &user.Username, &user.Password, &user.AuthData, &user.AuthService, &user.Email, &user.EmailVerified, &user.Nickname, &user.FirstName, &user.LastName, &user.Position, &user.Roles, &user.AllowMarketing, &props, ¬ifyProps, &user.LastPasswordUpdate, &user.LastPictureUpdate, &user.FailedAttempts, &user.Locale, &timezone, &user.MfaActive, &user.MfaSecret, &user.IsBot, &user.BotDescription, &user.BotLastIconUpdate, &user.RemoteId); err != nil {
|
||||
if err = rows.Scan(&user.Id, &user.CreateAt, &user.UpdateAt, &user.DeleteAt, &user.Username, &user.Password, &user.AuthData, &user.AuthService, &user.Email, &user.EmailVerified, &user.Nickname, &user.FirstName, &user.LastName, &user.Position, &user.Roles, &user.AllowMarketing, &props, ¬ifyProps, &user.LastPasswordUpdate, &user.LastPictureUpdate, &user.FailedAttempts, &user.Locale, &timezone, &user.MfaActive, &user.MfaSecret, &user.IsBot, &user.BotDescription, &user.BotLastIconUpdate, &user.RemoteId, &user.LastLogin); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to scan values from rows into User entity")
|
||||
}
|
||||
if err = json.Unmarshal(props, &user.Props); err != nil {
|
||||
@ -2231,3 +2253,15 @@ func (us SqlUserStore) GetUsersWithInvalidEmails(page int, perPage int, restrict
|
||||
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (us SqlUserStore) RefreshPostStatsForUsers() error {
|
||||
if us.DriverName() == model.DatabaseDriverPostgres {
|
||||
if _, err := us.GetReplicaX().Exec("REFRESH MATERIALIZED VIEW poststats"); err != nil {
|
||||
return errors.Wrap(err, "users_refresh_post_stats_exec")
|
||||
}
|
||||
} else {
|
||||
mlog.Debug("Skipped running refresh post stats, only available on Postgres")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -419,6 +419,7 @@ type UserStore interface {
|
||||
UpdatePassword(userID, newPassword string) error
|
||||
UpdateUpdateAt(userID string) (int64, error)
|
||||
UpdateAuthData(userID string, service string, authData *string, email string, resetMfa bool) (string, error)
|
||||
UpdateLastLogin(userID string, lastLogin int64) error
|
||||
ResetAuthDataToEmailForUsers(service string, userIDs []string, includeDeleted bool, dryRun bool) (int, error)
|
||||
UpdateMfaSecret(userID, secret string) error
|
||||
UpdateMfaActive(userID string, active bool) error
|
||||
@ -488,6 +489,7 @@ type UserStore interface {
|
||||
IsEmpty(excludeBots bool) (bool, error)
|
||||
GetUsersWithInvalidEmails(page int, perPage int, restrictedDomains string) ([]*model.User, error)
|
||||
InsertUsers(users []*model.User) error
|
||||
RefreshPostStatsForUsers() error
|
||||
}
|
||||
|
||||
type BotStore interface {
|
||||
|
@ -1308,6 +1308,20 @@ func (_m *UserStore) PromoteGuestToUser(userID string) error {
|
||||
return r0
|
||||
}
|
||||
|
||||
// RefreshPostStatsForUsers provides a mock function with given fields:
|
||||
func (_m *UserStore) RefreshPostStatsForUsers() error {
|
||||
ret := _m.Called()
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func() error); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// ResetAuthDataToEmailForUsers provides a mock function with given fields: service, userIDs, includeDeleted, dryRun
|
||||
func (_m *UserStore) ResetAuthDataToEmailForUsers(service string, userIDs []string, includeDeleted bool, dryRun bool) (int, error) {
|
||||
ret := _m.Called(service, userIDs, includeDeleted, dryRun)
|
||||
@ -1618,6 +1632,20 @@ func (_m *UserStore) UpdateFailedPasswordAttempts(userID string, attempts int) e
|
||||
return r0
|
||||
}
|
||||
|
||||
// UpdateLastLogin provides a mock function with given fields: userID, lastLogin
|
||||
func (_m *UserStore) UpdateLastLogin(userID string, lastLogin int64) error {
|
||||
ret := _m.Called(userID, lastLogin)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(string, int64) error); ok {
|
||||
r0 = rf(userID, lastLogin)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// UpdateLastPictureUpdate provides a mock function with given fields: userID
|
||||
func (_m *UserStore) UpdateLastPictureUpdate(userID string) error {
|
||||
ret := _m.Called(userID)
|
||||
|
@ -95,6 +95,7 @@ func TestUserStore(t *testing.T, ss store.Store, s SqlStore) {
|
||||
t.Run("ResetLastPictureUpdate", func(t *testing.T) { testUserStoreResetLastPictureUpdate(t, ss) })
|
||||
t.Run("GetKnownUsers", func(t *testing.T) { testGetKnownUsers(t, ss) })
|
||||
t.Run("GetUsersWithInvalidEmails", func(t *testing.T) { testGetUsersWithInvalidEmails(t, ss) })
|
||||
t.Run("UpdateLastLogin", func(t *testing.T) { testUpdateLastLogin(t, ss) })
|
||||
}
|
||||
|
||||
func testUserStoreSave(t *testing.T, ss store.Store) {
|
||||
@ -6169,3 +6170,18 @@ func testGetUsersWithInvalidEmails(t *testing.T, ss store.Store) {
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, users, 1)
|
||||
}
|
||||
|
||||
func testUpdateLastLogin(t *testing.T, ss store.Store) {
|
||||
u1 := model.User{}
|
||||
u1.Email = MakeEmail()
|
||||
_, err := ss.User().Save(&u1)
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, ss.User().PermanentDelete(u1.Id)) }()
|
||||
|
||||
err = ss.User().UpdateLastLogin(u1.Id, 1234567890)
|
||||
require.NoError(t, err)
|
||||
|
||||
user, err := ss.User().Get(context.Background(), u1.Id)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1234567890), user.LastLogin)
|
||||
}
|
||||
|
@ -10744,6 +10744,22 @@ func (s *TimerLayerUserStore) PromoteGuestToUser(userID string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *TimerLayerUserStore) RefreshPostStatsForUsers() error {
|
||||
start := time.Now()
|
||||
|
||||
err := s.UserStore.RefreshPostStatsForUsers()
|
||||
|
||||
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.RefreshPostStatsForUsers", success, elapsed)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *TimerLayerUserStore) ResetAuthDataToEmailForUsers(service string, userIDs []string, includeDeleted bool, dryRun bool) (int, error) {
|
||||
start := time.Now()
|
||||
|
||||
@ -10952,6 +10968,22 @@ func (s *TimerLayerUserStore) UpdateFailedPasswordAttempts(userID string, attemp
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *TimerLayerUserStore) UpdateLastLogin(userID string, lastLogin int64) error {
|
||||
start := time.Now()
|
||||
|
||||
err := s.UserStore.UpdateLastLogin(userID, lastLogin)
|
||||
|
||||
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.UpdateLastLogin", success, elapsed)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *TimerLayerUserStore) UpdateLastPictureUpdate(userID string) error {
|
||||
start := time.Now()
|
||||
|
||||
|
@ -6018,6 +6018,10 @@
|
||||
"id": "app.license.generate_renewal_token.no_license",
|
||||
"translation": "No license present"
|
||||
},
|
||||
{
|
||||
"id": "app.login.doLogin.updateLastLogin.error",
|
||||
"translation": "Could not update last login timestamp"
|
||||
},
|
||||
{
|
||||
"id": "app.member_count",
|
||||
"translation": "error retrieving member count"
|
||||
|
@ -492,6 +492,7 @@ func (ts *TelemetryService) trackConfig() {
|
||||
"persistent_notification_max_recipients": *cfg.ServiceSettings.PersistentNotificationMaxRecipients,
|
||||
"self_hosted_purchase": *cfg.ServiceSettings.SelfHostedPurchase,
|
||||
"allow_synced_drafts": *cfg.ServiceSettings.AllowSyncedDrafts,
|
||||
"refresh_post_stats_run_time": *cfg.ServiceSettings.RefreshPostStatsRunTime,
|
||||
})
|
||||
|
||||
ts.SendTelemetry(TrackConfigTeam, map[string]any{
|
||||
|
@ -394,6 +394,7 @@ type ServiceSettings struct {
|
||||
EnableCustomGroups *bool `access:"site_users_and_teams"`
|
||||
SelfHostedPurchase *bool `access:"write_restrictable,cloud_restrictable"`
|
||||
AllowSyncedDrafts *bool `access:"site_posts"`
|
||||
RefreshPostStatsRunTime *string `access:"site_users_and_teams"`
|
||||
}
|
||||
|
||||
var MattermostGiphySdkKey string
|
||||
@ -884,6 +885,10 @@ func (s *ServiceSettings) SetDefaults(isUpdate bool) {
|
||||
if s.SelfHostedPurchase == nil {
|
||||
s.SelfHostedPurchase = NewBool(true)
|
||||
}
|
||||
|
||||
if s.RefreshPostStatsRunTime == nil {
|
||||
s.RefreshPostStatsRunTime = NewString("00:00")
|
||||
}
|
||||
}
|
||||
|
||||
type ClusterSettings struct {
|
||||
|
@ -37,6 +37,7 @@ const (
|
||||
JobTypeS3PathMigration = "s3_path_migration"
|
||||
JobTypeCleanupDesktopTokens = "cleanup_desktop_tokens"
|
||||
JobTypeDeleteEmptyDraftsMigration = "delete_empty_drafts_migration"
|
||||
JobTypeRefreshPostStats = "refresh_post_stats"
|
||||
|
||||
JobStatusPending = "pending"
|
||||
JobStatusInProgress = "in_progress"
|
||||
@ -68,6 +69,7 @@ var AllJobTypes = [...]string{
|
||||
JobTypeLastAccessiblePost,
|
||||
JobTypeLastAccessibleFile,
|
||||
JobTypeCleanupDesktopTokens,
|
||||
JobTypeRefreshPostStats,
|
||||
}
|
||||
|
||||
type Job struct {
|
||||
|
@ -106,6 +106,7 @@ type User struct {
|
||||
TermsOfServiceId string `json:"terms_of_service_id,omitempty"`
|
||||
TermsOfServiceCreateAt int64 `json:"terms_of_service_create_at,omitempty"`
|
||||
DisableWelcomeEmail bool `json:"disable_welcome_email"`
|
||||
LastLogin int64 `json:"last_login,omitempty"`
|
||||
}
|
||||
|
||||
func (u *User) Auditable() map[string]interface{} {
|
||||
@ -611,6 +612,7 @@ func (u *User) Sanitize(options map[string]bool) {
|
||||
u.Password = ""
|
||||
u.AuthData = NewString("")
|
||||
u.MfaSecret = ""
|
||||
u.LastLogin = 0
|
||||
|
||||
if len(options) != 0 && !options["email"] {
|
||||
u.Email = ""
|
||||
|
@ -17,8 +17,8 @@ func (z *User) DecodeMsg(dc *msgp.Reader) (err error) {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
if zb0001 != 33 {
|
||||
err = msgp.ArrayError{Wanted: 33, Got: zb0001}
|
||||
if zb0001 != 34 {
|
||||
err = msgp.ArrayError{Wanted: 34, Got: zb0001}
|
||||
return
|
||||
}
|
||||
z.Id, err = dc.ReadString()
|
||||
@ -210,13 +210,18 @@ func (z *User) DecodeMsg(dc *msgp.Reader) (err error) {
|
||||
err = msgp.WrapError(err, "DisableWelcomeEmail")
|
||||
return
|
||||
}
|
||||
z.LastLogin, err = dc.ReadInt64()
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "LastLogin")
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// EncodeMsg implements msgp.Encodable
|
||||
func (z *User) EncodeMsg(en *msgp.Writer) (err error) {
|
||||
// array header, size 33
|
||||
err = en.Append(0xdc, 0x0, 0x21)
|
||||
// array header, size 34
|
||||
err = en.Append(0xdc, 0x0, 0x22)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@ -399,14 +404,19 @@ func (z *User) EncodeMsg(en *msgp.Writer) (err error) {
|
||||
err = msgp.WrapError(err, "DisableWelcomeEmail")
|
||||
return
|
||||
}
|
||||
err = en.WriteInt64(z.LastLogin)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "LastLogin")
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// MarshalMsg implements msgp.Marshaler
|
||||
func (z *User) MarshalMsg(b []byte) (o []byte, err error) {
|
||||
o = msgp.Require(b, z.Msgsize())
|
||||
// array header, size 33
|
||||
o = append(o, 0xdc, 0x0, 0x21)
|
||||
// array header, size 34
|
||||
o = append(o, 0xdc, 0x0, 0x22)
|
||||
o = msgp.AppendString(o, z.Id)
|
||||
o = msgp.AppendInt64(o, z.CreateAt)
|
||||
o = msgp.AppendInt64(o, z.UpdateAt)
|
||||
@ -460,6 +470,7 @@ func (z *User) MarshalMsg(b []byte) (o []byte, err error) {
|
||||
o = msgp.AppendString(o, z.TermsOfServiceId)
|
||||
o = msgp.AppendInt64(o, z.TermsOfServiceCreateAt)
|
||||
o = msgp.AppendBool(o, z.DisableWelcomeEmail)
|
||||
o = msgp.AppendInt64(o, z.LastLogin)
|
||||
return
|
||||
}
|
||||
|
||||
@ -471,8 +482,8 @@ func (z *User) UnmarshalMsg(bts []byte) (o []byte, err error) {
|
||||
err = msgp.WrapError(err)
|
||||
return
|
||||
}
|
||||
if zb0001 != 33 {
|
||||
err = msgp.ArrayError{Wanted: 33, Got: zb0001}
|
||||
if zb0001 != 34 {
|
||||
err = msgp.ArrayError{Wanted: 34, Got: zb0001}
|
||||
return
|
||||
}
|
||||
z.Id, bts, err = msgp.ReadStringBytes(bts)
|
||||
@ -662,6 +673,11 @@ func (z *User) UnmarshalMsg(bts []byte) (o []byte, err error) {
|
||||
err = msgp.WrapError(err, "DisableWelcomeEmail")
|
||||
return
|
||||
}
|
||||
z.LastLogin, bts, err = msgp.ReadInt64Bytes(bts)
|
||||
if err != nil {
|
||||
err = msgp.WrapError(err, "LastLogin")
|
||||
return
|
||||
}
|
||||
o = bts
|
||||
return
|
||||
}
|
||||
@ -680,7 +696,7 @@ func (z *User) Msgsize() (s int) {
|
||||
} else {
|
||||
s += msgp.StringPrefixSize + len(*z.RemoteId)
|
||||
}
|
||||
s += msgp.Int64Size + msgp.BoolSize + msgp.StringPrefixSize + len(z.BotDescription) + msgp.Int64Size + msgp.StringPrefixSize + len(z.TermsOfServiceId) + msgp.Int64Size + msgp.BoolSize
|
||||
s += msgp.Int64Size + msgp.BoolSize + msgp.StringPrefixSize + len(z.BotDescription) + msgp.Int64Size + msgp.StringPrefixSize + len(z.TermsOfServiceId) + msgp.Int64Size + msgp.BoolSize + msgp.Int64Size
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -2464,6 +2464,17 @@ const AdminDefinition: AdminDefinitionType = {
|
||||
it.licensedForSku(LicenseSkus.Professional),
|
||||
)),
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
key: 'ServiceSettings.RefreshPostStatsRunTime',
|
||||
label: t('admin.team.refreshPostStatsRunTimeTitle'),
|
||||
label_default: 'User Statistics Update Time:',
|
||||
help_text: t('admin.team.refreshPostStatsRunTimeDescription'),
|
||||
help_text_default: "Set the server time for updating the user post statistics, including each user's total post count and the timestamp of their most recent post. Must be a 24-hour time stamp in the form HH:MM based on the local time of the server.",
|
||||
placeholder: t('admin.team.refreshPostStatsRunTimeExample'),
|
||||
placeholder_default: 'E.g.: "00:00"',
|
||||
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.USERS_AND_TEAMS)),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
@ -2550,6 +2550,9 @@
|
||||
"admin.team.noBrandImage": "No brand image uploaded",
|
||||
"admin.team.openServerDescription": "When true, anyone can signup for a user account on this server without the need to be invited.",
|
||||
"admin.team.openServerTitle": "Enable Open Server: ",
|
||||
"admin.team.refreshPostStatsRunTimeDescription": "Set the server time for updating the user post statistics, including each user's total post count and the timestamp of their most recent post. Must be a 24-hour time stamp in the form HH:MM based on the local time of the server.",
|
||||
"admin.team.refreshPostStatsRunTimeExample": "E.g.: \"00:00\"",
|
||||
"admin.team.refreshPostStatsRunTimeTitle": "User Statistics Update Time:",
|
||||
"admin.team.removeBrandImage": "Remove brand image",
|
||||
"admin.team.restrict_direct_message_any": "Any user on the Mattermost server",
|
||||
"admin.team.restrict_direct_message_team": "Any member of the team",
|
||||
|
@ -378,6 +378,7 @@ export type ServiceSettings = {
|
||||
PersistentNotificationIntervalMinutes: number;
|
||||
PersistentNotificationMaxCount: number;
|
||||
PersistentNotificationMaxRecipients: number;
|
||||
RefreshPostStatsRunTime: string;
|
||||
};
|
||||
|
||||
export type TeamSettings = {
|
||||
|
Loading…
Reference in New Issue
Block a user