Add user badges (#36752)

Implemented #29798

This feature implements list badges, create new badges, view badge, edit
badge and assign badge to users.

- List all badges
![(screenshot)](https://github.com/user-attachments/assets/9dbf243e-c704-49f8-915a-73704e226da9)
- Create new badges
![(screenshot)](https://github.com/user-attachments/assets/8a3fff7e-fe6f-49b0-a7c5-bbba34478019)
- View badge
![(screenshot)](https://github.com/user-attachments/assets/dd7a882b-6e2c-47d2-93e0-05a2698a41e5)
![(screenshot)](https://private-user-images.githubusercontent.com/75789103/558982759-53536300-e189-406b-8b0e-824e1a768b92.png?jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NzQxOTMyMjUsIm5iZiI6MTc3NDE5MjkyNSwicGF0aCI6Ii83NTc4OTEwMy81NTg5ODI3NTktNTM1MzYzMDAtZTE4OS00MDZiLThiMGUtODI0ZTFhNzY4YjkyLnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNjAzMjIlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjYwMzIyVDE1MjIwNVomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTUxNjQ5ZDUyMGVlNWRmODg1OGUyN2NiOWI3YTAxODhiMjRhM2U1OGQ1NWMwNjQ0MTBmNTRjNTBjYjIzN2ExMWEmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.4aAfpFaziiXDG7W2HaNJop0B62-NR4f0Ni9YNjTZq0M)
- Edit badge
![(screenshot)](https://github.com/user-attachments/assets/7124671a-ed97-4c98-ac7d-34863377fa62)
- Add user to badge
![(screenshot)](https://github.com/user-attachments/assets/3438b492-0197-4acb-b9f2-2f9f7c80582e)
This commit is contained in:
Nicolas
2026-03-22 16:49:45 +01:00
committed by GitHub
parent aa9aea2c6e
commit 4ba90207cf
22 changed files with 1195 additions and 35 deletions
+5
View File
@@ -0,0 +1,5 @@
-
id: 1
slug: badge1
description: just a test badge
image_url: badge1.png
+1
View File
@@ -403,6 +403,7 @@ func prepareMigrationTasks() []*migration {
newMigration(326, "Migrate commit status target URL to use run ID and job ID", v1_26.FixCommitStatusTargetURLToUseRunAndJobID),
newMigration(327, "Add disabled state to action runners", v1_26.AddDisabledToActionRunner),
newMigration(328, "Add TokenPermissions column to ActionRunJob", v1_26.AddTokenPermissionsToActionRunJob),
newMigration(329, "Add unique constraint for user badge", v1_26.AddUniqueIndexForUserBadge),
}
return preparedMigrations
}
+62
View File
@@ -0,0 +1,62 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_26
import (
"fmt"
"xorm.io/xorm"
"xorm.io/xorm/schemas"
)
type UserBadge struct { //revive:disable-line:exported
ID int64 `xorm:"pk autoincr"`
BadgeID int64
UserID int64
}
// TableIndices implements xorm's TableIndices interface
func (n *UserBadge) TableIndices() []*schemas.Index {
indices := make([]*schemas.Index, 0, 1)
ubUnique := schemas.NewIndex("unique_user_badge", schemas.UniqueType)
ubUnique.AddColumn("user_id", "badge_id")
indices = append(indices, ubUnique)
return indices
}
// AddUniqueIndexForUserBadge adds a compound unique indexes for user badge table
// and it replaces an old index on user_id
func AddUniqueIndexForUserBadge(x *xorm.Engine) error {
// remove possible duplicated records in table user_badge
type result struct {
UserID int64
BadgeID int64
Cnt int
}
var results []result
if err := x.Select("user_id, badge_id, count(*) as cnt").
Table("user_badge").
GroupBy("user_id, badge_id").
Having("count(*) > 1").
Find(&results); err != nil {
return err
}
for _, r := range results {
if x.Dialect().URI().DBType == schemas.MSSQL {
if _, err := x.Exec(fmt.Sprintf("delete from user_badge where id in (SELECT top %d id FROM user_badge WHERE user_id = ? and badge_id = ?)", r.Cnt-1), r.UserID, r.BadgeID); err != nil {
return err
}
} else {
var ids []int64
if err := x.SQL("SELECT id FROM user_badge WHERE user_id = ? and badge_id = ? limit ?", r.UserID, r.BadgeID, r.Cnt-1).Find(&ids); err != nil {
return err
}
if _, err := x.Table("user_badge").In("id", ids).Delete(); err != nil {
return err
}
}
}
return x.Sync(new(UserBadge))
}
+85
View File
@@ -0,0 +1,85 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_26
import (
"testing"
"code.gitea.io/gitea/models/migrations/base"
"github.com/stretchr/testify/assert"
)
type UserBadgeBefore struct {
ID int64 `xorm:"pk autoincr"`
BadgeID int64
UserID int64 `xorm:"INDEX"`
}
func (UserBadgeBefore) TableName() string {
return "user_badge"
}
func Test_AddUniqueIndexForUserBadge(t *testing.T) {
x, deferable := base.PrepareTestEnv(t, 0, new(UserBadgeBefore))
defer deferable()
if x == nil || t.Failed() {
return
}
testData := []*UserBadgeBefore{
{UserID: 1, BadgeID: 1},
{UserID: 1, BadgeID: 1}, // duplicate
{UserID: 2, BadgeID: 1},
{UserID: 1, BadgeID: 2},
{UserID: 3, BadgeID: 3},
{UserID: 3, BadgeID: 3}, // duplicate
}
for _, data := range testData {
_, err := x.Insert(data)
assert.NoError(t, err)
}
// check that we have duplicates
count, err := x.Where("user_id = ? AND badge_id = ?", 1, 1).Count(&UserBadgeBefore{})
assert.NoError(t, err)
assert.Equal(t, int64(2), count)
count, err = x.Where("user_id = ? AND badge_id = ?", 3, 3).Count(&UserBadgeBefore{})
assert.NoError(t, err)
assert.Equal(t, int64(2), count)
totalCount, err := x.Count(&UserBadgeBefore{})
assert.NoError(t, err)
assert.Equal(t, int64(6), totalCount)
// run the migration
if err := AddUniqueIndexForUserBadge(x); err != nil {
assert.NoError(t, err)
return
}
// verify the duplicates were removed
count, err = x.Where("user_id = ? AND badge_id = ?", 1, 1).Count(&UserBadgeBefore{})
assert.NoError(t, err)
assert.Equal(t, int64(1), count)
count, err = x.Where("user_id = ? AND badge_id = ?", 3, 3).Count(&UserBadgeBefore{})
assert.NoError(t, err)
assert.Equal(t, int64(1), count)
// check total count
totalCount, err = x.Count(&UserBadgeBefore{})
assert.NoError(t, err)
assert.Equal(t, int64(4), totalCount)
// fail to insert a duplicate
_, err = x.Insert(&UserBadge{UserID: 1, BadgeID: 1})
assert.Error(t, err)
// succeed adding a non-duplicate
_, err = x.Insert(&UserBadge{UserID: 4, BadgeID: 1})
assert.NoError(t, err)
}
+170 -21
View File
@@ -5,9 +5,12 @@ package user
import (
"context"
"fmt"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/util"
"xorm.io/builder"
"xorm.io/xorm/schemas"
)
// Badge represents a user badge
@@ -22,7 +25,16 @@ type Badge struct {
type UserBadge struct { //nolint:revive // export stutter
ID int64 `xorm:"pk autoincr"`
BadgeID int64
UserID int64 `xorm:"INDEX"`
UserID int64
}
// TableIndices implements xorm's TableIndices interface
func (n *UserBadge) TableIndices() []*schemas.Index {
indices := make([]*schemas.Index, 0, 1)
ubUnique := schemas.NewIndex("unique_user_badge", schemas.UniqueType)
ubUnique.AddColumn("user_id", "badge_id")
indices = append(indices, ubUnique)
return indices
}
func init() {
@@ -42,32 +54,85 @@ func GetUserBadges(ctx context.Context, u *User) ([]*Badge, int64, error) {
return badges, count, err
}
// CreateBadge creates a new badge.
func CreateBadge(ctx context.Context, badge *Badge) error {
_, err := db.GetEngine(ctx).Insert(badge)
return err
// GetBadgeUsersOptions contains options for getting users with a specific badge
type GetBadgeUsersOptions struct {
db.ListOptions
BadgeSlug string
}
// GetBadge returns a badge
// GetBadgeUsers returns the users that have a specific badge with pagination support.
func GetBadgeUsers(ctx context.Context, opts *GetBadgeUsersOptions) ([]*User, int64, error) {
sess := db.GetEngine(ctx).
Select("`user`.*").
Join("INNER", "user_badge", "`user_badge`.user_id=user.id").
Join("INNER", "badge", "`user_badge`.badge_id=badge.id").
Where("badge.slug=?", opts.BadgeSlug)
if opts.Page > 0 {
sess = db.SetSessionPagination(sess, opts)
}
users := make([]*User, 0, opts.PageSize)
count, err := sess.FindAndCount(&users)
return users, count, err
}
// CreateBadge creates a new badge.
func CreateBadge(ctx context.Context, badge *Badge) error {
exists, err := db.GetEngine(ctx).Where("slug = ?", badge.Slug).Exist(new(Badge))
if err != nil {
return err
}
if exists {
return util.NewAlreadyExistErrorf("badge already exists [slug: %s]", badge.Slug)
}
if _, err := db.GetEngine(ctx).Insert(badge); err != nil {
// Handle race between existence check and insert.
exists, existErr := db.GetEngine(ctx).Where("slug = ?", badge.Slug).Exist(new(Badge))
if existErr == nil && exists {
return util.NewAlreadyExistErrorf("badge already exists [slug: %s]", badge.Slug)
}
return err
}
return nil
}
// GetBadge returns a specific badge
func GetBadge(ctx context.Context, slug string) (*Badge, error) {
badge := new(Badge)
has, err := db.GetEngine(ctx).Where("slug=?", slug).Get(badge)
if !has {
if err != nil {
return nil, err
}
return badge, err
if !has {
return nil, util.NewNotExistErrorf("badge does not exist [slug: %s]", slug)
}
return badge, nil
}
// UpdateBadge updates a badge based on its slug.
func UpdateBadge(ctx context.Context, badge *Badge) error {
_, err := db.GetEngine(ctx).Where("slug=?", badge.Slug).Update(badge)
_, err := db.GetEngine(ctx).Where("slug=?", badge.Slug).Cols("description", "image_url").Update(badge)
return err
}
// DeleteBadge deletes a badge.
// DeleteBadge deletes a badge and all associated user_badge entries.
func DeleteBadge(ctx context.Context, badge *Badge) error {
_, err := db.GetEngine(ctx).Where("slug=?", badge.Slug).Delete(badge)
return err
return db.WithTx(ctx, func(ctx context.Context) error {
// First delete all user_badge entries for this badge
if _, err := db.GetEngine(ctx).
Where("badge_id = ?", badge.ID).
Delete(&UserBadge{}); err != nil {
return err
}
// Then delete the badge itself
if _, err := db.GetEngine(ctx).Where("slug=?", badge.Slug).Delete(badge); err != nil {
return err
}
return nil
})
}
// AddUserBadge adds a badge to a user.
@@ -84,12 +149,25 @@ func AddUserBadges(ctx context.Context, u *User, badges []*Badge) error {
if err != nil {
return err
} else if !has {
return fmt.Errorf("badge with slug %s doesn't exist", badge.Slug)
return util.NewNotExistErrorf("badge does not exist [slug: %s]", badge.Slug)
}
exists, err := db.GetEngine(ctx).Where("badge_id = ? AND user_id = ?", badge.ID, u.ID).Exist(new(UserBadge))
if err != nil {
return err
}
if exists {
return util.NewAlreadyExistErrorf("user badge already exists [user_id: %d, badge_id: %d]", u.ID, badge.ID)
}
if err := db.Insert(ctx, &UserBadge{
BadgeID: badge.ID,
UserID: u.ID,
}); err != nil {
exists, existErr := db.GetEngine(ctx).Where("badge_id = ? AND user_id = ?", badge.ID, u.ID).Exist(new(UserBadge))
if existErr == nil && exists {
return util.NewAlreadyExistErrorf("user badge already exists [user_id: %d, badge_id: %d]", u.ID, badge.ID)
}
return err
}
}
@@ -102,16 +180,33 @@ func RemoveUserBadge(ctx context.Context, u *User, badge *Badge) error {
return RemoveUserBadges(ctx, u, []*Badge{badge})
}
// RemoveUserBadges removes badges from a user.
// RemoveUserBadges removes specific badges from a user.
func RemoveUserBadges(ctx context.Context, u *User, badges []*Badge) error {
return db.WithTx(ctx, func(ctx context.Context) error {
if len(badges) == 0 {
return nil
}
badgeSlugs := make([]string, 0, len(badges))
for _, badge := range badges {
if _, err := db.GetEngine(ctx).
Join("INNER", "badge", "badge.id = `user_badge`.badge_id").
Where("`user_badge`.user_id=? AND `badge`.slug=?", u.ID, badge.Slug).
Delete(&UserBadge{}); err != nil {
return err
}
badgeSlugs = append(badgeSlugs, badge.Slug)
}
var userBadges []UserBadge
if err := db.GetEngine(ctx).Table("user_badge").
Join("INNER", "badge", "badge.id = `user_badge`.badge_id").
Where("`user_badge`.user_id = ?", u.ID).In("`badge`.slug", badgeSlugs).
Find(&userBadges); err != nil {
return err
}
userBadgeIDs := make([]int64, 0, len(userBadges))
for _, ub := range userBadges {
userBadgeIDs = append(userBadgeIDs, ub.ID)
}
if len(userBadgeIDs) == 0 {
return nil
}
if _, err := db.GetEngine(ctx).Table("user_badge").In("id", userBadgeIDs).Delete(); err != nil {
return err
}
return nil
})
@@ -122,3 +217,57 @@ func RemoveAllUserBadges(ctx context.Context, u *User) error {
_, err := db.GetEngine(ctx).Where("user_id=?", u.ID).Delete(&UserBadge{})
return err
}
// SearchBadgeOptions represents the options when finding badges
type SearchBadgeOptions struct {
db.ListOptions
Keyword string
Slug string
ID int64
OrderBy db.SearchOrderBy
}
func (opts *SearchBadgeOptions) ToConds() builder.Cond {
cond := builder.NewCond()
if opts.Keyword != "" {
keywordCond := builder.Or(
db.BuildCaseInsensitiveLike("badge.slug", opts.Keyword),
db.BuildCaseInsensitiveLike("badge.description", opts.Keyword),
)
cond = cond.And(keywordCond)
}
if opts.ID > 0 {
cond = cond.And(builder.Eq{"badge.id": opts.ID})
}
if len(opts.Slug) > 0 {
cond = cond.And(builder.Eq{"badge.slug": opts.Slug})
}
return cond
}
func (opts *SearchBadgeOptions) ToOrders() string {
return opts.OrderBy.String()
}
// SearchBadges returns badges based on the provided SearchBadgeOptions options
func SearchBadges(ctx context.Context, opts *SearchBadgeOptions) ([]*Badge, int64, error) {
return db.FindAndCount[Badge](ctx, opts)
}
// GetBadgeByID returns a specific badge by ID
func GetBadgeByID(ctx context.Context, id int64) (*Badge, error) {
badge := new(Badge)
has, err := db.GetEngine(ctx).ID(id).Get(badge)
if err != nil {
return nil, err
}
if !has {
return nil, util.NewNotExistErrorf("badge does not exist [id: %d]", id)
}
return badge, nil
}
+185
View File
@@ -0,0 +1,185 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package user_test
import (
"testing"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/util"
"github.com/stretchr/testify/assert"
)
func TestBadge(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
t.Run("GetBadgeNotExist", testGetBadgeNotExist)
t.Run("CreateBadgeAlreadyExists", testCreateBadgeAlreadyExists)
t.Run("GetBadgeUsers", testGetBadgeUsers)
t.Run("AddAndRemoveUserBadges", testAddAndRemoveUserBadges)
t.Run("SearchBadgesOrderingAndKeyword", testSearchBadgesOrderingAndKeyword)
}
func testGetBadgeNotExist(t *testing.T) {
badge, err := user_model.GetBadge(t.Context(), "does-not-exist")
assert.Nil(t, badge)
assert.Error(t, err)
assert.ErrorIs(t, err, util.ErrNotExist)
}
func testCreateBadgeAlreadyExists(t *testing.T) {
badge := &user_model.Badge{
Slug: "duplicate-badge-slug",
Description: "First",
}
assert.NoError(t, user_model.CreateBadge(t.Context(), badge))
err := user_model.CreateBadge(t.Context(), &user_model.Badge{
Slug: "duplicate-badge-slug",
Description: "Second",
})
assert.Error(t, err)
assert.ErrorIs(t, err, util.ErrAlreadyExist)
}
func testGetBadgeUsers(t *testing.T) {
// Create a test badge
badge := &user_model.Badge{
Slug: "test-badge-users",
Description: "Test Badge",
ImageURL: "test.png",
}
assert.NoError(t, user_model.CreateBadge(t.Context(), badge))
// Create test users and assign badges
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
assert.NoError(t, user_model.AddUserBadge(t.Context(), user1, badge))
assert.NoError(t, user_model.AddUserBadge(t.Context(), user2, badge))
defer func() {
assert.NoError(t, user_model.RemoveUserBadge(t.Context(), user1, badge))
assert.NoError(t, user_model.RemoveUserBadge(t.Context(), user2, badge))
}()
// Test getting users with pagination
opts := &user_model.GetBadgeUsersOptions{
BadgeSlug: badge.Slug,
ListOptions: db.ListOptions{
Page: 1,
PageSize: 1,
},
}
users, count, err := user_model.GetBadgeUsers(t.Context(), opts)
assert.NoError(t, err)
assert.EqualValues(t, 2, count)
assert.Len(t, users, 1)
// Test second page
opts.Page = 2
users, count, err = user_model.GetBadgeUsers(t.Context(), opts)
assert.NoError(t, err)
assert.EqualValues(t, 2, count)
assert.Len(t, users, 1)
// Test with non-existent badge
opts.BadgeSlug = "non-existent"
users, count, err = user_model.GetBadgeUsers(t.Context(), opts)
assert.NoError(t, err)
assert.EqualValues(t, 0, count)
assert.Empty(t, users)
}
func testAddAndRemoveUserBadges(t *testing.T) {
badge1 := unittest.AssertExistsAndLoadBean(t, &user_model.Badge{ID: 1})
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
// Add a badge to user and verify that it is returned in the list
assert.NoError(t, user_model.AddUserBadge(t.Context(), user1, badge1))
badges, count, err := user_model.GetUserBadges(t.Context(), user1)
assert.Equal(t, int64(1), count)
assert.Equal(t, badge1.Slug, badges[0].Slug)
assert.NoError(t, err)
// Confirm that it is impossible to duplicate the same badge
err = user_model.AddUserBadge(t.Context(), user1, badge1)
assert.Error(t, err)
assert.ErrorIs(t, err, util.ErrAlreadyExist)
// Nothing happened to the existing badge
badges, count, err = user_model.GetUserBadges(t.Context(), user1)
assert.Equal(t, int64(1), count)
assert.Equal(t, badge1.Slug, badges[0].Slug)
assert.NoError(t, err)
// Remove a badge from user and verify that it is no longer in the list
assert.NoError(t, user_model.RemoveUserBadge(t.Context(), user1, badge1))
_, count, err = user_model.GetUserBadges(t.Context(), user1)
assert.Equal(t, int64(0), count)
assert.NoError(t, err)
// Removing empty or missing badge selections should be a no-op.
assert.NoError(t, user_model.RemoveUserBadges(t.Context(), user1, nil))
assert.NoError(t, user_model.RemoveUserBadges(t.Context(), user1, []*user_model.Badge{{Slug: "does-not-exist"}}))
}
func testSearchBadgesOrderingAndKeyword(t *testing.T) {
createdBadges := []*user_model.Badge{
{Slug: "badge-sort-b", Description: "Badge Sort B"},
{Slug: "badge-sort-c", Description: "Badge Sort C"},
{Slug: "badge-sort-a", Description: "Badge Sort A"},
{Slug: "badge-sort-case", Description: "MiXeDCaSeKeyword"},
}
for _, badge := range createdBadges {
assert.NoError(t, user_model.CreateBadge(t.Context(), badge))
}
opts := &user_model.SearchBadgeOptions{
ListOptions: db.ListOptions{ListAll: true},
Keyword: "badge-sort-",
OrderBy: db.SearchOrderBy("`badge`.id ASC"),
}
oldestFirst, count, err := user_model.SearchBadges(t.Context(), opts)
assert.NoError(t, err)
assert.EqualValues(t, 4, count)
assert.Equal(t, []string{"badge-sort-b", "badge-sort-c", "badge-sort-a", "badge-sort-case"}, collectBadgeSlugs(oldestFirst))
opts.OrderBy = db.SearchOrderBy("`badge`.id DESC")
newestFirst, count, err := user_model.SearchBadges(t.Context(), opts)
assert.NoError(t, err)
assert.EqualValues(t, 4, count)
assert.Equal(t, []string{"badge-sort-case", "badge-sort-a", "badge-sort-c", "badge-sort-b"}, collectBadgeSlugs(newestFirst))
opts.OrderBy = db.SearchOrderBy("`badge`.slug ASC")
alpha, count, err := user_model.SearchBadges(t.Context(), opts)
assert.NoError(t, err)
assert.EqualValues(t, 4, count)
assert.Equal(t, []string{"badge-sort-a", "badge-sort-b", "badge-sort-c", "badge-sort-case"}, collectBadgeSlugs(alpha))
opts.OrderBy = db.SearchOrderBy("`badge`.slug DESC")
reverseAlpha, count, err := user_model.SearchBadges(t.Context(), opts)
assert.NoError(t, err)
assert.EqualValues(t, 4, count)
assert.Equal(t, []string{"badge-sort-case", "badge-sort-c", "badge-sort-b", "badge-sort-a"}, collectBadgeSlugs(reverseAlpha))
opts.Keyword = "mixedcasekeyword"
opts.OrderBy = db.SearchOrderBy("`badge`.slug ASC")
caseInsensitive, count, err := user_model.SearchBadges(t.Context(), opts)
assert.NoError(t, err)
assert.EqualValues(t, 1, count)
assert.Equal(t, []string{"badge-sort-case"}, collectBadgeSlugs(caseInsensitive))
}
func collectBadgeSlugs(badges []*user_model.Badge) []string {
slugs := make([]string, 0, len(badges))
for _, badge := range badges {
slugs = append(slugs, badge.Slug)
}
return slugs
}