[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>
Этот коммит содержится в:
Devin Binnie
2023-11-14 11:26:27 -05:00
коммит произвёл GitHub
родитель 41c08a3715
Коммит 1bd72bdb99
27 изменённых файлов: 356 добавлений и 25 удалений

Просмотреть файл

@@ -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

Просмотреть файл

@@ -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)
}

Просмотреть файл

@@ -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, &notifyProps, &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, &notifyProps, &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, &notifyProps, &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 = {