Skip to content

Manage User Badges in the UI #31262

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 15 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 159 additions & 14 deletions models/user/badge.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@ package user
import (
"context"
"fmt"
"strings"

"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/util"

"xorm.io/builder"
)

// Badge represents a user badge
Expand All @@ -25,6 +29,50 @@ type UserBadge struct { //nolint:revive
UserID int64 `xorm:"INDEX"`
}

// ErrBadgeAlreadyExist represents a "badge already exists" error.
type ErrBadgeAlreadyExist struct {
Slug string
}

// IsErrBadgeAlreadyExist checks if an error is a ErrBadgeAlreadyExist.
func IsErrBadgeAlreadyExist(err error) bool {
_, ok := err.(ErrBadgeAlreadyExist)
return ok
}

func (err ErrBadgeAlreadyExist) Error() string {
return fmt.Sprintf("badge already exists [slug: %s]", err.Slug)
}

// Unwrap unwraps this error as a ErrExist error
func (err ErrBadgeAlreadyExist) Unwrap() error {
return util.ErrAlreadyExist
}

// ErrBadgeNotExist represents a "BadgeNotExist" kind of error.
type ErrBadgeNotExist struct {
Slug string
ID int64
}

func (err ErrBadgeNotExist) Error() string {
if err.ID > 0 {
return fmt.Sprintf("badge does not exist [id: %d]", err.ID)
}
return fmt.Sprintf("badge does not exist [slug: %s]", err.Slug)
}

// IsErrBadgeNotExist checks if an error is a ErrBadgeNotExist.
func IsErrBadgeNotExist(err error) bool {
_, ok := err.(ErrBadgeNotExist)
return ok
}

// Unwrap unwraps this error as a ErrNotExist error
func (err ErrBadgeNotExist) Unwrap() error {
return util.ErrNotExist
}

func init() {
db.RegisterModel(new(Badge))
db.RegisterModel(new(UserBadge))
Expand All @@ -42,13 +90,37 @@ func GetUserBadges(ctx context.Context, u *User) ([]*Badge, int64, error) {
return badges, count, err
}

// GetBadgeUsersOptions contains options for getting users with a specific badge
type GetBadgeUsersOptions struct {
db.ListOptions
Badge *Badge
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not clear to use a slug here. Why not directly use a BadgeSlug string here?

}

// 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.Badge.Slug)

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 {
// this will fail if the badge already exists due to the UNIQUE constraint
_, err := db.GetEngine(ctx).Insert(badge)
return err
}

// GetBadge returns a badge
// 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)
Expand All @@ -60,14 +132,26 @@ func GetBadge(ctx context.Context, slug string) (*Badge, error) {

// 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 = (SELECT id FROM badge WHERE slug = ?)", badge.Slug).
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.
Expand All @@ -84,7 +168,7 @@ 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 ErrBadgeNotExist{Slug: badge.Slug}
}
if err := db.Insert(ctx, &UserBadge{
BadgeID: badge.ID,
Expand All @@ -102,16 +186,24 @@ 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 {
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
}
slugs := make([]string, len(badges))
for i, badge := range badges {
slugs[i] = badge.Slug
}

var badgeIDs []int64
if err := db.GetEngine(ctx).Table("badge").In("slug", slugs).Cols("id").Find(&badgeIDs); err != nil {
return err
}

if _, err := db.GetEngine(ctx).
Where("user_id = ?", u.ID).
In("badge_id", badgeIDs).
Delete(&UserBadge{}); err != nil {
return err
}
return nil
})
Expand All @@ -122,3 +214,56 @@ 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 fdin badges
type SearchBadgeOptions struct {
db.ListOptions

Keyword string
Slug string
ID int64
OrderBy db.SearchOrderBy
Actor *User // The user doing the search
}

func (opts *SearchBadgeOptions) ToConds() builder.Cond {
cond := builder.NewCond()

if opts.Keyword != "" {
lowerKeyword := strings.ToLower(opts.Keyword)
keywordCond := builder.Or(
builder.Like{"badge.slug", lowerKeyword},
builder.Like{"badge.description", lowerKeyword},
builder.Like{"badge.id", lowerKeyword},
)
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
}

// 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, ErrBadgeNotExist{ID: id}
}
return badge, nil
}
61 changes: 61 additions & 0 deletions models/user/badge_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Copyright 2025 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"

