diff --git a/models/project/board.go b/models/project/board.go index 3e2d8e0472..798c19b570 100644 --- a/models/project/board.go +++ b/models/project/board.go @@ -244,6 +244,24 @@ func (p *Project) GetBoards(ctx context.Context) (BoardList, error) { return append([]*Board{defaultB}, boards...), nil } +func (p *Project) GetBoardsAndCount(ctx context.Context) (BoardList, int64, error) { + boards := make([]*Board, 0, 5) + count, err := db.GetEngine(ctx). + Where("project_id=? AND `default`=?", p.ID, false). + OrderBy("Sorting"). + FindAndCount(&boards) + if err != nil { + return nil, 0, err + } + + defaultB, err := p.getDefaultBoard(ctx) + if err != nil { + return nil, 0, err + } + + return append([]*Board{defaultB}, boards...), count, nil +} + // getDefaultBoard return default board and create a dummy if none exist func (p *Project) getDefaultBoard(ctx context.Context) (*Board, error) { var board Board diff --git a/models/project/project.go b/models/project/project.go index 670ae39f87..167d519705 100644 --- a/models/project/project.go +++ b/models/project/project.go @@ -108,6 +108,8 @@ type Project struct { ClosedDateUnix timeutil.TimeStamp } +type ProjectList []*Project + func (p *Project) LoadOwner(ctx context.Context) (err error) { if p.Owner != nil { return nil diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 30b1c2a07f..b1426a96aa 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1747,11 +1747,23 @@ func Routes() *web.Route { }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryAdmin), reqToken(), reqSiteAdmin()) m.Group("/projects", func() { - m. - Combo("/{id}"). - Get(projects.GetProject). - Patch(bind(api.UpdateProjectPayload{}), projects.UpdateProject). - Delete(projects.DeleteProject) + m.Group("/{projectId}", func() { + m.Combo(""). + Get(projects.GetProject). + Patch(bind(api.UpdateProjectPayload{}), projects.UpdateProject). + Delete(projects.DeleteProject) + m.Combo("/boards"). + Get(projects.ListProjectBoards). + Post(bind(api.NewProjectBoardPayload{}), projects.CreateProjectBoard) + }) + + m.Group("/boards", func() { + m.Combo("/{boardId}"). + Get(projects.GetProjectBoard). + Patch(bind(api.UpdateProjectBoardPayload{}), projects.UpdateProjectBoard). + Delete(projects.DeleteProjectBoard) + }) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryIssue), reqToken()) m.Group("/topics", func() { m.Get("/search", repo.TopicSearch) diff --git a/routers/api/v1/projects/boards.go b/routers/api/v1/projects/boards.go new file mode 100644 index 0000000000..a7f953e91d --- /dev/null +++ b/routers/api/v1/projects/boards.go @@ -0,0 +1,242 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package projects + +import ( + "net/http" + + project_model "code.gitea.io/gitea/models/project" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/convert" +) + +func ListProjectBoards(ctx *context.APIContext) { + // swagger:operation GET /projects/{projectId}/boards board boardGetProjectBoards + // --- + // summary: Get project boards + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the project + // type: string + // required: true + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // + // responses: + // + // "200": + // "$ref": "#/responses/ProjectBoardList" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + project_id := ctx.ParamsInt64(":projectId") + project, err := project_model.GetProjectByID(ctx, project_id) + if err != nil { + ctx.Error(http.StatusNotFound, "ListProjectBoards", err) + return + } + + boards, count, err := project.GetBoardsAndCount(ctx) + if err != nil { + ctx.InternalServerError(err) + return + } + + ctx.SetLinkHeader(int(count), setting.UI.IssuePagingNum) + ctx.SetTotalCountHeader(count) + + apiBoards, err := convert.ToApiProjectBoardList(ctx, boards) + if err != nil { + ctx.InternalServerError(err) + return + } + + ctx.JSON(200, apiBoards) + +} + +func CreateProjectBoard(ctx *context.APIContext) { + // swagger:operation POST /projects/{projectId}/boards board boardCreateProjectBoard + // --- + // summary: Create project board + // produces: + // - application/json + // consumes: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the project + // type: string + // required: true + // - name: board + // in: body + // required: true + // schema: { "$ref": "#/definitions/NewProjectBoardPayload" } + // + // responses: + // + // "201": + // "$ref": "#/responses/ProjectBoard" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + form := web.GetForm(ctx).(*api.NewProjectBoardPayload) + + board := &project_model.Board{ + Title: form.Title, + Default: form.Default, + Sorting: form.Sorting, + Color: form.Color, + ProjectID: ctx.ParamsInt64(":projectId"), + CreatorID: ctx.Doer.ID, + } + + if err := project_model.NewBoard(ctx, board); err != nil { + ctx.InternalServerError(err) + return + } + + board, err := project_model.GetBoard(ctx, board.ID) + if err != nil { + ctx.InternalServerError(err) + return + } + + ctx.JSON(http.StatusCreated, convert.ToAPIProjectBoard(ctx, board)) +} + +func GetProjectBoard(ctx *context.APIContext) { + // swagger:operation GET /projects/boards/{boardId} board boardGetProjectBoard + // --- + // summary: Get project board + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the board + // type: string + // required: true + // + // responses: + // + // "200": + // "$ref": "#/responses/ProjectBoard" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + board, err := project_model.GetBoard(ctx, ctx.ParamsInt64(":boardId")) + if err != nil { + if project_model.IsErrProjectBoardNotExist(err) { + ctx.Error(http.StatusNotFound, "GetProjectBoard", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetProjectBoard", err) + } + return + } + + ctx.JSON(http.StatusOK, convert.ToAPIProjectBoard(ctx, board)) +} + +func UpdateProjectBoard(ctx *context.APIContext) { + // swagger:operation PATCH /projects/boards/{boardId} board boardUpdateProjectBoard + // --- + // summary: Update project board + // produces: + // - application/json + // consumes: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the project board + // type: string + // required: true + // - name: board + // in: body + // required: true + // schema: { "$ref": "#/definitions/UpdateProjectBoardPayload" } + // + // responses: + // + // "200": + // "$ref": "#/responses/ProjectBoard" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + form := web.GetForm(ctx).(*api.UpdateProjectBoardPayload) + + board, err := project_model.GetBoard(ctx, ctx.ParamsInt64(":boardId")) + if err != nil { + ctx.InternalServerError(err) + return + } + + if board.Title != form.Title { + board.Title = form.Title + } + if board.Color != form.Color { + board.Color = form.Color + } + + if err := project_model.UpdateBoard(ctx, board); err != nil { + ctx.InternalServerError(err) + return + } + + board, err = project_model.GetBoard(ctx, board.ID) + if err != nil { + ctx.InternalServerError(err) + return + } + + ctx.JSON(200, convert.ToAPIProjectBoard(ctx, board)) +} + +func DeleteProjectBoard(ctx *context.APIContext) { + // swagger:operation DELETE /projects/boards/{boardId} board boardDeleteProjectBoard + // --- + // summary: Delete project board + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the project board + // type: string + // required: true + // + // responses: + // + // "204": + // "description": "Project board deleted" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + if err := project_model.DeleteBoardByID(ctx, ctx.ParamsInt64(":boardId")); err != nil { + ctx.InternalServerError(err) + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/projects/project.go b/routers/api/v1/projects/project.go index d5c5cbfe0e..62ed8b8bf5 100644 --- a/routers/api/v1/projects/project.go +++ b/routers/api/v1/projects/project.go @@ -136,7 +136,7 @@ func CreateRepoProject(ctx *context.APIContext) { } func GetProject(ctx *context.APIContext) { - // swagger:operation GET /projects/{id} project projectGetProject + // swagger:operation GET /projects/{projectId} project projectGetProject // --- // summary: Get project // produces: @@ -154,7 +154,7 @@ func GetProject(ctx *context.APIContext) { // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" - project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":projectId")) if err != nil { if project_model.IsErrProjectNotExist(err) { ctx.NotFound() @@ -168,7 +168,7 @@ func GetProject(ctx *context.APIContext) { } func UpdateProject(ctx *context.APIContext) { - // swagger:operation PATCH /projects/{id} project projectUpdateProject + // swagger:operation PATCH /projects/{projectId} project projectUpdateProject // --- // summary: Update project // produces: @@ -193,7 +193,7 @@ func UpdateProject(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" form := web.GetForm(ctx).(*api.UpdateProjectPayload) - project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64("id")) + project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":projectId")) if err != nil { if project_model.IsErrProjectNotExist(err) { ctx.NotFound() @@ -218,7 +218,7 @@ func UpdateProject(ctx *context.APIContext) { } func DeleteProject(ctx *context.APIContext) { - // swagger:operation DELETE /projects/{id} project projectDeleteProject + // swagger:operation DELETE /projects/{projectId} project projectDeleteProject // --- // summary: Delete project // parameters: @@ -235,7 +235,7 @@ func DeleteProject(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - if err := project_model.DeleteProjectByID(ctx, ctx.ParamsInt64(":id")); err != nil { + if err := project_model.DeleteProjectByID(ctx, ctx.ParamsInt64(":projectId")); err != nil { ctx.Error(http.StatusInternalServerError, "DeleteProjectByID", err) return } diff --git a/services/convert/project.go b/services/convert/project.go index 281c1c1a23..59300b4fd5 100644 --- a/services/convert/project.go +++ b/services/convert/project.go @@ -10,6 +10,29 @@ import ( api "code.gitea.io/gitea/modules/structs" ) +func ToAPIProjectBoard(ctx context.Context, board *project_model.Board) *api.ProjectBoard { + apiProjectBoard := api.ProjectBoard{ + ID: board.ID, + Title: board.Title, + Color: board.Color, + Default: board.Default, + Sorting: board.Sorting, + } + + return &apiProjectBoard +} + +func ToApiProjectBoardList( + ctx context.Context, + boards []*project_model.Board, +) ([]*api.ProjectBoard, error) { + result := make([]*api.ProjectBoard, len(boards)) + for i := range boards { + result[i] = ToAPIProjectBoard(ctx, boards[i]) + } + return result, nil +} + func ToAPIProject(ctx context.Context, project *project_model.Project) *api.Project { apiProject := &api.Project{ diff --git a/tests/integration/api_project_board_test.go b/tests/integration/api_project_board_test.go new file mode 100644 index 0000000000..1565e7057b --- /dev/null +++ b/tests/integration/api_project_board_test.go @@ -0,0 +1,121 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + project_model "code.gitea.io/gitea/models/project" + "code.gitea.io/gitea/models/unittest" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + "github.com/stretchr/testify/assert" +) + +func TestAPICreateProjectBoard(t *testing.T) { + defer tests.PrepareTestEnv(t)() + token := getUserToken( + t, + "user2", + auth_model.AccessTokenScopeWriteIssue, + ) + + link, _ := url.Parse(fmt.Sprintf("/api/v1/projects/%d/boards?token=%s", 1, token)) + + req := NewRequestWithJSON(t, "POST", link.String(), &api.NewProjectBoardPayload{ + Title: "Unused", + }) + resp := MakeRequest(t, req, http.StatusCreated) + + var apiProjectBoard *api.ProjectBoard + DecodeJSON(t, resp, &apiProjectBoard) + + assert.Equal(t, apiProjectBoard.Title, "Unused") + unittest.AssertExistsAndLoadBean(t, &project_model.Board{ID: apiProjectBoard.ID}) +} + +func TestAPIListProjectBoards(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + token := getUserToken( + t, + "user2", + auth_model.AccessTokenScopeWriteIssue, + ) + + link, _ := url.Parse(fmt.Sprintf("/api/v1/projects/%d/boards?token=%s", 1, token)) + + req := NewRequest(t, "GET", link.String()) + resp := MakeRequest(t, req, http.StatusOK) + + var apiProjectBoards []*api.ProjectBoard + DecodeJSON(t, resp, &apiProjectBoards) + + assert.Len(t, apiProjectBoards, 4) +} + +func TestAPIGetProjectBoard(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + token := getUserToken( + t, + "user2", + auth_model.AccessTokenScopeReadIssue, + ) + + link, _ := url.Parse(fmt.Sprintf("/api/v1/projects/boards/%d?token=%s", 1, token)) + + req := NewRequest(t, "GET", link.String()) + resp := MakeRequest(t, req, http.StatusOK) + + var apiProjectBoard *api.ProjectBoard + DecodeJSON(t, resp, &apiProjectBoard) + + assert.Equal(t, apiProjectBoard.Title, "To Do") +} + +func TestAPIUpdateProjectBoard(t *testing.T) { + defer tests.PrepareTestEnv(t)() + token := getUserToken( + t, + "user2", + auth_model.AccessTokenScopeWriteIssue, + ) + + link, _ := url.Parse(fmt.Sprintf("/api/v1/projects/boards/%d?token=%s", 1, token)) + + req := NewRequestWithJSON(t, "PATCH", link.String(), &api.UpdateProjectBoardPayload{ + Title: "Unused", + }) + resp := MakeRequest(t, req, http.StatusOK) + + var apiProjectBoard *api.ProjectBoard + DecodeJSON(t, resp, &apiProjectBoard) + + assert.Equal(t, apiProjectBoard.Title, "Unused") + dbboard := &project_model.Board{ID: apiProjectBoard.ID} + unittest.AssertExistsAndLoadBean(t, dbboard) + assert.Equal(t, dbboard.Title, "Unused") +} + +func TestAPIDeleteProjectBoard(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + token := getUserToken( + t, + "user2", + auth_model.AccessTokenScopeWriteIssue, + ) + + link, _ := url.Parse(fmt.Sprintf("/api/v1/projects/boards/%d?token=%s", 1, token)) + + req := NewRequest(t, "DELETE", link.String()) + MakeRequest(t, req, http.StatusNoContent) + + unittest.AssertNotExistsBean(t, &project_model.Board{ID: 1}) +} diff --git a/tests/integration/api_project_test.go b/tests/integration/api_project_test.go index 75cb480c28..0fb1923b3c 100644 --- a/tests/integration/api_project_test.go +++ b/tests/integration/api_project_test.go @@ -67,7 +67,8 @@ func TestAPICreateOrgProject(t *testing.T) { assert.Equal(t, title, apiProject.Title) assert.Equal(t, description, apiProject.Description) assert.Equal(t, board_type, apiProject.BoardType) - assert.Equal(t, "org17", apiProject.Creator.UserName) + assert.Equal(t, "org17", apiProject.Owner.UserName) + assert.Equal(t, "user2", apiProject.Creator.UserName) } func TestAPICreateRepoProject(t *testing.T) {