From f8ae9f730b0234b9877615bc3f3155a19c8e1d97 Mon Sep 17 00:00:00 2001 From: eyad-hussein Date: Wed, 10 Jul 2024 16:12:59 +0300 Subject: [PATCH] api: implement logic for all project/user related endpoints --- modules/structs/project.go | 4 +- modules/structs/project_column.go | 18 ++ routers/api/v1/api.go | 76 +++--- routers/api/v1/org/project.go | 399 +++++++++++++++++++++++++++++- services/convert/project.go | 65 ----- 5 files changed, 444 insertions(+), 118 deletions(-) create mode 100644 modules/structs/project_column.go delete mode 100644 services/convert/project.go diff --git a/modules/structs/project.go b/modules/structs/project.go index 2163d15980..15dfb0e28e 100644 --- a/modules/structs/project.go +++ b/modules/structs/project.go @@ -8,8 +8,8 @@ type Project struct { ID int64 `json:"id"` Title string `json:"title"` Description string `json:"description"` - TemplateType string `json:"template_type"` - CardType string `json:"card_type"` + TemplateType uint8 `json:"template_type"` + CardType uint8 `json:"card_type"` } type CreateProjectOption struct { diff --git a/modules/structs/project_column.go b/modules/structs/project_column.go new file mode 100644 index 0000000000..4138717d77 --- /dev/null +++ b/modules/structs/project_column.go @@ -0,0 +1,18 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package structs + +// Column represents a project column +type Column struct { + ID int64 `json:"id"` + Title string `json:"title"` + Color string `json:"color"` +} + +// EditProjectColumnOption options for editing a project column +type EditProjectColumnOption struct { + Title string `binding:"Required;MaxSize(100)"` + Sorting int8 + Color string `binding:"MaxSize(7)"` +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 572abcdb31..8606a27d22 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -964,48 +964,29 @@ func Routes() *web.Router { }, context.UserAssignmentAPI(), individualPermsChecker) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser)) - // m.Group("/projects", func() { - // m.Group("", func() { - // m.Get("", org.Projects) - // m.Get("/{id}", org.ViewProject) - // }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true)) - // m.Group("", func() { //nolint:dupl - // m.Get("/new", org.RenderNewProject) - // m.Post("/new", web.Bind(forms.CreateProjectForm{}), org.NewProjectPost) - // m.Group("/{id}", func() { - // m.Post("", web.Bind(forms.EditProjectColumnForm{}), org.AddColumnToProjectPost) - // m.Post("/move", project.MoveColumns) - // m.Post("/delete", org.DeleteProject) - - // m.Get("/edit", org.RenderEditProject) - // m.Post("/edit", web.Bind(forms.CreateProjectForm{}), org.EditProjectPost) - // m.Post("/{action:open|close}", org.ChangeProjectStatus) - - // m.Group("/{columnID}", func() { - // m.Put("", web.Bind(forms.EditProjectColumnForm{}), org.EditProjectColumn) - // m.Delete("", org.DeleteProjectColumn) - // m.Post("/default", org.SetDefaultProjectColumn) - // m.Post("/move", org.MoveIssues) - // }) - // }) - // }, reqSignIn, reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true), func(ctx *context.Context) { - // if ctx.ContextUser.IsIndividual() && ctx.ContextUser.ID != ctx.Doer.ID { - // ctx.NotFound("NewProject", nil) - // return - // } - // }) - // }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true), individualPermsChecker) // Users (requires user scope) - m.Group("/{username}/-", func() { + m.Group("users/{username}/-", func() { m.Group("/projects", func() { m.Group("", func() { m.Get("", org.GetProjects) - // m.Get("/{id}", org.ViewProject) + m.Get("/{id}", org.GetProject) }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true)) + m.Group("", func() { m.Post("", bind(api.CreateProjectOption{}), org.CreateProject) m.Group("/{id}", func() { + m.Post("", bind(api.EditProjectColumnOption{}), org.AddColumnToProject) + m.Post("/move", org.MoveColumns) + m.Post("/delete", org.DeleteProject) + m.Post("/edit", bind(api.CreateProjectOption{}), org.EditProject) m.Post("/{action:open|close}", org.ChangeProjectStatus) + + m.Group("/{columnID}", func() { + m.Put("", bind(api.EditProjectColumnOption{}), org.EditProjectColumn) + m.Delete("", org.DeleteProjectColumn) + m.Post("/default", org.SetDefaultProjectColumn) + m.Post("/move", org.MoveIssues) + }) }) }, reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true), func(ctx *context.APIContext) { if ctx.ContextUser.IsIndividual() && ctx.ContextUser.ID != ctx.Doer.ID { @@ -1014,22 +995,23 @@ func Routes() *web.Router { } }) }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true), individualPermsChecker) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken(), context.UserAssignmentAPI()) - m.Group("/{org}/-", func() { - m.Group("/projects", func() { - m.Group("", func() { - // m.Get("", org.Projects) - // m.Get("/{id}", org.ViewProject) - }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true)) - m.Group("", func() { - m.Post("", bind(api.CreateProjectOption{}), org.CreateProject) - m.Group("/{id}", func() { - m.Post("/{action:open|close}", org.ChangeProjectStatus) - }) - }, reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true)) - }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true), individualPermsChecker) - }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), reqToken(), orgAssignment(true)) + // m.Group("orgs/{org}/-", func() { + // m.Group("/projects", func() { + // m.Group("", func() { + // // m.Get("", org.Projects) + // // m.Get("/{id}", org.ViewProject) + // }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true)) + // m.Group("", func() { + // m.Post("", bind(api.CreateProjectOption{}), org.CreateProject) + // m.Group("/{id}", func() { + // m.Post("/{action:open|close}", org.ChangeProjectStatus) + // }) + // }, reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true)) + // }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead, true), individualPermsChecker) + // }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), reqToken(), orgAssignment(true)) // Users (requires user scope) m.Group("/users", func() { diff --git a/routers/api/v1/org/project.go b/routers/api/v1/org/project.go index 1b0f96c7f3..7429c5c943 100644 --- a/routers/api/v1/org/project.go +++ b/routers/api/v1/org/project.go @@ -1,12 +1,16 @@ package org import ( - "log" + "encoding/json" + "errors" + "fmt" "net/http" "strings" "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" project_model "code.gitea.io/gitea/models/project" + attachment_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" @@ -18,8 +22,6 @@ import ( func CreateProject(ctx *context.APIContext) { form := web.GetForm(ctx).(*api.CreateProjectOption) - log.Println(ctx.ContextUser.ID) - project := &project_model.Project{ OwnerID: ctx.ContextUser.ID, Title: form.Title, @@ -40,7 +42,7 @@ func CreateProject(ctx *context.APIContext) { return } - ctx.JSON(http.StatusCreated, map[string]int64{"id": project.ID}) + ctx.JSON(http.StatusCreated, project) } // ChangeProjectStatus updates the status of a project between "open" and "close" @@ -101,3 +103,392 @@ func GetProjects(ctx *context.APIContext) { ctx.JSON(http.StatusOK, projects) } + +// TODO: Send issues as well +// GetProject returns a project by ID +func GetProject(ctx *context.APIContext) { + project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + if project.OwnerID != ctx.ContextUser.ID { + ctx.NotFound("", nil) + return + } + + columns, err := project.GetColumns(ctx) + if err != nil { + ctx.ServerError("GetProjectColumns", err) + return + } + + issuesMap, err := issues_model.LoadIssuesFromColumnList(ctx, columns) + if err != nil { + ctx.ServerError("LoadIssuesOfColumns", err) + return + } + + if project.CardType != project_model.CardTypeTextOnly { + issuesAttachmentMap := make(map[int64][]*attachment_model.Attachment) + for _, issuesList := range issuesMap { + for _, issue := range issuesList { + if issueAttachment, err := attachment_model.GetAttachmentsByIssueIDImagesLatest(ctx, issue.ID); err == nil { + issuesAttachmentMap[issue.ID] = issueAttachment + } + } + } + ctx.Data["issuesAttachmentMap"] = issuesAttachmentMap + } + + linkedPrsMap := make(map[int64][]*issues_model.Issue) + for _, issuesList := range issuesMap { + for _, issue := range issuesList { + var referencedIDs []int64 + for _, comment := range issue.Comments { + if comment.RefIssueID != 0 && comment.RefIsPull { + referencedIDs = append(referencedIDs, comment.RefIssueID) + } + } + + if len(referencedIDs) > 0 { + if linkedPrs, err := issues_model.Issues(ctx, &issues_model.IssuesOptions{ + IssueIDs: referencedIDs, + IsPull: optional.Some(true), + }); err == nil { + linkedPrsMap[issue.ID] = linkedPrs + } + } + } + } + + issues := make(map[int64][]*issues_model.Issue) + + for _, column := range columns { + if empty := issuesMap[column.ID]; len(empty) == 0 { + continue + } + issues[column.ID] = issuesMap[column.ID] + + } + + data := map[string]any{ + "project": project, + "columns": columns, + } + + ctx.JSON(http.StatusOK, data) +} + +// AddColumnToProject adds a new column to a project +func AddColumnToProject(ctx *context.APIContext) { + project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + if project.OwnerID != ctx.ContextUser.ID { + ctx.NotFound("", nil) + return + } + + form := web.GetForm(ctx).(*api.EditProjectColumnOption) + column := &project_model.Column{ + ProjectID: project.ID, + Title: form.Title, + Sorting: form.Sorting, + Color: form.Color, + } + if err := project_model.NewColumn(ctx, column); err != nil { + ctx.ServerError("NewProjectColumn", err) + return + } + + ctx.JSON(http.StatusCreated, column) +} + +// DeleteProject delete a project +func DeleteProject(ctx *context.APIContext) { + p, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + if p.OwnerID != ctx.ContextUser.ID { + ctx.NotFound("", nil) + return + } + + err = project_model.DeleteProjectByID(ctx, p.ID) + + if err != nil { + ctx.ServerError("DeleteProjectByID", err) + return + } + + ctx.JSON(http.StatusOK, map[string]any{"message": "project deleted successfully"}) +} + +// EditProject updates a project +func EditProject(ctx *context.APIContext) { + form := web.GetForm(ctx).(*api.CreateProjectOption) + projectID := ctx.PathParamInt64(":id") + + ctx.Data["CancelLink"] = fmt.Sprintf("%s/-/projects/%d", ctx.ContextUser.HomeLink(), projectID) + + p, err := project_model.GetProjectByID(ctx, projectID) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + if p.OwnerID != ctx.ContextUser.ID { + ctx.NotFound("", nil) + return + } + + p.Title = form.Title + p.Description = form.Content + p.CardType = project_model.CardType(form.CardType) + + if err = project_model.UpdateProject(ctx, p); err != nil { + ctx.ServerError("UpdateProjects", err) + return + } + + ctx.JSON(http.StatusOK, p) +} + +// MoveColumns moves or keeps columns in a project and sorts them inside that project +func MoveColumns(ctx *context.APIContext) { + project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + if !project.CanBeAccessedByOwnerRepo(ctx.ContextUser.ID, ctx.Repo.Repository) { + ctx.NotFound("CanBeAccessedByOwnerRepo", nil) + return + } + + type movedColumnsForm struct { + Columns []struct { + ColumnID int64 `json:"columnID"` + Sorting int64 `json:"sorting"` + } `json:"columns"` + } + + form := &movedColumnsForm{} + if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { + ctx.ServerError("DecodeMovedColumnsForm", err) + return + } + + sortedColumnIDs := make(map[int64]int64) + for _, column := range form.Columns { + sortedColumnIDs[column.Sorting] = column.ColumnID + } + + if err = project_model.MoveColumnsOnProject(ctx, project, sortedColumnIDs); err != nil { + ctx.ServerError("MoveColumnsOnProject", err) + return + } + + ctx.JSON(http.StatusOK, map[string]string{"message": "columns moved successfully"}) +} + +// CheckProjectColumnChangePermissions check permission +func CheckProjectColumnChangePermissions(ctx *context.APIContext) (*project_model.Project, *project_model.Column) { + if ctx.Doer == nil { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only signed in users are allowed to perform this action.", + }) + return nil, nil + } + + project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return nil, nil + } + + column, err := project_model.GetColumn(ctx, ctx.PathParamInt64(":columnID")) + if err != nil { + ctx.ServerError("GetProjectColumn", err) + return nil, nil + } + if column.ProjectID != ctx.PathParamInt64(":id") { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectColumn[%d] is not in Project[%d] as expected", column.ID, project.ID), + }) + return nil, nil + } + + if project.OwnerID != ctx.ContextUser.ID { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectColumn[%d] is not in Repository[%d] as expected", column.ID, project.ID), + }) + return nil, nil + } + return project, column +} + +// EditProjectColumn allows a project column's to be updated +func EditProjectColumn(ctx *context.APIContext) { + form := web.GetForm(ctx).(*api.EditProjectColumnOption) + _, column := CheckProjectColumnChangePermissions(ctx) + if ctx.Written() { + return + } + + if form.Title != "" { + column.Title = form.Title + } + column.Color = form.Color + if form.Sorting != 0 { + column.Sorting = form.Sorting + } + + if err := project_model.UpdateColumn(ctx, column); err != nil { + ctx.ServerError("UpdateProjectColumn", err) + return + } + + ctx.JSON(http.StatusOK, column) +} + +// DeleteProjectColumn allows for the deletion of a project column +func DeleteProjectColumn(ctx *context.APIContext) { + if ctx.Doer == nil { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only signed in users are allowed to perform this action.", + }) + return + } + + project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + + pb, err := project_model.GetColumn(ctx, ctx.PathParamInt64(":columnID")) + if err != nil { + ctx.ServerError("GetProjectColumn", err) + return + } + if pb.ProjectID != ctx.PathParamInt64(":id") { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectColumn[%d] is not in Project[%d] as expected", pb.ID, project.ID), + }) + return + } + + if project.OwnerID != ctx.ContextUser.ID { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectColumn[%d] is not in Owner[%d] as expected", pb.ID, ctx.ContextUser.ID), + }) + return + } + + if err := project_model.DeleteColumnByID(ctx, ctx.PathParamInt64(":columnID")); err != nil { + ctx.ServerError("DeleteProjectColumnByID", err) + return + } + + ctx.JSON(http.StatusOK, map[string]string{"message": "column deleted successfully"}) +} + +// SetDefaultProjectColumn set default column for uncategorized issues/pulls +func SetDefaultProjectColumn(ctx *context.APIContext) { + project, column := CheckProjectColumnChangePermissions(ctx) + if ctx.Written() { + return + } + + if err := project_model.SetDefaultColumn(ctx, project.ID, column.ID); err != nil { + ctx.ServerError("SetDefaultColumn", err) + return + } + + ctx.JSON(http.StatusOK, map[string]string{"message": "default column set successfully"}) +} + +// MoveIssues moves or keeps issues in a column and sorts them inside that column +func MoveIssues(ctx *context.APIContext) { + if ctx.Doer == nil { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only signed in users are allowed to perform this action.", + }) + return + } + + project, err := project_model.GetProjectByID(ctx, ctx.PathParamInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) + return + } + if project.OwnerID != ctx.ContextUser.ID { + ctx.NotFound("InvalidRepoID", nil) + return + } + + column, err := project_model.GetColumn(ctx, ctx.PathParamInt64(":columnID")) + if err != nil { + ctx.NotFoundOrServerError("GetProjectColumn", project_model.IsErrProjectColumnNotExist, err) + return + } + + if column.ProjectID != project.ID { + ctx.NotFound("ColumnNotInProject", nil) + return + } + + type movedIssuesForm struct { + Issues []struct { + IssueID int64 `json:"issueID"` + Sorting int64 `json:"sorting"` + } `json:"issues"` + } + + form := &movedIssuesForm{} + if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { + ctx.ServerError("DecodeMovedIssuesForm", err) + return + } + + issueIDs := make([]int64, 0, len(form.Issues)) + sortedIssueIDs := make(map[int64]int64) + for _, issue := range form.Issues { + issueIDs = append(issueIDs, issue.IssueID) + sortedIssueIDs[issue.Sorting] = issue.IssueID + } + movedIssues, err := issues_model.GetIssuesByIDs(ctx, issueIDs) + if err != nil { + ctx.NotFoundOrServerError("GetIssueByID", issues_model.IsErrIssueNotExist, err) + return + } + + if len(movedIssues) != len(form.Issues) { + ctx.ServerError("some issues do not exist", errors.New("some issues do not exist")) + return + } + + if _, err = movedIssues.LoadRepositories(ctx); err != nil { + ctx.ServerError("LoadRepositories", err) + return + } + + for _, issue := range movedIssues { + if issue.RepoID != project.RepoID && issue.Repo.OwnerID != project.OwnerID { + ctx.ServerError("Some issue's repoID is not equal to project's repoID", errors.New("Some issue's repoID is not equal to project's repoID")) + return + } + } + + if err = project_model.MoveIssuesOnProjectColumn(ctx, column, sortedIssueIDs); err != nil { + ctx.ServerError("MoveIssuesOnProjectColumn", err) + return + } + + ctx.JSON(http.StatusOK, map[string]string{"message": "issues moved successfully"}) +} diff --git a/services/convert/project.go b/services/convert/project.go deleted file mode 100644 index 12cb1358cd..0000000000 --- a/services/convert/project.go +++ /dev/null @@ -1,65 +0,0 @@ -package convert - -// use this as reference to create the ToProject function: -/* -// ToLabel converts Label to API format -func ToLabel(label *issues_model.Label, repo *repo_model.Repository, org *user_model.User) *api.Label { - result := &api.Label{ - ID: label.ID, - Name: label.Name, - Exclusive: label.Exclusive, - Color: strings.TrimLeft(label.Color, "#"), - Description: label.Description, - IsArchived: label.IsArchived(), - } - - labelBelongsToRepo := label.BelongsToRepo() - - // calculate URL - if labelBelongsToRepo && repo != nil { - result.URL = fmt.Sprintf("%s/labels/%d", repo.APIURL(), label.ID) - } else { // BelongsToOrg - if org != nil { - result.URL = fmt.Sprintf("%sapi/v1/orgs/%s/labels/%d", setting.AppURL, url.PathEscape(org.Name), label.ID) - } else { - log.Error("ToLabel did not get org to calculate url for label with id '%d'", label.ID) - } - } - - if labelBelongsToRepo && repo == nil { - log.Error("ToLabel did not get repo to calculate url for label with id '%d'", label.ID) - } - - return result -} -*/ - -// ToProject converts Project to API format -// func ToProject(project *project_model.Project, repo *repo_model.Repository, org *user_model.User) *api.Project { -// result := &api.Project{ -// ID: project.ID, -// Title: project.Title, -// Description: project.Description, -// TemplateType: project.TemplateType, -// CardType: project.CardType, -// } - -// projectBelongsToRepo := project.BelongsToRepo() - -// // calculate URL -// if projectBelongsToRepo && repo != nil { -// result.URL = fmt.Sprintf("%s/projects/%d", repo.APIURL(), project.ID) -// } else { // BelongsToOrg -// if org != nil { -// result.URL = fmt.Sprintf("%sapi/v1/orgs/%s/projects/%d", setting.AppURL, url.PathEscape(org.Name), project.ID) -// } else { -// log.Error("ToProject did not get org to calculate url for project with id '%d'", project.ID) -// } -// } - -// if projectBelongsToRepo && repo == nil { -// log.Error("ToProject did not get repo to calculate url for project with id '%d'", project.ID) -// } - -// return result -// }