"github.com/stretchr/testify/assert"
)

func TestGetBadgeUsers(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())

// Create a test badge
badge := &user_model.Badge{
Slug: "test-badge",
Description: "Test Badge",
ImageURL: "test.png",
}
assert.NoError(t, user_model.CreateBadge(db.DefaultContext, 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(db.DefaultContext, user1, badge))
assert.NoError(t, user_model.AddUserBadge(db.DefaultContext, user2, badge))

// Test getting users with pagination
opts := &user_model.GetBadgeUsersOptions{
Badge: badge,
ListOptions: db.ListOptions{
Page: 1,
PageSize: 1,
},
}

users, count, err := user_model.GetBadgeUsers(db.DefaultContext, 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(db.DefaultContext, opts)
assert.NoError(t, err)
assert.EqualValues(t, 2, count)
assert.Len(t, users, 1)

// Test with non-existent badge
opts.Badge = &user_model.Badge{Slug: "non-existent"}
users, count, err = user_model.GetBadgeUsers(db.DefaultContext, opts)
assert.NoError(t, err)
assert.EqualValues(t, 0, count)
assert.Empty(t, users)
}
19 changes: 19 additions & 0 deletions modules/validation/binding.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ const (
ErrUsername = "UsernameError"
// ErrInvalidGroupTeamMap is returned when a group team mapping is invalid
ErrInvalidGroupTeamMap = "InvalidGroupTeamMap"
// ErrInvalidSlug is returned when a slug is invalid
ErrInvalidSlug = "InvalidSlug"
)

// AddBindingRules adds additional binding rules
Expand All @@ -40,6 +42,7 @@ func AddBindingRules() {
addGlobOrRegexPatternRule()
addUsernamePatternRule()
addValidGroupTeamMapRule()
addSlugPatternRule()
}

func addGitRefNameBindingRule() {
Expand Down Expand Up @@ -123,6 +126,22 @@ func addValidSiteURLBindingRule() {
})
}

func addSlugPatternRule() {
binding.AddRule(&binding.Rule{
IsMatch: func(rule string) bool {
return rule == "Slug"
},
IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) {
str := fmt.Sprintf("%v", val)
if !IsValidSlug(str) {
errs.Add([]string{name}, ErrInvalidSlug, "invalid slug")
return false, errs
}
return true, errs
},
})
}

func addGlobPatternRule() {
binding.AddRule(&binding.Rule{
IsMatch: func(rule string) bool {
Expand Down
4 changes: 4 additions & 0 deletions modules/validation/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,7 @@ func IsValidUsername(name string) bool {
vars := globalVars()
return vars.validUsernamePattern.MatchString(name) && !vars.invalidUsernamePattern.MatchString(name)
}

func IsValidSlug(slug string) bool {
return IsValidUsername(slug)
}
2 changes: 2 additions & 0 deletions modules/web/middleware/binding.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ func Validate(errs binding.Errors, data map[string]any, f Form, l translation.Lo
data["ErrorMsg"] = trName + l.TrString("form.username_error")
case validation.ErrInvalidGroupTeamMap:
data["ErrorMsg"] = trName + l.TrString("form.invalid_group_team_map_error", errs[0].Message)
case validation.ErrInvalidSlug:
data["ErrorMsg"] = l.TrString("form.invalid_slug_error")
default:
msg := errs[0].Classification
if msg != "" && errs[0].Message != "" {
Expand Down
Loading