From cb52b17f92e2d2293f7c003649743464492bca48 Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Fri, 1 Mar 2024 03:23:28 -0500 Subject: [PATCH] Add admin API route for managing user's badges (#23106) Fix #22785 --------- Co-authored-by: Lunny Xiao --- models/migrations/migrations.go | 2 + models/migrations/v1_22/v287.go | 46 +++++++ models/migrations/v1_22/v287_test.go | 57 +++++++++ models/user/badge.go | 85 ++++++++++++- modules/structs/user.go | 31 +++++ routers/api/v1/admin/user_badge.go | 124 +++++++++++++++++++ routers/api/v1/api.go | 3 + routers/api/v1/swagger/options.go | 6 + templates/swagger/v1_json.tmpl | 171 ++++++++++++++++++++++++++- 9 files changed, 523 insertions(+), 2 deletions(-) create mode 100644 models/migrations/v1_22/v287.go create mode 100644 models/migrations/v1_22/v287_test.go create mode 100644 routers/api/v1/admin/user_badge.go diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index beb1f3bb9..516eb53f6 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -558,6 +558,8 @@ var migrations = []Migration{ NewMigration("Add PreviousDuration to ActionRun", v1_22.AddPreviousDurationToActionRun), // v286 -> v287 NewMigration("Add support for SHA256 git repositories", v1_22.AdjustDBForSha256), + // v287 -> v288 + NewMigration("Use Slug instead of ID for Badges", v1_22.UseSlugInsteadOfIDForBadges), } // GetCurrentDBVersion returns the current db version diff --git a/models/migrations/v1_22/v287.go b/models/migrations/v1_22/v287.go new file mode 100644 index 000000000..c8b159328 --- /dev/null +++ b/models/migrations/v1_22/v287.go @@ -0,0 +1,46 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_22 //nolint + +import ( + "xorm.io/xorm" +) + +type BadgeUnique struct { + ID int64 `xorm:"pk autoincr"` + Slug string `xorm:"UNIQUE"` +} + +func (BadgeUnique) TableName() string { + return "badge" +} + +func UseSlugInsteadOfIDForBadges(x *xorm.Engine) error { + type Badge struct { + Slug string + } + + err := x.Sync(new(Badge)) + if err != nil { + return err + } + + sess := x.NewSession() + defer sess.Close() + if err := sess.Begin(); err != nil { + return err + } + + _, err = sess.Exec("UPDATE `badge` SET `slug` = `id` Where `slug` IS NULL") + if err != nil { + return err + } + + err = sess.Sync(new(BadgeUnique)) + if err != nil { + return err + } + + return sess.Commit() +} diff --git a/models/migrations/v1_22/v287_test.go b/models/migrations/v1_22/v287_test.go new file mode 100644 index 000000000..19c7ae3b9 --- /dev/null +++ b/models/migrations/v1_22/v287_test.go @@ -0,0 +1,57 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_22 //nolint + +import ( + "fmt" + "testing" + + "code.gitea.io/gitea/models/migrations/base" + + "github.com/stretchr/testify/assert" +) + +func Test_UpdateBadgeColName(t *testing.T) { + type Badge struct { + ID int64 `xorm:"pk autoincr"` + Description string + ImageURL string + } + + // Prepare and load the testing database + x, deferable := base.PrepareTestEnv(t, 0, new(BadgeUnique), new(Badge)) + defer deferable() + if x == nil || t.Failed() { + return + } + + oldBadges := []Badge{ + {ID: 1, Description: "Test Badge 1", ImageURL: "https://example.com/badge1.png"}, + {ID: 2, Description: "Test Badge 2", ImageURL: "https://example.com/badge2.png"}, + {ID: 3, Description: "Test Badge 3", ImageURL: "https://example.com/badge3.png"}, + } + + for _, badge := range oldBadges { + _, err := x.Insert(&badge) + assert.NoError(t, err) + } + + if err := UseSlugInsteadOfIDForBadges(x); err != nil { + assert.NoError(t, err) + return + } + + got := []BadgeUnique{} + if err := x.Table("badge").Asc("id").Find(&got); !assert.NoError(t, err) { + return + } + + for i, e := range oldBadges { + got := got[i] + assert.Equal(t, e.ID, got.ID) + assert.Equal(t, fmt.Sprintf("%d", e.ID), got.Slug) + } + + // TODO: check if badges have been updated +} diff --git a/models/user/badge.go b/models/user/badge.go index ee52b44cf..3ff3530a3 100644 --- a/models/user/badge.go +++ b/models/user/badge.go @@ -5,13 +5,15 @@ package user import ( "context" + "fmt" "code.gitea.io/gitea/models/db" ) // Badge represents a user badge type Badge struct { - ID int64 `xorm:"pk autoincr"` + ID int64 `xorm:"pk autoincr"` + Slug string `xorm:"UNIQUE"` Description string ImageURL string } @@ -39,3 +41,84 @@ func GetUserBadges(ctx context.Context, u *User) ([]*Badge, int64, error) { count, err := sess.FindAndCount(&badges) 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 +} + +// GetBadge returns a 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 { + return nil, err + } + return badge, err +} + +// 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) + return err +} + +// DeleteBadge deletes a badge. +func DeleteBadge(ctx context.Context, badge *Badge) error { + _, err := db.GetEngine(ctx).Where("slug=?", badge.Slug).Delete(badge) + return err +} + +// AddUserBadge adds a badge to a user. +func AddUserBadge(ctx context.Context, u *User, badge *Badge) error { + return AddUserBadges(ctx, u, []*Badge{badge}) +} + +// AddUserBadges adds badges to a user. +func AddUserBadges(ctx context.Context, u *User, badges []*Badge) error { + return db.WithTx(ctx, func(ctx context.Context) error { + for _, badge := range badges { + // hydrate badge and check if it exists + has, err := db.GetEngine(ctx).Where("slug=?", badge.Slug).Get(badge) + if err != nil { + return err + } else if !has { + return fmt.Errorf("badge with slug %s doesn't exist", badge.Slug) + } + if err := db.Insert(ctx, &UserBadge{ + BadgeID: badge.ID, + UserID: u.ID, + }); err != nil { + return err + } + } + return nil + }) +} + +// RemoveUserBadge removes a badge from a user. +func RemoveUserBadge(ctx context.Context, u *User, badge *Badge) error { + return RemoveUserBadges(ctx, u, []*Badge{badge}) +} + +// RemoveUserBadges removes 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 + } + } + return nil + }) +} + +// RemoveAllUserBadges removes all badges from a user. +func RemoveAllUserBadges(ctx context.Context, u *User) error { + _, err := db.GetEngine(ctx).Where("user_id=?", u.ID).Delete(&UserBadge{}) + return err +} diff --git a/modules/structs/user.go b/modules/structs/user.go index 0df67894b..c43558be5 100644 --- a/modules/structs/user.go +++ b/modules/structs/user.go @@ -1,4 +1,5 @@ // Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2023 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package structs @@ -108,3 +109,33 @@ type UpdateUserAvatarOption struct { // image must be base64 encoded Image string `json:"image" binding:"Required"` } + +// Badge represents a user badge +// swagger:model +type Badge struct { + ID int64 `json:"id"` + Slug string `json:"slug"` + Description string `json:"description"` + ImageURL string `json:"image_url"` +} + +// UserBadge represents a user badge +// swagger:model +type UserBadge struct { + ID int64 `json:"id"` + BadgeID int64 `json:"badge_id"` + UserID int64 `json:"user_id"` +} + +// UserBadgeOption options for link between users and badges +type UserBadgeOption struct { + // example: ["badge1","badge2"] + BadgeSlugs []string `json:"badge_slugs" binding:"Required"` +} + +// BadgeList +// swagger:response BadgeList +type BadgeList struct { + // in:body + Body []Badge `json:"body"` +} diff --git a/routers/api/v1/admin/user_badge.go b/routers/api/v1/admin/user_badge.go new file mode 100644 index 000000000..bacd1f809 --- /dev/null +++ b/routers/api/v1/admin/user_badge.go @@ -0,0 +1,124 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "net/http" + + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" +) + +// ListUserBadges lists all badges belonging to a user +func ListUserBadges(ctx *context.APIContext) { + // swagger:operation GET /admin/users/{username}/badges admin adminListUserBadges + // --- + // summary: List a user's badges + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of user + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/BadgeList" + // "404": + // "$ref": "#/responses/notFound" + + badges, maxResults, err := user_model.GetUserBadges(ctx, ctx.ContextUser) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetUserBadges", err) + return + } + + ctx.SetTotalCountHeader(maxResults) + ctx.JSON(http.StatusOK, &badges) +} + +// AddUserBadges add badges to a user +func AddUserBadges(ctx *context.APIContext) { + // swagger:operation POST /admin/users/{username}/badges admin adminAddUserBadges + // --- + // summary: Add a badge to a user + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of user + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/UserBadgeOption" + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + + form := web.GetForm(ctx).(*api.UserBadgeOption) + badges := prepareBadgesForReplaceOrAdd(ctx, *form) + + if err := user_model.AddUserBadges(ctx, ctx.ContextUser, badges); err != nil { + ctx.Error(http.StatusInternalServerError, "ReplaceUserBadges", err) + return + } + + ctx.Status(http.StatusNoContent) +} + +// DeleteUserBadges delete a badge from a user +func DeleteUserBadges(ctx *context.APIContext) { + // swagger:operation DELETE /admin/users/{username}/badges admin adminDeleteUserBadges + // --- + // summary: Remove a badge from a user + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of user + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/UserBadgeOption" + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "422": + // "$ref": "#/responses/validationError" + + form := web.GetForm(ctx).(*api.UserBadgeOption) + badges := prepareBadgesForReplaceOrAdd(ctx, *form) + + if err := user_model.RemoveUserBadges(ctx, ctx.ContextUser, badges); err != nil { + ctx.Error(http.StatusInternalServerError, "ReplaceUserBadges", err) + return + } + + ctx.Status(http.StatusNoContent) +} + +func prepareBadgesForReplaceOrAdd(ctx *context.APIContext, form api.UserBadgeOption) []*user_model.Badge { + badges := make([]*user_model.Badge, len(form.BadgeSlugs)) + for i, badge := range form.BadgeSlugs { + badges[i] = &user_model.Badge{ + Slug: badge, + } + } + return badges +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 0913571c2..1587d413f 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1519,6 +1519,9 @@ func Routes() *web.Route { m.Post("/orgs", bind(api.CreateOrgOption{}), admin.CreateOrg) m.Post("/repos", bind(api.CreateRepoOption{}), admin.CreateRepo) m.Post("/rename", bind(api.RenameUserOption{}), admin.RenameUser) + m.Get("/badges", admin.ListUserBadges) + m.Post("/badges", bind(api.UserBadgeOption{}), admin.AddUserBadges) + m.Delete("/badges", bind(api.UserBadgeOption{}), admin.DeleteUserBadges) }, context.UserAssignmentAPI()) }) m.Group("/emails", func() { diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index 6f7859df6..e03862d7b 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -190,4 +190,10 @@ type swaggerParameterBodies struct { // in:body CreateOrUpdateSecretOption api.CreateOrUpdateSecretOption + + // in:body + UserBadgeOption api.UserBadgeOption + + // in:body + UserBadgeList api.BadgeList } diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index b2bd1bf17..d4c5d9a7e 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -689,6 +689,109 @@ } } }, + "/admin/users/{username}/badges": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "List a user's badges", + "operationId": "adminListUserBadges", + "parameters": [ + { + "type": "string", + "description": "username of user", + "name": "username", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/BadgeList" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Add a badge to a user", + "operationId": "adminAddUserBadges", + "parameters": [ + { + "type": "string", + "description": "username of user", + "name": "username", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/UserBadgeOption" + } + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Remove a badge from a user", + "operationId": "adminDeleteUserBadges", + "parameters": [ + { + "type": "string", + "description": "username of user", + "name": "username", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/UserBadgeOption" + } + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, "/admin/users/{username}/keys": { "post": { "consumes": [ @@ -17003,6 +17106,45 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "Badge": { + "description": "Badge represents a user badge", + "type": "object", + "properties": { + "description": { + "type": "string", + "x-go-name": "Description" + }, + "id": { + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "image_url": { + "type": "string", + "x-go-name": "ImageURL" + }, + "slug": { + "type": "string", + "x-go-name": "Slug" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, + "BadgeList": { + "description": "BadgeList", + "type": "object", + "properties": { + "body": { + "description": "in:body", + "type": "array", + "items": { + "$ref": "#/definitions/Badge" + }, + "x-go-name": "Body" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "Branch": { "description": "Branch represents a repository branch", "type": "object", @@ -23047,6 +23189,24 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "UserBadgeOption": { + "description": "UserBadgeOption options for link between users and badges", + "type": "object", + "properties": { + "badge_slugs": { + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "BadgeSlugs", + "example": [ + "badge1", + "badge2" + ] + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "UserHeatmapData": { "description": "UserHeatmapData represents the data needed to create a heatmap", "type": "object", @@ -23336,6 +23496,15 @@ } } }, + "BadgeList": { + "description": "BadgeList", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Badge" + } + } + }, "Branch": { "description": "Branch", "schema": { @@ -24249,7 +24418,7 @@ "parameterBodies": { "description": "parameterBodies", "schema": { - "$ref": "#/definitions/CreateOrUpdateSecretOption" + "$ref": "#/definitions/BadgeList" } }, "redirect": {