diff options
author | Lunny Xiao <xiaolunwen@gmail.com> | 2022-03-31 17:20:39 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2022-03-31 17:20:39 +0800 |
commit | d4f84f1c937e24e71aa5f05c58d440cde741450f (patch) | |
tree | 18571ad08fabe6d1090ea4d2674beab3b1c00a6e /models/issues | |
parent | 43332a483f7838df66e0209eb9c15d4aba3d5874 (diff) | |
download | gitea-d4f84f1c937e24e71aa5f05c58d440cde741450f.tar.gz gitea-d4f84f1c937e24e71aa5f05c58d440cde741450f.zip |
Move reaction to models/issues/ (#19264)
* Move reaction to models/issues/
* Fix test
* move the function
* improve code
* Update models/issues/reaction.go
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Diffstat (limited to 'models/issues')
-rw-r--r-- | models/issues/main_test.go | 12 | ||||
-rw-r--r-- | models/issues/reaction.go | 377 | ||||
-rw-r--r-- | models/issues/reaction_test.go | 174 |
3 files changed, 562 insertions, 1 deletions
diff --git a/models/issues/main_test.go b/models/issues/main_test.go index af71f038d6..1c786d005f 100644 --- a/models/issues/main_test.go +++ b/models/issues/main_test.go @@ -9,8 +9,18 @@ import ( "testing" "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/setting" ) +func init() { + setting.SetCustomPathAndConf("", "", "") + setting.LoadForTest() +} + func TestMain(m *testing.M) { - unittest.MainTest(m, filepath.Join("..", ".."), "") + unittest.MainTest(m, filepath.Join("..", ".."), + "reaction.yml", + "user.yml", + "repository.yml", + ) } diff --git a/models/issues/reaction.go b/models/issues/reaction.go new file mode 100644 index 0000000000..87d6ff4310 --- /dev/null +++ b/models/issues/reaction.go @@ -0,0 +1,377 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package issues + +import ( + "bytes" + "context" + "fmt" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" + + "xorm.io/builder" +) + +// ErrForbiddenIssueReaction is used when a forbidden reaction was try to created +type ErrForbiddenIssueReaction struct { + Reaction string +} + +// IsErrForbiddenIssueReaction checks if an error is a ErrForbiddenIssueReaction. +func IsErrForbiddenIssueReaction(err error) bool { + _, ok := err.(ErrForbiddenIssueReaction) + return ok +} + +func (err ErrForbiddenIssueReaction) Error() string { + return fmt.Sprintf("'%s' is not an allowed reaction", err.Reaction) +} + +// ErrReactionAlreadyExist is used when a existing reaction was try to created +type ErrReactionAlreadyExist struct { + Reaction string +} + +// IsErrReactionAlreadyExist checks if an error is a ErrReactionAlreadyExist. +func IsErrReactionAlreadyExist(err error) bool { + _, ok := err.(ErrReactionAlreadyExist) + return ok +} + +func (err ErrReactionAlreadyExist) Error() string { + return fmt.Sprintf("reaction '%s' already exists", err.Reaction) +} + +// Reaction represents a reactions on issues and comments. +type Reaction struct { + ID int64 `xorm:"pk autoincr"` + Type string `xorm:"INDEX UNIQUE(s) NOT NULL"` + IssueID int64 `xorm:"INDEX UNIQUE(s) NOT NULL"` + CommentID int64 `xorm:"INDEX UNIQUE(s)"` + UserID int64 `xorm:"INDEX UNIQUE(s) NOT NULL"` + OriginalAuthorID int64 `xorm:"INDEX UNIQUE(s) NOT NULL DEFAULT(0)"` + OriginalAuthor string `xorm:"INDEX UNIQUE(s)"` + User *user_model.User `xorm:"-"` + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` +} + +// LoadUser load user of reaction +func (r *Reaction) LoadUser() (*user_model.User, error) { + if r.User != nil { + return r.User, nil + } + user, err := user_model.GetUserByIDCtx(db.DefaultContext, r.UserID) + if err != nil { + return nil, err + } + r.User = user + return user, nil +} + +// RemapExternalUser ExternalUserRemappable interface +func (r *Reaction) RemapExternalUser(externalName string, externalID, userID int64) error { + r.OriginalAuthor = externalName + r.OriginalAuthorID = externalID + r.UserID = userID + return nil +} + +// GetUserID ExternalUserRemappable interface +func (r *Reaction) GetUserID() int64 { return r.UserID } + +// GetExternalName ExternalUserRemappable interface +func (r *Reaction) GetExternalName() string { return r.OriginalAuthor } + +// GetExternalID ExternalUserRemappable interface +func (r *Reaction) GetExternalID() int64 { return r.OriginalAuthorID } + +func init() { + db.RegisterModel(new(Reaction)) +} + +// FindReactionsOptions describes the conditions to Find reactions +type FindReactionsOptions struct { + db.ListOptions + IssueID int64 + CommentID int64 + UserID int64 + Reaction string +} + +func (opts *FindReactionsOptions) toConds() builder.Cond { + // If Issue ID is set add to Query + cond := builder.NewCond() + if opts.IssueID > 0 { + cond = cond.And(builder.Eq{"reaction.issue_id": opts.IssueID}) + } + // If CommentID is > 0 add to Query + // If it is 0 Query ignore CommentID to select + // If it is -1 it explicit search of Issue Reactions where CommentID = 0 + if opts.CommentID > 0 { + cond = cond.And(builder.Eq{"reaction.comment_id": opts.CommentID}) + } else if opts.CommentID == -1 { + cond = cond.And(builder.Eq{"reaction.comment_id": 0}) + } + if opts.UserID > 0 { + cond = cond.And(builder.Eq{ + "reaction.user_id": opts.UserID, + "reaction.original_author_id": 0, + }) + } + if opts.Reaction != "" { + cond = cond.And(builder.Eq{"reaction.type": opts.Reaction}) + } + + return cond +} + +// FindCommentReactions returns a ReactionList of all reactions from an comment +func FindCommentReactions(issueID, commentID int64) (ReactionList, int64, error) { + return FindReactions(db.DefaultContext, FindReactionsOptions{ + IssueID: issueID, + CommentID: commentID, + }) +} + +// FindIssueReactions returns a ReactionList of all reactions from an issue +func FindIssueReactions(issueID int64, listOptions db.ListOptions) (ReactionList, int64, error) { + return FindReactions(db.DefaultContext, FindReactionsOptions{ + ListOptions: listOptions, + IssueID: issueID, + CommentID: -1, + }) +} + +// FindReactions returns a ReactionList of all reactions from an issue or a comment +func FindReactions(ctx context.Context, opts FindReactionsOptions) (ReactionList, int64, error) { + sess := db.GetEngine(ctx). + Where(opts.toConds()). + In("reaction.`type`", setting.UI.Reactions). + Asc("reaction.issue_id", "reaction.comment_id", "reaction.created_unix", "reaction.id") + if opts.Page != 0 { + sess = db.SetSessionPagination(sess, &opts) + + reactions := make([]*Reaction, 0, opts.PageSize) + count, err := sess.FindAndCount(&reactions) + return reactions, count, err + } + + reactions := make([]*Reaction, 0, 10) + count, err := sess.FindAndCount(&reactions) + return reactions, count, err +} + +func createReaction(ctx context.Context, opts *ReactionOptions) (*Reaction, error) { + reaction := &Reaction{ + Type: opts.Type, + UserID: opts.DoerID, + IssueID: opts.IssueID, + CommentID: opts.CommentID, + } + findOpts := FindReactionsOptions{ + IssueID: opts.IssueID, + CommentID: opts.CommentID, + Reaction: opts.Type, + UserID: opts.DoerID, + } + + existingR, _, err := FindReactions(ctx, findOpts) + if err != nil { + return nil, err + } + if len(existingR) > 0 { + return existingR[0], ErrReactionAlreadyExist{Reaction: opts.Type} + } + + if err := db.Insert(ctx, reaction); err != nil { + return nil, err + } + + return reaction, nil +} + +// ReactionOptions defines options for creating or deleting reactions +type ReactionOptions struct { + Type string + DoerID int64 + IssueID int64 + CommentID int64 +} + +// CreateReaction creates reaction for issue or comment. +func CreateReaction(opts *ReactionOptions) (*Reaction, error) { + if !setting.UI.ReactionsMap[opts.Type] { + return nil, ErrForbiddenIssueReaction{opts.Type} + } + + ctx, committer, err := db.TxContext() + if err != nil { + return nil, err + } + defer committer.Close() + + reaction, err := createReaction(ctx, opts) + if err != nil { + return reaction, err + } + + if err := committer.Commit(); err != nil { + return nil, err + } + return reaction, nil +} + +// CreateIssueReaction creates a reaction on issue. +func CreateIssueReaction(doerID, issueID int64, content string) (*Reaction, error) { + return CreateReaction(&ReactionOptions{ + Type: content, + DoerID: doerID, + IssueID: issueID, + }) +} + +// CreateCommentReaction creates a reaction on comment. +func CreateCommentReaction(doerID, issueID, commentID int64, content string) (*Reaction, error) { + return CreateReaction(&ReactionOptions{ + Type: content, + DoerID: doerID, + IssueID: issueID, + CommentID: commentID, + }) +} + +// DeleteReaction deletes reaction for issue or comment. +func DeleteReaction(ctx context.Context, opts *ReactionOptions) error { + reaction := &Reaction{ + Type: opts.Type, + UserID: opts.DoerID, + IssueID: opts.IssueID, + CommentID: opts.CommentID, + } + + _, err := db.GetEngine(ctx).Where("original_author_id = 0").Delete(reaction) + return err +} + +// DeleteIssueReaction deletes a reaction on issue. +func DeleteIssueReaction(doerID, issueID int64, content string) error { + return DeleteReaction(db.DefaultContext, &ReactionOptions{ + Type: content, + DoerID: doerID, + IssueID: issueID, + }) +} + +// DeleteCommentReaction deletes a reaction on comment. +func DeleteCommentReaction(doerID, issueID, commentID int64, content string) error { + return DeleteReaction(db.DefaultContext, &ReactionOptions{ + Type: content, + DoerID: doerID, + IssueID: issueID, + CommentID: commentID, + }) +} + +// ReactionList represents list of reactions +type ReactionList []*Reaction + +// HasUser check if user has reacted +func (list ReactionList) HasUser(userID int64) bool { + if userID == 0 { + return false + } + for _, reaction := range list { + if reaction.OriginalAuthor == "" && reaction.UserID == userID { + return true + } + } + return false +} + +// GroupByType returns reactions grouped by type +func (list ReactionList) GroupByType() map[string]ReactionList { + reactions := make(map[string]ReactionList) + for _, reaction := range list { + reactions[reaction.Type] = append(reactions[reaction.Type], reaction) + } + return reactions +} + +func (list ReactionList) getUserIDs() []int64 { + userIDs := make(map[int64]struct{}, len(list)) + for _, reaction := range list { + if reaction.OriginalAuthor != "" { + continue + } + if _, ok := userIDs[reaction.UserID]; !ok { + userIDs[reaction.UserID] = struct{}{} + } + } + return container.KeysInt64(userIDs) +} + +func valuesUser(m map[int64]*user_model.User) []*user_model.User { + values := make([]*user_model.User, 0, len(m)) + for _, v := range m { + values = append(values, v) + } + return values +} + +// LoadUsers loads reactions' all users +func (list ReactionList) LoadUsers(ctx context.Context, repo *repo_model.Repository) ([]*user_model.User, error) { + if len(list) == 0 { + return nil, nil + } + + userIDs := list.getUserIDs() + userMaps := make(map[int64]*user_model.User, len(userIDs)) + err := db.GetEngine(ctx). + In("id", userIDs). + Find(&userMaps) + if err != nil { + return nil, fmt.Errorf("find user: %v", err) + } + + for _, reaction := range list { + if reaction.OriginalAuthor != "" { + reaction.User = user_model.NewReplaceUser(fmt.Sprintf("%s(%s)", reaction.OriginalAuthor, repo.OriginalServiceType.Name())) + } else if user, ok := userMaps[reaction.UserID]; ok { + reaction.User = user + } else { + reaction.User = user_model.NewGhostUser() + } + } + return valuesUser(userMaps), nil +} + +// GetFirstUsers returns first reacted user display names separated by comma +func (list ReactionList) GetFirstUsers() string { + var buffer bytes.Buffer + rem := setting.UI.ReactionMaxUserNum + for _, reaction := range list { + if buffer.Len() > 0 { + buffer.WriteString(", ") + } + buffer.WriteString(reaction.User.DisplayName()) + if rem--; rem == 0 { + break + } + } + return buffer.String() +} + +// GetMoreUserCount returns count of not shown users in reaction tooltip +func (list ReactionList) GetMoreUserCount() int { + if len(list) <= setting.UI.ReactionMaxUserNum { + return 0 + } + return len(list) - setting.UI.ReactionMaxUserNum +} diff --git a/models/issues/reaction_test.go b/models/issues/reaction_test.go new file mode 100644 index 0000000000..b1216a3a69 --- /dev/null +++ b/models/issues/reaction_test.go @@ -0,0 +1,174 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package issues + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + + "github.com/stretchr/testify/assert" +) + +func addReaction(t *testing.T, doerID, issueID, commentID int64, content string) { + var reaction *Reaction + var err error + if commentID == 0 { + reaction, err = CreateIssueReaction(doerID, issueID, content) + } else { + reaction, err = CreateCommentReaction(doerID, issueID, commentID, content) + } + assert.NoError(t, err) + assert.NotNil(t, reaction) +} + +func TestIssueAddReaction(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}).(*user_model.User) + + var issue1ID int64 = 1 + + addReaction(t, user1.ID, issue1ID, 0, "heart") + + unittest.AssertExistsAndLoadBean(t, &Reaction{Type: "heart", UserID: user1.ID, IssueID: issue1ID}) +} + +func TestIssueAddDuplicateReaction(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}).(*user_model.User) + + var issue1ID int64 = 1 + + addReaction(t, user1.ID, issue1ID, 0, "heart") + + reaction, err := CreateReaction(&ReactionOptions{ + DoerID: user1.ID, + IssueID: issue1ID, + Type: "heart", + }) + assert.Error(t, err) + assert.Equal(t, ErrReactionAlreadyExist{Reaction: "heart"}, err) + + existingR := unittest.AssertExistsAndLoadBean(t, &Reaction{Type: "heart", UserID: user1.ID, IssueID: issue1ID}).(*Reaction) + assert.Equal(t, existingR.ID, reaction.ID) +} + +func TestIssueDeleteReaction(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}).(*user_model.User) + + var issue1ID int64 = 1 + + addReaction(t, user1.ID, issue1ID, 0, "heart") + + err := DeleteIssueReaction(user1.ID, issue1ID, "heart") + assert.NoError(t, err) + + unittest.AssertNotExistsBean(t, &Reaction{Type: "heart", UserID: user1.ID, IssueID: issue1ID}) +} + +func TestIssueReactionCount(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + setting.UI.ReactionMaxUserNum = 2 + + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}).(*user_model.User) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}).(*user_model.User) + user3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}).(*user_model.User) + user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}).(*user_model.User) + ghost := user_model.NewGhostUser() + + var issueID int64 = 2 + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}).(*repo_model.Repository) + + addReaction(t, user1.ID, issueID, 0, "heart") + addReaction(t, user2.ID, issueID, 0, "heart") + addReaction(t, user3.ID, issueID, 0, "heart") + addReaction(t, user3.ID, issueID, 0, "+1") + addReaction(t, user4.ID, issueID, 0, "+1") + addReaction(t, user4.ID, issueID, 0, "heart") + addReaction(t, ghost.ID, issueID, 0, "-1") + + reactionsList, _, err := FindReactions(db.DefaultContext, FindReactionsOptions{ + IssueID: issueID, + }) + assert.NoError(t, err) + assert.Len(t, reactionsList, 7) + _, err = reactionsList.LoadUsers(db.DefaultContext, repo) + assert.NoError(t, err) + + reactions := reactionsList.GroupByType() + assert.Len(t, reactions["heart"], 4) + assert.Equal(t, 2, reactions["heart"].GetMoreUserCount()) + assert.Equal(t, user1.DisplayName()+", "+user2.DisplayName(), reactions["heart"].GetFirstUsers()) + assert.True(t, reactions["heart"].HasUser(1)) + assert.False(t, reactions["heart"].HasUser(5)) + assert.False(t, reactions["heart"].HasUser(0)) + assert.Len(t, reactions["+1"], 2) + assert.Equal(t, 0, reactions["+1"].GetMoreUserCount()) + assert.Len(t, reactions["-1"], 1) +} + +func TestIssueCommentAddReaction(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}).(*user_model.User) + + var issue1ID int64 = 1 + var comment1ID int64 = 1 + + addReaction(t, user1.ID, issue1ID, comment1ID, "heart") + + unittest.AssertExistsAndLoadBean(t, &Reaction{Type: "heart", UserID: user1.ID, IssueID: issue1ID, CommentID: comment1ID}) +} + +func TestIssueCommentDeleteReaction(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}).(*user_model.User) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}).(*user_model.User) + user3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}).(*user_model.User) + user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}).(*user_model.User) + + var issue1ID int64 = 1 + var comment1ID int64 = 1 + + addReaction(t, user1.ID, issue1ID, comment1ID, "heart") + addReaction(t, user2.ID, issue1ID, comment1ID, "heart") + addReaction(t, user3.ID, issue1ID, comment1ID, "heart") + addReaction(t, user4.ID, issue1ID, comment1ID, "+1") + + reactionsList, _, err := FindReactions(db.DefaultContext, FindReactionsOptions{ + IssueID: issue1ID, + CommentID: comment1ID, + }) + assert.NoError(t, err) + assert.Len(t, reactionsList, 4) + + reactions := reactionsList.GroupByType() + assert.Len(t, reactions["heart"], 3) + assert.Len(t, reactions["+1"], 1) +} + +func TestIssueCommentReactionCount(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}).(*user_model.User) + + var issue1ID int64 = 1 + var comment1ID int64 = 1 + + addReaction(t, user1.ID, issue1ID, comment1ID, "heart") + assert.NoError(t, DeleteCommentReaction(user1.ID, issue1ID, comment1ID, "heart")) + + unittest.AssertNotExistsBean(t, &Reaction{Type: "heart", UserID: user1.ID, IssueID: issue1ID, CommentID: comment1ID}) +} |