diff --git a/models/badge.go b/models/badge.go new file mode 100644 index 000000000..fcfbdc27f --- /dev/null +++ b/models/badge.go @@ -0,0 +1,181 @@ +package models + +import ( + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" + "path/filepath" + "strings" + "xorm.io/builder" +) + +type Badge struct { + ID int64 `xorm:"pk autoincr"` + Name string + LightedIcon string `xorm:"varchar(2048)"` + GreyedIcon string `xorm:"varchar(2048)"` + Url string `xorm:"varchar(2048)"` + CategoryId int64 + CreatedUnix timeutil.TimeStamp `xorm:"created"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated"` + DeletedAt timeutil.TimeStamp `xorm:"deleted"` +} + +func (m *Badge) ToUserShow() *Badge4UserShow { + return &Badge4UserShow{ + Name: m.Name, + LightedIcon: GetIconOuterLink(m.LightedIcon), + GreyedIcon: GetIconOuterLink(m.GreyedIcon), + Url: m.Url, + } +} + +type GetBadgeOpts struct { + BadgeType BadgeType + CategoryId int64 + ListOpts ListOptions +} + +type BadgeAndCategory struct { + Badge Badge `xorm:"extends"` + Category BadgeCategory `xorm:"extends"` +} + +func (*BadgeAndCategory) TableName() string { + return "badge" +} + +func (m *BadgeAndCategory) ToShow() *Badge4AdminShow { + return &Badge4AdminShow{ + ID: m.Badge.ID, + Name: m.Badge.Name, + LightedIcon: GetIconOuterLink(m.Badge.LightedIcon), + GreyedIcon: GetIconOuterLink(m.Badge.GreyedIcon), + Url: m.Badge.Url, + CategoryName: m.Category.Name, + CategoryId: m.Category.ID, + CreatedUnix: m.Badge.CreatedUnix, + UpdatedUnix: m.Badge.UpdatedUnix, + } +} + +type Badge4AdminShow struct { + ID int64 + Name string + LightedIcon string + GreyedIcon string + Url string + CategoryName string + CategoryId int64 + CreatedUnix timeutil.TimeStamp + UpdatedUnix timeutil.TimeStamp +} + +func (m Badge4AdminShow) ToDTO() Badge { + return Badge{ + Name: m.Name, + LightedIcon: m.LightedIcon, + GreyedIcon: m.GreyedIcon, + Url: m.Url, + CategoryId: m.CategoryId, + } +} + +type BadgeOperateReq struct { + ID int64 + Name string + LightedIcon string + GreyedIcon string + Url string + CategoryId int64 +} + +func (m BadgeOperateReq) ToDTO() Badge { + return Badge{ + Name: m.Name, + LightedIcon: m.LightedIcon, + GreyedIcon: m.GreyedIcon, + Url: m.Url, + CategoryId: m.CategoryId, + } +} + +type Badge4UserShow struct { + Name string + LightedIcon string + GreyedIcon string + Url string +} + +type BadgeShowWithStatus struct { + Badge *Badge4UserShow + IsLighted bool +} + +type UserAllBadgeInCategory struct { + CategoryName string + CategoryId int64 + LightedNum int + Badges []*BadgeShowWithStatus +} + +func GetBadgeList(opts GetBadgeOpts) (int64, []*BadgeAndCategory, error) { + if opts.ListOpts.Page <= 0 { + opts.ListOpts.Page = 1 + } + var cond = builder.NewCond() + if opts.BadgeType > 0 { + cond = cond.And(builder.Eq{"badge_category.type": opts.BadgeType}) + } + if opts.CategoryId > 0 { + cond = cond.And(builder.Eq{"badge_category.id": opts.CategoryId}) + } + n, err := x.Join("INNER", "badge_category", "badge_category.ID = badge.category_id").Where(cond).Count(&BadgeAndCategory{}) + if err != nil { + return 0, nil, err + } + r := make([]*BadgeAndCategory, 0) + if err = x.Join("INNER", "badge_category", "badge_category.ID = badge.category_id").Where(cond).OrderBy("badge.created_unix desc").Limit(opts.ListOpts.PageSize, (opts.ListOpts.Page-1)*opts.ListOpts.PageSize).Find(&r); err != nil { + return 0, nil, err + } + return n, r, nil +} + +func AddBadge(m Badge) (int64, error) { + return x.Insert(&m) +} + +func UpdateBadgeById(id int64, param Badge) (int64, error) { + return x.ID(id).Update(¶m) +} + +func DelBadge(id int64) (int64, error) { + return x.ID(id).Delete(&Badge{}) +} + +func GetBadgeById(id int64) (*Badge, error) { + m := &Badge{} + has, err := x.ID(id).Get(m) + if err != nil { + return nil, err + } else if !has { + return nil, &ErrRecordNotExist{} + } + return m, nil +} + +func GetBadgeByCategoryId(categoryId int64) ([]*Badge, error) { + r := make([]*Badge, 0) + err := x.Where("category_id = ?", categoryId).Find(&r) + return r, err +} + +func GetCustomIconByHash(hash string) string { + if len(hash) == 0 { + return "" + } + return filepath.Join(setting.IconUploadPath, hash) +} + +func GetIconOuterLink(hash string) string { + return strings.TrimRight(setting.AppSubURL, "/") + "/show/icon/" + hash +} diff --git a/models/badge_category.go b/models/badge_category.go new file mode 100644 index 000000000..069fb6b10 --- /dev/null +++ b/models/badge_category.go @@ -0,0 +1,94 @@ +package models + +import "code.gitea.io/gitea/modules/timeutil" + +type BadgeType int + +const ( + CustomizeBadge = iota + 1 + SystemBadge +) + +type BadgeCategory struct { + ID int64 `xorm:"pk autoincr"` + Name string + Position int64 + Type BadgeType + CreatedUnix timeutil.TimeStamp `xorm:"created"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated"` + DeletedAt timeutil.TimeStamp `xorm:"deleted"` +} + +func (m *BadgeCategory) ToShow() *BadgeCategory4Show { + return &BadgeCategory4Show{ + ID: m.ID, + Name: m.Name, + Position: m.Position, + Type: m.Type, + CreatedUnix: m.CreatedUnix, + } +} + +type BadgeCategory4Show struct { + ID int64 `xorm:"pk autoincr"` + Name string + Position int64 + Type BadgeType + CreatedUnix timeutil.TimeStamp `xorm:"created"` +} + +func (m BadgeCategory4Show) ToDTO() BadgeCategory { + return BadgeCategory{ + ID: m.ID, + Name: m.Name, + Position: m.Position, + Type: m.Type, + CreatedUnix: m.CreatedUnix, + } +} + +func GetBadgeCategoryListPaging(opts ListOptions) (int64, []*BadgeCategory, error) { + n, err := x.Count(&BadgeCategory{}) + if err != nil { + return 0, nil, err + } + if opts.Page <= 0 { + opts.Page = 1 + } + r := make([]*BadgeCategory, 0) + if err := x.OrderBy("position asc,created_unix desc").Limit(opts.PageSize, (opts.Page-1)*opts.PageSize).Find(&r); err != nil { + return 0, nil, err + } + return n, r, nil +} + +func GetBadgeCategoryList() ([]*BadgeCategory, error) { + r := make([]*BadgeCategory, 0) + if err := x.OrderBy("position asc,created_unix desc").Find(&r); err != nil { + return nil, err + } + return r, nil +} + +func AddBadgeCategory(m BadgeCategory) (int64, error) { + return x.Insert(&m) +} + +func UpdateBadgeCategoryById(id int64, param BadgeCategory) (int64, error) { + return x.ID(id).Update(¶m) +} + +func DelBadgeCategory(id int64) (int64, error) { + return x.ID(id).Delete(&BadgeCategory{}) +} + +func GetBadgeCategoryById(id int64) (*BadgeCategory, error) { + m := &BadgeCategory{} + has, err := x.ID(id).Get(m) + if err != nil { + return nil, err + } else if !has { + return nil, ErrRecordNotExist{} + } + return m, nil +} diff --git a/models/badge_user.go b/models/badge_user.go new file mode 100644 index 000000000..9b556bc0e --- /dev/null +++ b/models/badge_user.go @@ -0,0 +1,159 @@ +package models + +import ( + "code.gitea.io/gitea/modules/timeutil" + "xorm.io/builder" +) + +const ( + ActionAddBadgeUser = 1 + ActionDelBadgeUser = 2 +) + +type BadgeUser struct { + ID int64 `xorm:"pk autoincr"` + UserId int64 `xorm:"unique(user_badge)"` + BadgeId int64 `xorm:"unique(user_badge) index"` + CreatedUnix timeutil.TimeStamp `xorm:"created index"` +} + +type BadgeUserLog struct { + ID int64 `xorm:"pk autoincr"` + UserId int64 `xorm:"index"` + BadgeId int64 `xorm:"index"` + Action int + CreatedUnix timeutil.TimeStamp `xorm:"created index"` +} + +type BadgeUserDetail struct { + BadgeUser BadgeUser `xorm:"extends"` + User User `xorm:"extends"` +} + +func (*BadgeUserDetail) TableName() string { + return "badge_user" +} + +func (m *BadgeUserDetail) ToShow() *BadgeUser4SHow { + return &BadgeUser4SHow{ + ID: m.BadgeUser.ID, + UserId: m.BadgeUser.UserId, + Name: m.User.Name, + Avatar: m.User.RelAvatarLink(), + Email: m.User.Email, + CreatedUnix: m.BadgeUser.CreatedUnix, + } +} + +type BadgeUser4SHow struct { + ID int64 + UserId int64 + Name string + Avatar string + Email string + CreatedUnix timeutil.TimeStamp +} + +type AddBadgeUsersReq struct { + BadgeId int64 + Users string +} +type DelBadgeUserReq struct { + ID int64 +} + +type GetUserBadgesOpts struct { + CategoryId int64 + ListOptions +} + +func AddBadgeUser(m BadgeUser) (int64, error) { + sess := x.NewSession() + defer sess.Close() + sess.Begin() + n, err := sess.Insert(&m) + if err != nil || n == 0 { + return 0, err + } + _, err = sess.Insert(&BadgeUserLog{ + UserId: m.UserId, + BadgeId: m.BadgeId, + Action: ActionAddBadgeUser, + }) + if err != nil { + sess.Rollback() + return 0, err + } + return n, sess.Commit() +} + +func DelBadgeUser(id int64) (int64, error) { + m := BadgeUser{} + has, err := x.ID(id).Get(&m) + if err != nil { + return 0, err + } + if !has { + return 0, ErrRecordNotExist{} + } + sess := x.NewSession() + defer sess.Close() + sess.Begin() + n, err := x.ID(m.ID).Delete(&BadgeUser{}) + if err != nil || n == 0 { + return 0, err + } + _, err = sess.Insert(&BadgeUserLog{ + UserId: m.UserId, + BadgeId: m.BadgeId, + Action: ActionDelBadgeUser, + }) + if err != nil { + sess.Rollback() + return 0, err + } + return n, sess.Commit() +} + +func GetBadgeUsers(badgeId int64, opts ListOptions) (int64, []BadgeUserDetail, error) { + n, err := x.Join("LEFT", "public.user", "public.user.ID = badge_user.user_id").Where("badge_user.badge_id = ?", badgeId).Count(&BadgeUserDetail{}) + if err != nil { + return 0, nil, err + } + if opts.Page <= 0 { + opts.Page = 1 + } + m := make([]BadgeUserDetail, 0) + err = x.Join("LEFT", "public.user", "public.user.ID = badge_user.user_id").Where("badge_user.badge_id = ?", badgeId).OrderBy("badge_user.id desc").Limit(opts.PageSize, (opts.Page-1)*opts.PageSize).Find(&m) + if err != nil { + return 0, nil, err + } + return n, m, nil +} + +func GetUserBadgesPaging(userId int64, opts GetUserBadgesOpts) ([]*Badge, error) { + cond := builder.NewCond() + cond = cond.And(builder.Eq{"badge_user.user_id": userId}) + if opts.CategoryId > 0 { + cond = cond.And(builder.Eq{"badge.category_id": opts.CategoryId}) + } + + r := make([]*Badge, 0) + err := x.Join("INNER", "badge_user", "badge_user.badge_id = badge.id").Where(cond).OrderBy("badge_user.id desc").Limit(opts.PageSize, (opts.Page-1)*opts.PageSize).Find(&r) + return r, err +} +func CountUserBadges(userId int64) (int64, error) { + return x.Where("user_id = ?", userId).Count(&BadgeUser{}) +} + +func GetUserBadges(userId, categoryId int64) ([]*Badge, error) { + cond := builder.NewCond() + cond = cond.And(builder.Eq{"badge_user.user_id": userId}) + if categoryId > 0 { + cond = cond.And(builder.Eq{"badge.category_id": categoryId}) + } + + r := make([]*Badge, 0) + err := x.Join("INNER", "badge_user", "badge_user.badge_id = badge.id").Where(cond).OrderBy("badge_user.created_unix desc").Find(&r) + return r, err +} diff --git a/models/models.go b/models/models.go index 4c2079cd8..ff64bfad2 100755 --- a/models/models.go +++ b/models/models.go @@ -161,6 +161,10 @@ func init() { new(CloudbrainSpec), new(CloudbrainTemp), new(DatasetReference), + new(BadgeCategory), + new(Badge), + new(BadgeUser), + new(BadgeUserLog), ) tablesStatistic = append(tablesStatistic, diff --git a/models/user.go b/models/user.go index f40eb699f..b21858e37 100755 --- a/models/user.go +++ b/models/user.go @@ -2184,3 +2184,24 @@ func GetBlockChainUnSuccessUsers() ([]*User, error) { Find(&users) return users, err } + +//GetUserIdsByUserNames Get userIDs in batches through username paging, this method will ignore errors +func GetUserIdsByUserNames(names []string) []int64 { + pageSize := 200 + length := len(names) + r := make([]int64, 0, length) + for i := 0; i < length; i = i + pageSize { + if length-i < 200 { + pageSize = length - i + } + userNameTemp := names[i : i+pageSize] + t := make([]int64, 0, length) + err := x.Table("public.user").Cols("id").In("name", userNameTemp).Find(&t) + if err != nil { + continue + } + r = append(r, t...) + + } + return r +} diff --git a/modules/setting/setting.go b/modules/setting/setting.go index c6afae05a..2f468c850 100755 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -622,6 +622,13 @@ var ( DeductTaskRange time.Duration DeductTaskRangeForFirst time.Duration + //badge config + BadgeIconMaxFileSize int64 + BadgeIconMaxWidth int + BadgeIconMaxHeight int + BadgeIconDefaultSize uint + IconUploadPath string + //wechat auto reply config UserNameOfWechatReply string RepoNameOfWechatReply string @@ -1531,6 +1538,14 @@ func NewContext() { CloudBrainPayInterval = sec.Key("CLOUDBRAIN_PAY_INTERVAL").MustDuration(60 * time.Minute) DeductTaskRange = sec.Key("DEDUCT_TASK_RANGE").MustDuration(30 * time.Minute) DeductTaskRangeForFirst = sec.Key("DEDUCT_TASK_RANGE_FOR_FIRST").MustDuration(3 * time.Hour) + + sec = Cfg.Section("icons") + BadgeIconMaxFileSize = sec.Key("BADGE_ICON_MAX_FILE_SIZE").MustInt64(1048576) + BadgeIconMaxWidth = sec.Key("BADGE_ICON_MAX_WIDTH").MustInt(4096) + BadgeIconMaxHeight = sec.Key("BADGE_ICON_MAX_HEIGHT").MustInt(3072) + BadgeIconDefaultSize = sec.Key("BADGE_ICON_DEFAULT_SIZE").MustUint(200) + IconUploadPath = sec.Key("ICON_UPLOAD_PATH").MustString(path.Join(AppDataPath, "icons")) + SetRadarMapConfig() sec = Cfg.Section("warn_mail") diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 773a338c1..324930544 100755 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -525,6 +525,7 @@ datasets = Datasets activity = Public Activity followers = Followers starred = Starred Repositories +badge = Achievement Badge following = Following follow = Follow unfollow = Unfollow diff --git a/options/locale/locale_zh-CN.ini b/options/locale/locale_zh-CN.ini index 8ba4d252d..fd5596a2e 100755 --- a/options/locale/locale_zh-CN.ini +++ b/options/locale/locale_zh-CN.ini @@ -530,6 +530,7 @@ datasets=数据集 activity=公开活动 followers=关注者 starred=已点赞 +badge=成就徽章 following=关注中 follow=关注 unfollow=取消关注 diff --git a/routers/badge/badge.go b/routers/badge/badge.go new file mode 100644 index 000000000..6d8725b12 --- /dev/null +++ b/routers/badge/badge.go @@ -0,0 +1,136 @@ +package badge + +import ( + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/routers/response" + "code.gitea.io/gitea/services/badge" + "errors" + "github.com/unknwon/com" + "net/http" + "strings" +) + +func GetCustomizeBadgeList(ctx *context.Context) { + page := ctx.QueryInt("page") + category := ctx.QueryInt64("category") + pageSize := 50 + n, r, err := badge.GetBadgeList(models.GetBadgeOpts{CategoryId: category, BadgeType: models.CustomizeBadge, ListOpts: models.ListOptions{PageSize: pageSize, Page: page}}) + if err != nil { + log.Error("GetCustomizeBadgeList error.%v", err) + ctx.JSON(http.StatusOK, response.ServerError(err.Error())) + return + } + m := make(map[string]interface{}) + m["List"] = r + m["Total"] = n + m["PageSize"] = pageSize + ctx.JSON(http.StatusOK, response.SuccessWithData(m)) +} + +func OperateBadge(ctx *context.Context, req models.BadgeOperateReq) { + action := ctx.Params(":action") + + var err *response.BizError + switch action { + case "edit": + err = badge.EditBadge(req, ctx.User) + case "new": + err = badge.AddBadge(req, ctx.User) + case "del": + err = badge.DelBadge(req.ID, ctx.User) + default: + err = response.NewBizError(errors.New("action type error")) + } + + if err != nil { + log.Error("OperateBadge error ,%v", err) + ctx.JSON(http.StatusOK, response.ResponseError(err)) + return + } + ctx.JSON(http.StatusOK, response.Success()) +} + +func GetBadgeUsers(ctx *context.Context) { + page := ctx.QueryInt("page") + badgeId := ctx.QueryInt64("badge") + pageSize := 50 + n, r, err := badge.GetBadgeUsers(badgeId, models.ListOptions{PageSize: pageSize, Page: page}) + if err != nil { + log.Error("GetBadgeUsers error.%v", err) + ctx.JSON(http.StatusOK, response.ServerError(err.Error())) + return + } + m := make(map[string]interface{}) + m["List"] = r + m["Total"] = n + m["PageSize"] = pageSize + ctx.JSON(http.StatusOK, response.SuccessWithData(m)) +} + +func AddOperateBadgeUsers(ctx *context.Context, req models.AddBadgeUsersReq) { + userStr := req.Users + if userStr == "" { + ctx.JSON(http.StatusOK, response.Success()) + return + } + userStr = strings.ReplaceAll(userStr, " ", "") + userStr = strings.ReplaceAll(userStr, "\r", "") + userNames := strings.Split(userStr, "\n") + n, err := badge.AddBadgeUsers(req.BadgeId, userNames) + if err != nil { + log.Error("AddOperateBadgeUsers error.%v", err) + ctx.JSON(http.StatusOK, response.ServerError(err.Error())) + return + } + m := make(map[string]interface{}) + m["Total"] = len(userNames) + m["Success"] = n + ctx.JSON(http.StatusOK, response.SuccessWithData(m)) +} + +func DelBadgeUsers(ctx *context.Context, req models.DelBadgeUserReq) { + id := req.ID + if id <= 0 { + ctx.JSON(http.StatusOK, response.Success()) + return + } + + err := badge.DelBadgeUser(id) + if err != nil { + log.Error("DelBadgeUsers error.%v", err) + ctx.JSON(http.StatusOK, response.ServerError(err.Error())) + return + } + ctx.JSON(http.StatusOK, response.Success()) +} + +func UploadIcon(ctx *context.Context, form badge.IconUploadForm) { + + uploader := badge.NewIconUploader(badge.IconUploadConfig{ + FileMaxSize: setting.BadgeIconMaxFileSize, + FileMaxWidth: setting.BadgeIconMaxWidth, + FileMaxHeight: setting.BadgeIconMaxHeight, + NeedSquare: true, + }) + iconName, err := uploader.Upload(form, ctx.User) + if err != nil { + log.Error("UploadIcon error.%v", err) + ctx.JSON(http.StatusOK, response.ServerError(err.Error())) + return + } + m := make(map[string]string, 0) + m["IconName"] = iconName + ctx.JSON(http.StatusOK, response.SuccessWithData(m)) +} + +func GetIcon(ctx *context.Context) { + hash := ctx.Params(":hash") + if !com.IsFile(models.GetCustomIconByHash(hash)) { + ctx.NotFound(ctx.Req.URL.RequestURI(), nil) + return + } + ctx.Redirect(setting.AppSubURL + "/icons/" + hash) +} diff --git a/routers/badge/category.go b/routers/badge/category.go new file mode 100644 index 000000000..71c34e1ba --- /dev/null +++ b/routers/badge/category.go @@ -0,0 +1,50 @@ +package badge + +import ( + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/routers/response" + "code.gitea.io/gitea/services/badge" + "errors" + "net/http" +) + +func GetBadgeCategoryList(ctx *context.Context) { + page := ctx.QueryInt("page") + pageSize := 50 + n, r, err := badge.GetBadgeCategoryList(models.ListOptions{Page: page, PageSize: pageSize}) + if err != nil { + log.Error("GetCategoryList error.%v", err) + ctx.JSON(http.StatusOK, response.ServerError(err.Error())) + return + } + m := make(map[string]interface{}) + m["List"] = r + m["Total"] = n + m["PageSize"] = pageSize + ctx.JSON(http.StatusOK, response.SuccessWithData(m)) +} + +func OperateBadgeCategory(ctx *context.Context, category models.BadgeCategory4Show) { + action := ctx.Params(":action") + + var err *response.BizError + switch action { + case "edit": + err = badge.EditBadgeCategory(category, ctx.User) + case "new": + err = badge.AddBadgeCategory(category, ctx.User) + case "del": + err = badge.DelBadgeCategory(category.ID, ctx.User) + default: + err = response.NewBizError(errors.New("action type error")) + } + + if err != nil { + log.Error("OperateBadgeCategory error ,%v", err) + ctx.JSON(http.StatusOK, response.ResponseError(err)) + return + } + ctx.JSON(http.StatusOK, response.Success()) +} diff --git a/routers/response/response_list.go b/routers/response/response_list.go index 6514f3edd..8bdbf375c 100644 --- a/routers/response/response_list.go +++ b/routers/response/response_list.go @@ -3,3 +3,6 @@ package response var RESOURCE_QUEUE_NOT_AVAILABLE = &BizError{Code: 1001, Err: "resource queue not available"} var SPECIFICATION_NOT_EXIST = &BizError{Code: 1002, Err: "specification not exist"} var SPECIFICATION_NOT_AVAILABLE = &BizError{Code: 1003, Err: "specification not available"} + +var CATEGORY_STILL_HAS_BADGES = &BizError{Code: 1004, Err: "Please delete badges in the category first"} +var BADGES_STILL_HAS_USERS = &BizError{Code: 1005, Err: "Please delete users of badge first"} diff --git a/routers/routes/routes.go b/routers/routes/routes.go index 9a523ea48..193555ffd 100755 --- a/routers/routes/routes.go +++ b/routers/routes/routes.go @@ -6,6 +6,11 @@ package routes import ( "bytes" + "code.gitea.io/gitea/routers/badge" + "code.gitea.io/gitea/routers/reward/point" + "code.gitea.io/gitea/routers/task" + badge_service "code.gitea.io/gitea/services/badge" + "code.gitea.io/gitea/services/reward" "encoding/gob" "net/http" "path" @@ -13,9 +18,6 @@ import ( "time" "code.gitea.io/gitea/routers/modelapp" - "code.gitea.io/gitea/routers/reward/point" - "code.gitea.io/gitea/routers/task" - "code.gitea.io/gitea/services/reward" "code.gitea.io/gitea/modules/slideimage" @@ -195,6 +197,14 @@ func NewMacaron() *macaron.Macaron { ExpiresAfter: setting.StaticCacheTime, }, )) + m.Use(public.StaticHandler( + setting.IconUploadPath, + &public.Options{ + Prefix: "icons", + SkipLogging: setting.DisableRouterLog, + ExpiresAfter: setting.StaticCacheTime, + }, + )) m.Use(public.StaticHandler( setting.RepositoryAvatarUploadPath, &public.Options{ @@ -521,6 +531,8 @@ func RegisterRoutes(m *macaron.Macaron) { m.Get("/avatar/:hash", user.AvatarByEmailHash) + m.Get("/show/icon/:hash", badge.GetIcon) + adminReq := context.Toggle(&context.ToggleOptions{SignInRequired: true, AdminRequired: true}) // ***** START: Admin ***** @@ -666,6 +678,23 @@ func RegisterRoutes(m *macaron.Macaron) { m.Post("/add/batch", bindIgnErr(models.BatchLimitConfigVO{}), task.BatchAddTaskConfig) m.Post("/^:action(new|edit|del)$", bindIgnErr(models.TaskConfigWithLimit{}), task.OperateTaskConfig) }) + + m.Group("/badge", func() { + m.Group("/category", func() { + m.Get("/list", badge.GetBadgeCategoryList) + m.Post("/^:action(new|edit|del)$", bindIgnErr(models.BadgeCategory4Show{}), badge.OperateBadgeCategory) + }) + m.Group("/customize", func() { + m.Get("/list", badge.GetCustomizeBadgeList) + }) + m.Group("/users", func() { + m.Get("", badge.GetBadgeUsers) + m.Post("/add", bindIgnErr(models.AddBadgeUsersReq{}), badge.AddOperateBadgeUsers) + m.Post("/del", bindIgnErr(models.DelBadgeUserReq{}), badge.DelBadgeUsers) + }) + m.Post("/^:action(new|edit|del)$", bindIgnErr(models.BadgeOperateReq{}), badge.OperateBadge) + }) + m.Post("/icon/upload", bindIgnErr(badge_service.IconUploadForm{}), badge.UploadIcon) }, operationReq) // ***** END: Operation ***** diff --git a/routers/user/profile.go b/routers/user/profile.go index 42cdfd1a8..66a480b7f 100755 --- a/routers/user/profile.go +++ b/routers/user/profile.go @@ -6,6 +6,7 @@ package user import ( + "code.gitea.io/gitea/services/badge" "errors" "fmt" "path" @@ -90,10 +91,25 @@ func Profile(ctx *context.Context) { return } + // Show user badges + badges, err := badge.GetUserBadges(ctxUser.ID, models.ListOptions{Page: 1, PageSize: 5}) + if err != nil { + ctx.ServerError("GetUserBadges", err) + return + } + // Count user badges + cnt, err := badge.CountUserBadges(ctxUser.ID) + if err != nil { + ctx.ServerError("CountUserBadges", err) + return + } + ctx.Data["Title"] = ctxUser.DisplayName() ctx.Data["PageIsUserProfile"] = true ctx.Data["Owner"] = ctxUser ctx.Data["OpenIDs"] = openIDs + ctx.Data["RecentBadges"] = badges + ctx.Data["TotalBadges"] = cnt ctx.Data["EnableHeatmap"] = setting.Service.EnableUserHeatmap ctx.Data["HeatmapUser"] = ctxUser.Name showPrivate := ctx.IsSigned && (ctx.User.IsAdmin || ctx.User.ID == ctxUser.ID) @@ -297,6 +313,13 @@ func Profile(ctx *context.Context) { } total = int(count) + case "badge": + allBadges, err := badge.GetUserAllBadges(ctxUser.ID) + if err != nil { + ctx.ServerError("GetUserAllBadges", err) + return + } + ctx.Data["AllBadges"] = allBadges default: ctx.ServerError("tab error", errors.New("tab error")) return diff --git a/services/admin/operate_log/operate_log.go b/services/admin/operate_log/operate_log.go index 7b72ec2e2..f52950351 100644 --- a/services/admin/operate_log/operate_log.go +++ b/services/admin/operate_log/operate_log.go @@ -4,6 +4,13 @@ import ( "code.gitea.io/gitea/models" ) +type LogBizType string + +const ( + BadgeCategoryOperate LogBizType = "BadgeCategoryOperate" + BadgeOperate LogBizType = "BadgeOperate" +) + func Log(log models.AdminOperateLog) error { _, err := models.InsertAdminOperateLog(log) return err @@ -12,3 +19,34 @@ func Log(log models.AdminOperateLog) error { func NewLogValues() *models.LogValues { return &models.LogValues{Params: make([]models.LogValue, 0)} } + +func Log4Add(bizType LogBizType, newValue interface{}, doerId int64, comment string) { + Log(models.AdminOperateLog{ + BizType: string(bizType), + OperateType: "add", + NewValue: NewLogValues().Add("new", newValue).JsonString(), + CreatedBy: doerId, + Comment: comment, + }) +} + +func Log4Edit(bizType LogBizType, oldValue interface{}, newValue interface{}, doerId int64, comment string) { + Log(models.AdminOperateLog{ + BizType: string(bizType), + OperateType: "edit", + NewValue: NewLogValues().Add("new", newValue).JsonString(), + OldValue: NewLogValues().Add("old", oldValue).JsonString(), + CreatedBy: doerId, + Comment: comment, + }) +} + +func Log4Del(bizType LogBizType, oldValue interface{}, doerId int64, comment string) { + Log(models.AdminOperateLog{ + BizType: string(bizType), + OperateType: "del", + OldValue: NewLogValues().Add("old", oldValue).JsonString(), + CreatedBy: doerId, + Comment: comment, + }) +} diff --git a/services/badge/badge.go b/services/badge/badge.go new file mode 100644 index 000000000..c6f833f65 --- /dev/null +++ b/services/badge/badge.go @@ -0,0 +1,80 @@ +package badge + +import ( + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/routers/response" + "code.gitea.io/gitea/services/admin/operate_log" + "errors" +) + +func GetBadgeList(opts models.GetBadgeOpts) (int64, []*models.Badge4AdminShow, error) { + total, list, err := models.GetBadgeList(opts) + if err != nil { + return 0, nil, err + } + if len(list) == 0 { + return 0, nil, nil + } + r := make([]*models.Badge4AdminShow, len(list)) + for i := 0; i < len(list); i++ { + r[i] = list[i].ToShow() + } + + return total, r, nil +} + +func AddBadge(m models.BadgeOperateReq, doer *models.User) *response.BizError { + _, err := models.GetBadgeCategoryById(m.CategoryId) + + if err != nil { + if models.IsErrRecordNotExist(err) { + return response.NewBizError(errors.New("badge category is not available")) + } + return response.NewBizError(err) + } + _, err = models.AddBadge(m.ToDTO()) + if err != nil { + return response.NewBizError(err) + } + operate_log.Log4Add(operate_log.BadgeOperate, m, doer.ID, "新增了勋章") + return nil +} + +func EditBadge(m models.BadgeOperateReq, doer *models.User) *response.BizError { + if m.ID == 0 { + log.Error(" EditBadge param error") + return response.NewBizError(errors.New("param error")) + } + old, err := models.GetBadgeById(m.ID) + if err != nil { + return response.NewBizError(err) + } + _, err = models.UpdateBadgeById(m.ID, m.ToDTO()) + if err != nil { + return response.NewBizError(err) + } + operate_log.Log4Edit(operate_log.BadgeOperate, old, m.ToDTO(), doer.ID, "修改了勋章") + return nil +} + +func DelBadge(id int64, doer *models.User) *response.BizError { + if id == 0 { + log.Error(" DelBadge param error") + return response.NewBizError(errors.New("param error")) + } + old, err := models.GetBadgeById(id) + if err != nil { + return response.NewBizError(err) + } + n, _, err := models.GetBadgeUsers(id, models.ListOptions{PageSize: 1, Page: 1}) + if err != nil { + return response.NewBizError(err) + } + if n > 0 { + return response.BADGES_STILL_HAS_USERS + } + _, err = models.DelBadge(id) + operate_log.Log4Del(operate_log.BadgeOperate, old, doer.ID, "删除了勋章") + return nil +} diff --git a/services/badge/category.go b/services/badge/category.go new file mode 100644 index 000000000..445dedcad --- /dev/null +++ b/services/badge/category.go @@ -0,0 +1,72 @@ +package badge + +import ( + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/routers/response" + "code.gitea.io/gitea/services/admin/operate_log" + "errors" +) + +func GetBadgeCategoryList(opts models.ListOptions) (int64, []*models.BadgeCategory4Show, error) { + total, list, err := models.GetBadgeCategoryListPaging(opts) + if err != nil { + return 0, nil, err + } + if len(list) == 0 { + return 0, nil, nil + } + r := make([]*models.BadgeCategory4Show, len(list)) + for i := 0; i < len(list); i++ { + r[i] = list[i].ToShow() + } + + return total, r, nil +} + +func AddBadgeCategory(m models.BadgeCategory4Show, doer *models.User) *response.BizError { + _, err := models.AddBadgeCategory(m.ToDTO()) + if err != nil { + return response.NewBizError(err) + } + operate_log.Log4Add(operate_log.BadgeCategoryOperate, m, doer.ID, "新增了勋章分类") + return nil +} + +func EditBadgeCategory(m models.BadgeCategory4Show, doer *models.User) *response.BizError { + if m.ID == 0 { + log.Error(" EditBadgeCategory param error") + return response.NewBizError(errors.New("param error")) + } + old, err := models.GetBadgeCategoryById(m.ID) + if err != nil { + return response.NewBizError(err) + } + _, err = models.UpdateBadgeCategoryById(m.ID, m.ToDTO()) + if err != nil { + return response.NewBizError(err) + } + operate_log.Log4Edit(operate_log.BadgeCategoryOperate, old, m.ToDTO(), doer.ID, "修改了勋章分类") + return nil +} + +func DelBadgeCategory(id int64, doer *models.User) *response.BizError { + if id == 0 { + log.Error(" DelBadgeCategory param error") + return response.NewBizError(errors.New("param error")) + } + old, err := models.GetBadgeCategoryById(id) + if err != nil { + return response.NewBizError(err) + } + badges, err := models.GetBadgeByCategoryId(id) + if err != nil { + return response.NewBizError(err) + } + if len(badges) > 0 { + return response.CATEGORY_STILL_HAS_BADGES + } + _, err = models.DelBadgeCategory(id) + operate_log.Log4Del(operate_log.BadgeCategoryOperate, old, doer.ID, "删除了勋章分类") + return nil +} diff --git a/services/badge/icon.go b/services/badge/icon.go new file mode 100644 index 000000000..fd731b586 --- /dev/null +++ b/services/badge/icon.go @@ -0,0 +1,140 @@ +package badge + +import ( + "bytes" + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + "crypto/md5" + "errors" + "fmt" + "github.com/nfnt/resize" + "github.com/oliamb/cutter" + "image" + "image/png" + "io/ioutil" + "mime/multipart" + "os" +) + +type IconUploader struct { + Config IconUploadConfig +} + +type IconUploadForm struct { + Icon *multipart.FileHeader +} + +type IconUploadConfig struct { + FileMaxSize int64 + FileMaxWidth int + FileMaxHeight int + DefaultSize uint + NeedResize bool + NeedSquare bool +} + +func NewIconUploader(config IconUploadConfig) IconUploader { + return IconUploader{Config: config} +} + +func (u IconUploader) Upload(form IconUploadForm, user *models.User) (string, error) { + if form.Icon == nil || form.Icon.Filename == "" { + return "", errors.New("File or fileName is empty") + } + + fr, err := form.Icon.Open() + if err != nil { + return "", fmt.Errorf("Icon.Open: %v", err) + } + defer fr.Close() + + if form.Icon.Size > u.Config.FileMaxSize { + return "", errors.New("File is too large") + } + + data, err := ioutil.ReadAll(fr) + if err != nil { + return "", fmt.Errorf("ioutil.ReadAll: %v", err) + } + if !base.IsImageFile(data) { + return "", errors.New("File is not a image") + } + iconName, err := u.uploadIcon(data, user.ID) + if err != nil { + return "", fmt.Errorf("uploadIcon: %v", err) + } + return iconName, nil + +} + +func (u IconUploader) uploadIcon(data []byte, userId int64) (string, error) { + m, err := u.prepare(data) + if err != nil { + return "", err + } + + iconName := fmt.Sprintf("%x", md5.Sum([]byte(fmt.Sprintf("%d-%x", userId, md5.Sum(data))))) + + if err := os.MkdirAll(setting.IconUploadPath, os.ModePerm); err != nil { + return "", fmt.Errorf("uploadIcon. Failed to create dir %s: %v", setting.AvatarUploadPath, err) + } + + fw, err := os.Create(models.GetCustomIconByHash(iconName)) + if err != nil { + return "", fmt.Errorf("Create: %v", err) + } + defer fw.Close() + + if err = png.Encode(fw, *m); err != nil { + return "", fmt.Errorf("Encode: %v", err) + } + + return iconName, nil +} + +func (u IconUploader) prepare(data []byte) (*image.Image, error) { + imgCfg, _, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("DecodeConfig: %v", err) + } + if imgCfg.Width > u.Config.FileMaxWidth { + return nil, fmt.Errorf("Image width is too large: %d > %d", imgCfg.Width, setting.AvatarMaxWidth) + } + if imgCfg.Height > u.Config.FileMaxHeight { + return nil, fmt.Errorf("Image height is too large: %d > %d", imgCfg.Height, setting.AvatarMaxHeight) + } + + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("Decode: %v", err) + } + + if u.Config.NeedSquare { + if imgCfg.Width != imgCfg.Height { + var newSize, ax, ay int + if imgCfg.Width > imgCfg.Height { + newSize = imgCfg.Height + ax = (imgCfg.Width - imgCfg.Height) / 2 + } else { + newSize = imgCfg.Width + ay = (imgCfg.Height - imgCfg.Width) / 2 + } + + img, err = cutter.Crop(img, cutter.Config{ + Width: newSize, + Height: newSize, + Anchor: image.Point{ax, ay}, + }) + if err != nil { + return nil, err + } + } + } + + if u.Config.NeedResize && u.Config.DefaultSize > 0 { + img = resize.Resize(u.Config.DefaultSize, u.Config.DefaultSize, img, resize.NearestNeighbor) + } + + return &img, nil +} diff --git a/services/badge/user.go b/services/badge/user.go new file mode 100644 index 000000000..025b10f77 --- /dev/null +++ b/services/badge/user.go @@ -0,0 +1,111 @@ +package badge + +import ( + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/log" +) + +func GetBadgeUsers(badgeId int64, opts models.ListOptions) (int64, []*models.BadgeUser4SHow, error) { + total, list, err := models.GetBadgeUsers(badgeId, opts) + if err != nil { + return 0, nil, err + } + if len(list) == 0 { + return 0, nil, nil + } + r := make([]*models.BadgeUser4SHow, len(list)) + for i := 0; i < len(list); i++ { + r[i] = list[i].ToShow() + } + + return total, r, nil +} + +func AddBadgeUsers(badgeId int64, userNames []string) (int, error) { + userIds := models.GetUserIdsByUserNames(userNames) + if len(userIds) == 0 { + return 0, nil + } + successCount := 0 + for _, v := range userIds { + m := models.BadgeUser{ + UserId: v, + BadgeId: badgeId, + } + _, err := models.AddBadgeUser(m) + if err != nil { + log.Error("AddBadgeUser err in loop, m=%+v. e=%v", m, err) + continue + } + successCount++ + } + return successCount, nil +} + +func DelBadgeUser(id int64) error { + _, err := models.DelBadgeUser(id) + return err +} + +//GetUserBadges Only Returns badges the user has earned +func GetUserBadges(userId int64, opts models.ListOptions) ([]*models.Badge4UserShow, error) { + badges, err := models.GetUserBadgesPaging(userId, models.GetUserBadgesOpts{ListOptions: opts}) + if err != nil { + return nil, err + } + r := make([]*models.Badge4UserShow, len(badges)) + for i, v := range badges { + r[i] = v.ToUserShow() + } + return r, nil +} + +func CountUserBadges(userId int64) (int64, error) { + return models.CountUserBadges(userId) +} + +func GetUserAllBadges(userId int64) ([]models.UserAllBadgeInCategory, error) { + categoryList, err := models.GetBadgeCategoryList() + if err != nil { + return nil, err + } + r := make([]models.UserAllBadgeInCategory, 0) + for _, v := range categoryList { + badges, err := models.GetBadgeByCategoryId(v.ID) + if badges == nil || len(badges) == 0 { + continue + } + userBadgeMap, err := getUserBadgesMap(userId, v.ID) + if err != nil { + return nil, err + } + t := models.UserAllBadgeInCategory{ + CategoryName: v.Name, + CategoryId: v.ID, + LightedNum: len(userBadgeMap), + } + bArray := make([]*models.BadgeShowWithStatus, len(badges)) + for j, v := range badges { + b := &models.BadgeShowWithStatus{Badge: v.ToUserShow()} + if _, has := userBadgeMap[v.ID]; has { + b.IsLighted = true + } + bArray[j] = b + } + t.Badges = bArray + r = append(r, t) + } + return r, nil +} + +func getUserBadgesMap(userId, categoryId int64) (map[int64]*models.Badge, error) { + userBadges, err := models.GetUserBadges(userId, categoryId) + if err != nil { + return nil, err + } + m := make(map[int64]*models.Badge, 0) + for _, v := range userBadges { + m[v.ID] = v + } + return m, nil +} diff --git a/templates/org/member/members.tmpl b/templates/org/member/members.tmpl new file mode 100644 index 000000000..cd8a691a7 --- /dev/null +++ b/templates/org/member/members.tmpl @@ -0,0 +1,80 @@ +{{template "base/head" .}} +
+ {{template "org/header" .}} +
+ {{template "base/alert" .}} + {{template "org/navber" .}} +
+ +
+ {{ range .Members}} +
+
+ +
+
+ +
{{.FullName}}
+
+
+
+ {{$.i18n.Tr "org.members.membership_visibility"}} +
+
+ {{ $isPublic := index $.MembersIsPublicMember .ID}} + {{if $isPublic}} + {{$.i18n.Tr "org.members.public"}} + {{if or (eq $.SignedUser.ID .ID) $.IsOrganizationOwner}}({{$.i18n.Tr "org.members.public_helper"}}){{end}} + {{else}} + {{$.i18n.Tr "org.members.private"}} + {{if or (eq $.SignedUser.ID .ID) $.IsOrganizationOwner}}({{$.i18n.Tr "org.members.private_helper"}}){{end}} + {{end}} +
+
+
+
+ {{$.i18n.Tr "org.members.member_role"}} +
+
+ {{if index $.MembersIsUserOrgOwner .ID}}{{svg "octicon-shield-lock" 16}} {{$.i18n.Tr "org.members.owner"}}{{else}}{{$.i18n.Tr "org.members.member"}}{{end}} +
+
+
+
+ 2FA +
+
+ + {{if index $.MembersTwoFaStatus .ID}} + {{svg "octicon-check" 16}} + {{else}} + {{svg "octicon-x" 16}} + {{end}} + +
+
+
+
+ {{if eq $.SignedUser.ID .ID}} +
+ {{$.CsrfTokenHtml}} + +
+ {{else if $.IsOrganizationOwner}} +
+ {{$.CsrfTokenHtml}} + +
+ {{end}} +
+
+
+ {{end}} +
+ + {{template "base/paginate" .}} +
+ +
+
+{{template "base/footer" .}} \ No newline at end of file diff --git a/templates/repo/badge.tmpl b/templates/repo/badge.tmpl new file mode 100644 index 000000000..61b542be8 --- /dev/null +++ b/templates/repo/badge.tmpl @@ -0,0 +1,25 @@ +
+ {{range .AllBadges }} +
+
{{.CategoryName}}   (已点亮{{.LightedNum}}个)
+ +
+ {{ end }} + {{ template "base/paginate" . }} +
+ diff --git a/templates/user/profile.tmpl b/templates/user/profile.tmpl index be6ecbaa0..d4e97a961 100755 --- a/templates/user/profile.tmpl +++ b/templates/user/profile.tmpl @@ -17,6 +17,17 @@ {{if .Owner.FullName}}{{.Owner.FullName}}{{end}} {{.Owner.Name}} + +
+ {{range $k,$v :=.RecentBadges}} + {{if le $k 3}} +
+ {{else}} + + {{end}} + {{end}} + +
{{if eq .TabName "activity"}} @@ -201,6 +218,8 @@ {{template "explore/dataset_search" .}} {{template "explore/dataset_list" .}} {{template "base/paginate" .}} + {{else if eq .TabName "badge"}} + {{template "repo/badge" .}} {{else}} {{template "explore/repo_search" .}} {{template "explore/repo_list" .}} @@ -228,5 +247,8 @@ .user.profile .ui.card .extra.content ul { padding: 5px 0; } + .ui.secondary.pointing.menu .item{ + padding: 0.78571429em 0.92857143em; + } \ No newline at end of file diff --git a/web_src/less/_user.less b/web_src/less/_user.less index 6acbb35ee..29ca96255 100644 --- a/web_src/less/_user.less +++ b/web_src/less/_user.less @@ -9,11 +9,30 @@ .username { display: block; } - + .badge-wrap { + display: flex; + justify-content: center; + align-items: center; + .badge-img-avatar { + width: 32px; + height: 32px; + margin-right: 5px; + } + .badge-more-icon { + width: 32px; + height: 32px; + display: flex; + justify-content: center; + align-items: center; + border-radius: 50%; + border: 1px #f8f9fa solid; + background: #f8f9fa; + } + } .header { font-weight: 700; font-size: 1.3rem; - margin-top: -.2rem; + margin-top: -0.2rem; line-height: 1.3rem; } @@ -158,3 +177,70 @@ max-width: 60px; } } +.badge-achive { + .bagde-section { + color: #000; + margin-top: 28px; + border-bottom: 1px solid #dededf; + } + .bagde-section:last-child { + color: #000; + margin-top: 28px; + border-bottom: none; + } + .badge-section-title { + position: relative; + font-size: 16px; + line-height: 24px; + padding-left: 8px; + margin-bottom: 20px; + display: flex; + justify-content: space-between; + } + .badge-section-children { + width: 100%; + } + .badge-honor-badge { + margin-bottom: 25px; + } + .badge-honor-badge-basic { + display: flex; + align-items: flex-start; + flex-wrap: wrap; + } + .badge-honor-badge-basic-item { + text-align: center; + font-size: 12px; + margin-right: 30px; + color: #101010; + } + .is-not-pointer { + cursor: pointer; + pointer-events: none; + } + .badge-honor-badge-basic-img { + width: 100px; + height: 100px; + margin-bottom: 10px; + } + .badge-honor-badge-basic-txt { + line-height: 20px; + width: 100px; + word-break: break-all; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } + .badge-section-title:before { + content: ""; + position: absolute; + top: 50%; + left: 0; + transform: translateY(-50%); + width: 3px; + height: 1em; + background-color: #000; + } +}