[MM-44084] Feature: Top threads insights (#20195)
* Add route endpoints, model, store functions, and tests for top threads
* Run make store-layers
* Make the following changes
- Fix top user threads query
- Fix passing parameters in api4/insights.go to handler in app
- Add top user threads test
* Add post-message, user_id, participants information to insights results
* model.TopThread.UserID -> model.TopThread.UserId, for compatibility with MySQL
* Rename name -> channel_name
* Add user information to response
* Link post in response, filter out deleted root posts from top threads
* Handle thread delete cases, add app tests for threads insights
* lint: fix typo
* lint: rename asserts
* lint: require.nil -> require.NoError
* Add integration tests for thread insights
* Add embeds and images to top posts
* Add license checks for top threads endpoints
* Query users in batch to populate post-creator
* Make the following changes
- Add license to test server in api4/
- Add tests for threads insights
- top team threads shouldn't include threads from other teams, DMs
- Test duration constraint
- Pagination testing for top threads in model/insights_test.go
* Add i18n-extract
* i18n fixes
* Add username, nickname to user_information
* Hide message, user_id, post_id, reply_count in depth=1 of top threads response
* Fix tests using response.reply_count to use response.post.reply_count
Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
de50943d61
Коммит
2cd83d2f8d
114
api4/insights.go
114
api4/insights.go
@@ -20,6 +20,10 @@ func (api *API) InitInsights() {
|
||||
// Channels
|
||||
api.BaseRoutes.InsightsForTeam.Handle("/channels", api.APISessionRequired(minimumProfessionalLicense(rejectGuests(getTopChannelsForTeamSince)))).Methods("GET")
|
||||
api.BaseRoutes.InsightsForUser.Handle("/channels", api.APISessionRequired(minimumProfessionalLicense(rejectGuests(getTopChannelsForUserSince)))).Methods("GET")
|
||||
|
||||
// Threads
|
||||
api.BaseRoutes.InsightsForTeam.Handle("/threads", api.APISessionRequired(requireLicense(getTopThreadsForTeamSince))).Methods("GET")
|
||||
api.BaseRoutes.InsightsForUser.Handle("/threads", api.APISessionRequired(requireLicense(getTopThreadsForUserSince))).Methods("GET")
|
||||
}
|
||||
|
||||
// Top Reactions
|
||||
@@ -227,6 +231,116 @@ func getTopChannelsForUserSince(c *Context, w http.ResponseWriter, r *http.Reque
|
||||
w.Write(js)
|
||||
}
|
||||
|
||||
// Top Threads
|
||||
func getTopThreadsForTeamSince(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequireTeamId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
team, err := c.App.GetTeam(c.Params.TeamId)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
// license check
|
||||
lic := c.App.Srv().License()
|
||||
if lic.SkuShortName != model.LicenseShortSkuProfessional && lic.SkuShortName != model.LicenseShortSkuEnterprise {
|
||||
c.Err = model.NewAppError("", "api.insights.license_error", nil, "", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
|
||||
// restrict guests and users with no access to team
|
||||
user, err := c.App.GetUser(c.AppContext.Session().UserId)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), team.Id, model.PermissionViewTeam) || user.IsGuest() {
|
||||
c.SetPermissionError(model.PermissionViewTeam)
|
||||
return
|
||||
}
|
||||
|
||||
startTime := model.StartOfDayForTimeRange(c.Params.TimeRange, user.GetTimezoneLocation())
|
||||
|
||||
topThreads, err := c.App.GetTopThreadsForTeamSince(c.Params.TeamId, c.AppContext.Session().UserId, &model.InsightsOpts{
|
||||
StartUnixMilli: startTime.UnixMilli(),
|
||||
Page: c.Params.Page,
|
||||
PerPage: c.Params.PerPage,
|
||||
})
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
js, jsonErr := json.Marshal(topThreads)
|
||||
if jsonErr != nil {
|
||||
c.Err = model.NewAppError("getTopThreadsForTeamSince", "api.marshal_error", nil, jsonErr.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Write(js)
|
||||
}
|
||||
|
||||
func getTopThreadsForUserSince(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.Params.TeamId = r.URL.Query().Get("team_id")
|
||||
|
||||
// restrict guests and users with no access to team
|
||||
user, err := c.App.GetUser(c.AppContext.Session().UserId)
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
// TeamId is an optional parameter
|
||||
if c.Params.TeamId != "" {
|
||||
if !model.IsValidId(c.Params.TeamId) {
|
||||
c.SetInvalidURLParam("team_id")
|
||||
return
|
||||
}
|
||||
|
||||
team, teamErr := c.App.GetTeam(c.Params.TeamId)
|
||||
if teamErr != nil {
|
||||
c.Err = teamErr
|
||||
return
|
||||
}
|
||||
|
||||
// license check
|
||||
lic := c.App.Srv().License()
|
||||
if lic.SkuShortName != model.LicenseShortSkuProfessional && lic.SkuShortName != model.LicenseShortSkuEnterprise {
|
||||
c.Err = model.NewAppError("", "api.insights.license_error", nil, "", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
|
||||
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), team.Id, model.PermissionViewTeam) || user.IsGuest() {
|
||||
c.SetPermissionError(model.PermissionViewTeam)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
startTime := model.StartOfDayForTimeRange(c.Params.TimeRange, user.GetTimezoneLocation())
|
||||
|
||||
topThreads, err := c.App.GetTopThreadsForUserSince(c.Params.TeamId, c.AppContext.Session().UserId, &model.InsightsOpts{
|
||||
StartUnixMilli: startTime.UnixMilli(),
|
||||
Page: c.Params.Page,
|
||||
PerPage: c.Params.PerPage,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
js, jsonErr := json.Marshal(topThreads)
|
||||
if jsonErr != nil {
|
||||
c.Err = model.NewAppError("getTopThreadsForUserSince", "api.marshal_error", nil, jsonErr.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Write(js)
|
||||
}
|
||||
|
||||
// postCountByDurationViewModel expects a list of channels that are pre-authorized for the given user to view.
|
||||
func postCountByDurationViewModel(app app.AppIface, topChannelList *model.TopChannelList, startTime *time.Time, timeRange string, userID *string, location *time.Location) (model.ChannelPostCountByDuration, *model.AppError) {
|
||||
if len(topChannelList.Items) == 0 {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/store"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -610,3 +611,228 @@ func TestGetTopChannelsForUserSince(t *testing.T) {
|
||||
CheckForbiddenStatus(t, resp)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetTopThreadsForTeamSince(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuProfessional))
|
||||
|
||||
th.ConfigStore.SetReadOnlyFF(false)
|
||||
defer th.ConfigStore.SetReadOnlyFF(true)
|
||||
th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.InsightsEnabled = true })
|
||||
|
||||
th.LoginBasic()
|
||||
client := th.Client
|
||||
|
||||
// create a public channel, a private channel
|
||||
|
||||
channelPublic := th.BasicChannel
|
||||
channelPrivate := th.BasicPrivateChannel
|
||||
th.App.AddUserToChannel(th.BasicUser, channelPublic, false)
|
||||
th.App.AddUserToChannel(th.BasicUser, channelPrivate, false)
|
||||
th.App.AddUserToChannel(th.BasicUser2, channelPublic, false)
|
||||
th.App.RemoveUserFromChannel(th.Context, th.BasicUser2.Id, th.BasicUser.Id, channelPrivate)
|
||||
|
||||
// create two threads: one in public channel, one in private
|
||||
// post in public channel has both users interacting, post in private only has user1 interacting
|
||||
|
||||
rootPostPublicChannel, appErr := th.App.CreatePost(th.Context, &model.Post{
|
||||
UserId: th.BasicUser.Id,
|
||||
ChannelId: channelPublic.Id,
|
||||
Message: "root post pub",
|
||||
}, channelPublic, false, true)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
_, appErr = th.App.CreatePost(th.Context, &model.Post{
|
||||
UserId: th.BasicUser2.Id,
|
||||
ChannelId: channelPublic.Id,
|
||||
RootId: rootPostPublicChannel.Id,
|
||||
Message: "reply post 1",
|
||||
}, channelPublic, false, true)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
rootPostPrivateChannel, appErr := th.App.CreatePost(th.Context, &model.Post{
|
||||
UserId: th.BasicUser.Id,
|
||||
ChannelId: channelPrivate.Id,
|
||||
Message: "root post priv",
|
||||
}, channelPrivate, false, true)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
_, appErr = th.App.CreatePost(th.Context, &model.Post{
|
||||
UserId: th.BasicUser.Id,
|
||||
ChannelId: channelPrivate.Id,
|
||||
RootId: rootPostPrivateChannel.Id,
|
||||
Message: "reply post 1",
|
||||
}, channelPrivate, false, true)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
_, appErr = th.App.CreatePost(th.Context, &model.Post{
|
||||
UserId: th.BasicUser.Id,
|
||||
ChannelId: channelPrivate.Id,
|
||||
RootId: rootPostPrivateChannel.Id,
|
||||
Message: "reply post 2",
|
||||
}, channelPrivate, false, true)
|
||||
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// get top threads for team, as user 1 and user 2
|
||||
// user 1, 2 should see both threads
|
||||
|
||||
topTeamThreadsByUser1, _, _ := client.GetTopThreadsForTeamSince(th.BasicTeam.Id, model.TimeRangeToday, 0, 10)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, topTeamThreadsByUser1.Items, 2)
|
||||
require.Equal(t, topTeamThreadsByUser1.Items[0].Post.Id, rootPostPrivateChannel.Id)
|
||||
require.Equal(t, topTeamThreadsByUser1.Items[1].Post.Id, rootPostPublicChannel.Id)
|
||||
|
||||
client.Logout()
|
||||
|
||||
th.LoginBasic2()
|
||||
|
||||
client = th.Client
|
||||
|
||||
topTeamThreadsByUser2, _, _ := client.GetTopThreadsForTeamSince(th.BasicTeam.Id, model.TimeRangeToday, 0, 10)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, topTeamThreadsByUser2.Items, 1)
|
||||
require.Equal(t, topTeamThreadsByUser2.Items[0].Post.Id, rootPostPublicChannel.Id)
|
||||
|
||||
// add user2 to private channel and it can see 2 top threads.
|
||||
th.AddUserToChannel(th.BasicUser2, channelPrivate)
|
||||
topTeamThreadsByUser2IncludingPrivate, _, _ := client.GetTopThreadsForTeamSince(th.BasicTeam.Id, model.TimeRangeToday, 0, 10)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, topTeamThreadsByUser2IncludingPrivate.Items, 2)
|
||||
}
|
||||
|
||||
func TestGetTopThreadsForUserSince(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuProfessional))
|
||||
|
||||
th.ConfigStore.SetReadOnlyFF(false)
|
||||
defer th.ConfigStore.SetReadOnlyFF(true)
|
||||
th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.InsightsEnabled = true })
|
||||
|
||||
th.LoginBasic()
|
||||
client := th.Client
|
||||
|
||||
// create a public channel, a private channel
|
||||
|
||||
channelPublic := th.BasicChannel
|
||||
channelPrivate := th.BasicPrivateChannel
|
||||
th.App.AddUserToChannel(th.BasicUser, channelPublic, false)
|
||||
th.App.AddUserToChannel(th.BasicUser, channelPrivate, false)
|
||||
th.App.AddUserToChannel(th.BasicUser2, channelPublic, false)
|
||||
|
||||
// create two threads: one in public channel, one in private
|
||||
// post in public channel has both users interacting, post in private only has user1 interacting
|
||||
|
||||
rootPostPublicChannel, appErr := th.App.CreatePost(th.Context, &model.Post{
|
||||
UserId: th.BasicUser.Id,
|
||||
ChannelId: channelPublic.Id,
|
||||
Message: "root post pub",
|
||||
}, channelPublic, false, true)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
_, appErr = th.App.CreatePost(th.Context, &model.Post{
|
||||
UserId: th.BasicUser2.Id,
|
||||
ChannelId: channelPublic.Id,
|
||||
RootId: rootPostPublicChannel.Id,
|
||||
Message: "reply post 1",
|
||||
}, channelPublic, false, true)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
rootPostPrivateChannel, appErr := th.App.CreatePost(th.Context, &model.Post{
|
||||
UserId: th.BasicUser.Id,
|
||||
ChannelId: channelPrivate.Id,
|
||||
Message: "root post priv",
|
||||
}, channelPrivate, false, true)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
_, appErr = th.App.CreatePost(th.Context, &model.Post{
|
||||
UserId: th.BasicUser.Id,
|
||||
ChannelId: channelPrivate.Id,
|
||||
RootId: rootPostPrivateChannel.Id,
|
||||
Message: "reply post 1",
|
||||
}, channelPrivate, false, true)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
_, appErr = th.App.CreatePost(th.Context, &model.Post{
|
||||
UserId: th.BasicUser.Id,
|
||||
ChannelId: channelPrivate.Id,
|
||||
RootId: rootPostPrivateChannel.Id,
|
||||
Message: "reply post 2",
|
||||
}, channelPrivate, false, true)
|
||||
|
||||
require.Nil(t, appErr)
|
||||
|
||||
// get top threads for user, as user 1 and user 2
|
||||
// user 1 should see both threads, while user 2 should see only thread in public channel
|
||||
// (even if user2 is in the private channel it hasn't interacted with the thread there.)
|
||||
|
||||
topUser1Threads, _, _ := client.GetTopThreadsForUserSince(th.BasicTeam.Id, model.TimeRangeToday, 0, 10)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, topUser1Threads.Items, 2)
|
||||
require.Equal(t, topUser1Threads.Items[0].Post.Id, rootPostPrivateChannel.Id)
|
||||
require.Equal(t, topUser1Threads.Items[0].Post.ReplyCount, int64(2))
|
||||
require.Equal(t, topUser1Threads.Items[1].Post.Id, rootPostPublicChannel.Id)
|
||||
require.Contains(t, topUser1Threads.Items[1].Participants, th.BasicUser2.Id)
|
||||
require.Equal(t, topUser1Threads.Items[1].Post.ReplyCount, int64(1))
|
||||
|
||||
client.Logout()
|
||||
|
||||
th.LoginBasic2()
|
||||
|
||||
client = th.Client
|
||||
|
||||
topUser2Threads, _, _ := client.GetTopThreadsForUserSince(th.BasicTeam.Id, model.TimeRangeToday, 0, 10)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, topUser2Threads.Items, 1)
|
||||
require.Equal(t, topUser2Threads.Items[0].Post.Id, rootPostPublicChannel.Id)
|
||||
require.Equal(t, topUser2Threads.Items[0].Post.ReplyCount, int64(1))
|
||||
|
||||
// deleting the root post results in the thread not making it to top threads list
|
||||
_, appErr = th.App.DeletePost(rootPostPublicChannel.Id, th.BasicUser.Id)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
client.Logout()
|
||||
|
||||
th.LoginBasic()
|
||||
|
||||
client = th.Client
|
||||
|
||||
topUser1ThreadsAfterPost1Delete, _, _ := client.GetTopThreadsForUserSince(th.BasicTeam.Id, model.TimeRangeToday, 0, 10)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, topUser1ThreadsAfterPost1Delete.Items, 1)
|
||||
|
||||
client.Logout()
|
||||
|
||||
th.LoginBasic2()
|
||||
|
||||
client = th.Client
|
||||
|
||||
// reply with user2 in thread2. deleting that reply, shouldn't give any top thread for user2 if the user2 unsubscribes to the thread after deleting the comment
|
||||
replyPostUser2InPrivate, appErr := th.App.CreatePost(th.Context, &model.Post{
|
||||
UserId: th.BasicUser2.Id,
|
||||
ChannelId: channelPrivate.Id,
|
||||
RootId: rootPostPrivateChannel.Id,
|
||||
Message: "reply post 3",
|
||||
}, channelPrivate, false, true)
|
||||
require.Nil(t, appErr)
|
||||
|
||||
topUser2ThreadsAfterPrivateReply, _, _ := client.GetTopThreadsForUserSince(th.BasicTeam.Id, model.TimeRangeToday, 0, 10)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, topUser2ThreadsAfterPrivateReply.Items, 1)
|
||||
|
||||
// deleting reply, and unfollowing thread
|
||||
_, appErr = th.App.DeletePost(replyPostUser2InPrivate.Id, th.BasicUser2.Id)
|
||||
require.Nil(t, appErr)
|
||||
// unfollow thread
|
||||
_, err := th.App.Srv().Store.Thread().MaintainMembership(th.BasicUser2.Id, rootPostPrivateChannel.Id, store.ThreadMembershipOpts{
|
||||
Following: false,
|
||||
UpdateFollowing: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
topUser2ThreadsAfterPrivateReplyDelete, _, _ := client.GetTopThreadsForUserSince(th.BasicTeam.Id, model.TimeRangeToday, 0, 10)
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, topUser2ThreadsAfterPrivateReplyDelete.Items, 0)
|
||||
}
|
||||
|
||||
Ссылка в новой задаче
Block a user