From 6fe3c8b3980f850c9789f9fa62bdfee7b2708ff0 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Fri, 20 Jan 2023 19:42:33 +0800 Subject: [PATCH] Support org/user level projects (#22235) Fix #13405 image Co-authored-by: 6543 <6543@obermui.de> --- models/fixtures/project.yml | 9 + models/fixtures/project_board.yml | 8 + models/issues/issue.go | 2 +- models/issues/issue_project.go | 10 +- models/organization/team.go | 65 -- models/organization/team_list.go | 128 ++++ models/organization/team_user.go | 20 - models/project/project.go | 94 ++- models/project/project_test.go | 6 +- modules/context/org.go | 28 + routers/web/org/main_test.go | 17 + routers/web/org/projects.go | 670 ++++++++++++++++++ routers/web/org/projects_test.go | 28 + routers/web/repo/issue.go | 33 +- routers/web/repo/projects.go | 48 +- routers/web/shared/user/header.go | 14 + routers/web/user/package.go | 17 +- routers/web/user/profile.go | 2 +- routers/web/web.go | 42 +- services/context/user.go | 9 + templates/org/menu.tmpl | 3 + templates/org/projects/list.tmpl | 6 + templates/org/projects/new.tmpl | 6 + templates/org/projects/view.tmpl | 6 + templates/projects/list.tmpl | 98 +++ templates/projects/new.tmpl | 66 ++ templates/projects/view.tmpl | 279 ++++++++ .../repo/issue/view_content/sidebar.tmpl | 12 +- templates/user/overview/header.tmpl | 3 + templates/user/profile.tmpl | 3 + 30 files changed, 1556 insertions(+), 176 deletions(-) create mode 100644 models/organization/team_list.go create mode 100644 routers/web/org/main_test.go create mode 100644 routers/web/org/projects.go create mode 100644 routers/web/org/projects_test.go create mode 100644 routers/web/shared/user/header.go create mode 100644 templates/org/projects/list.tmpl create mode 100644 templates/org/projects/new.tmpl create mode 100644 templates/org/projects/view.tmpl create mode 100644 templates/projects/list.tmpl create mode 100644 templates/projects/new.tmpl create mode 100644 templates/projects/view.tmpl diff --git a/models/fixtures/project.yml b/models/fixtures/project.yml index 3d42597c5e8a..f38b5344bba2 100644 --- a/models/fixtures/project.yml +++ b/models/fixtures/project.yml @@ -24,3 +24,12 @@ creator_id: 5 board_type: 1 type: 2 + +- + id: 4 + title: project on user2 + owner_id: 2 + is_closed: false + creator_id: 2 + board_type: 1 + type: 2 diff --git a/models/fixtures/project_board.yml b/models/fixtures/project_board.yml index 9e06e8c23960..dc4f9cf565d7 100644 --- a/models/fixtures/project_board.yml +++ b/models/fixtures/project_board.yml @@ -21,3 +21,11 @@ creator_id: 2 created_unix: 1588117528 updated_unix: 1588117528 + +- + id: 4 + project_id: 4 + title: Done + creator_id: 2 + created_unix: 1588117528 + updated_unix: 1588117528 diff --git a/models/issues/issue.go b/models/issues/issue.go index 4a8ab06824aa..dc9e5c5acd18 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -1098,7 +1098,7 @@ func GetIssueWithAttrsByID(id int64) (*Issue, error) { } // GetIssuesByIDs return issues with the given IDs. -func GetIssuesByIDs(ctx context.Context, issueIDs []int64) ([]*Issue, error) { +func GetIssuesByIDs(ctx context.Context, issueIDs []int64) (IssueList, error) { issues := make([]*Issue, 0, 10) return issues, db.GetEngine(ctx).In("id", issueIDs).Find(&issues) } diff --git a/models/issues/issue_project.go b/models/issues/issue_project.go index 8e559f13c92c..c9f4c9f5336b 100644 --- a/models/issues/issue_project.go +++ b/models/issues/issue_project.go @@ -125,13 +125,17 @@ func ChangeProjectAssign(issue *Issue, doer *user_model.User, newProjectID int64 func addUpdateIssueProject(ctx context.Context, issue *Issue, doer *user_model.User, newProjectID int64) error { oldProjectID := issue.projectID(ctx) + if err := issue.LoadRepo(ctx); err != nil { + return err + } + // Only check if we add a new project and not remove it. if newProjectID > 0 { newProject, err := project_model.GetProjectByID(ctx, newProjectID) if err != nil { return err } - if newProject.RepoID != issue.RepoID { + if newProject.RepoID != issue.RepoID && newProject.OwnerID != issue.Repo.OwnerID { return fmt.Errorf("issue's repository is not the same as project's repository") } } @@ -140,10 +144,6 @@ func addUpdateIssueProject(ctx context.Context, issue *Issue, doer *user_model.U return err } - if err := issue.LoadRepo(ctx); err != nil { - return err - } - if oldProjectID > 0 || newProjectID > 0 { if _, err := CreateComment(ctx, &CreateCommentOptions{ Type: CommentTypeProject, diff --git a/models/organization/team.go b/models/organization/team.go index 55d3f1727665..0c2577dab15a 100644 --- a/models/organization/team.go +++ b/models/organization/team.go @@ -16,8 +16,6 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/util" - - "xorm.io/builder" ) // ___________ @@ -96,59 +94,6 @@ func init() { db.RegisterModel(new(TeamInvite)) } -// SearchTeamOptions holds the search options -type SearchTeamOptions struct { - db.ListOptions - UserID int64 - Keyword string - OrgID int64 - IncludeDesc bool -} - -func (opts *SearchTeamOptions) toCond() builder.Cond { - cond := builder.NewCond() - - if len(opts.Keyword) > 0 { - lowerKeyword := strings.ToLower(opts.Keyword) - var keywordCond builder.Cond = builder.Like{"lower_name", lowerKeyword} - if opts.IncludeDesc { - keywordCond = keywordCond.Or(builder.Like{"LOWER(description)", lowerKeyword}) - } - cond = cond.And(keywordCond) - } - - if opts.OrgID > 0 { - cond = cond.And(builder.Eq{"`team`.org_id": opts.OrgID}) - } - - if opts.UserID > 0 { - cond = cond.And(builder.Eq{"team_user.uid": opts.UserID}) - } - - return cond -} - -// SearchTeam search for teams. Caller is responsible to check permissions. -func SearchTeam(opts *SearchTeamOptions) ([]*Team, int64, error) { - sess := db.GetEngine(db.DefaultContext) - - opts.SetDefaultValues() - cond := opts.toCond() - - if opts.UserID > 0 { - sess = sess.Join("INNER", "team_user", "team_user.team_id = team.id") - } - sess = db.SetSessionPagination(sess, opts) - - teams := make([]*Team, 0, opts.PageSize) - count, err := sess.Where(cond).OrderBy("lower_name").FindAndCount(&teams) - if err != nil { - return nil, 0, err - } - - return teams, count, nil -} - // ColorFormat provides a basic color format for a Team func (t *Team) ColorFormat(s fmt.State) { if t == nil { @@ -335,16 +280,6 @@ func GetTeamNamesByID(teamIDs []int64) ([]string, error) { return teamNames, err } -// GetRepoTeams gets the list of teams that has access to the repository -func GetRepoTeams(ctx context.Context, repo *repo_model.Repository) (teams []*Team, err error) { - return teams, db.GetEngine(ctx). - Join("INNER", "team_repo", "team_repo.team_id = team.id"). - Where("team.org_id = ?", repo.OwnerID). - And("team_repo.repo_id=?", repo.ID). - OrderBy("CASE WHEN name LIKE '" + OwnerTeamName + "' THEN '' ELSE name END"). - Find(&teams) -} - // IncrTeamRepoNum increases the number of repos for the given team by 1 func IncrTeamRepoNum(ctx context.Context, teamID int64) error { _, err := db.GetEngine(ctx).Incr("num_repos").ID(teamID).Update(new(Team)) diff --git a/models/organization/team_list.go b/models/organization/team_list.go new file mode 100644 index 000000000000..5d3bd555cc28 --- /dev/null +++ b/models/organization/team_list.go @@ -0,0 +1,128 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package organization + +import ( + "context" + "strings" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/perm" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + + "xorm.io/builder" +) + +type TeamList []*Team + +func (t TeamList) LoadUnits(ctx context.Context) error { + for _, team := range t { + if err := team.getUnits(ctx); err != nil { + return err + } + } + return nil +} + +func (t TeamList) UnitMaxAccess(tp unit.Type) perm.AccessMode { + maxAccess := perm.AccessModeNone + for _, team := range t { + if team.IsOwnerTeam() { + return perm.AccessModeOwner + } + for _, teamUnit := range team.Units { + if teamUnit.Type != tp { + continue + } + if teamUnit.AccessMode > maxAccess { + maxAccess = teamUnit.AccessMode + } + } + } + return maxAccess +} + +// SearchTeamOptions holds the search options +type SearchTeamOptions struct { + db.ListOptions + UserID int64 + Keyword string + OrgID int64 + IncludeDesc bool +} + +func (opts *SearchTeamOptions) toCond() builder.Cond { + cond := builder.NewCond() + + if len(opts.Keyword) > 0 { + lowerKeyword := strings.ToLower(opts.Keyword) + var keywordCond builder.Cond = builder.Like{"lower_name", lowerKeyword} + if opts.IncludeDesc { + keywordCond = keywordCond.Or(builder.Like{"LOWER(description)", lowerKeyword}) + } + cond = cond.And(keywordCond) + } + + if opts.OrgID > 0 { + cond = cond.And(builder.Eq{"`team`.org_id": opts.OrgID}) + } + + if opts.UserID > 0 { + cond = cond.And(builder.Eq{"team_user.uid": opts.UserID}) + } + + return cond +} + +// SearchTeam search for teams. Caller is responsible to check permissions. +func SearchTeam(opts *SearchTeamOptions) (TeamList, int64, error) { + sess := db.GetEngine(db.DefaultContext) + + opts.SetDefaultValues() + cond := opts.toCond() + + if opts.UserID > 0 { + sess = sess.Join("INNER", "team_user", "team_user.team_id = team.id") + } + sess = db.SetSessionPagination(sess, opts) + + teams := make([]*Team, 0, opts.PageSize) + count, err := sess.Where(cond).OrderBy("lower_name").FindAndCount(&teams) + if err != nil { + return nil, 0, err + } + + return teams, count, nil +} + +// GetRepoTeams gets the list of teams that has access to the repository +func GetRepoTeams(ctx context.Context, repo *repo_model.Repository) (teams TeamList, err error) { + return teams, db.GetEngine(ctx). + Join("INNER", "team_repo", "team_repo.team_id = team.id"). + Where("team.org_id = ?", repo.OwnerID). + And("team_repo.repo_id=?", repo.ID). + OrderBy("CASE WHEN name LIKE '" + OwnerTeamName + "' THEN '' ELSE name END"). + Find(&teams) +} + +// GetUserOrgTeams returns all teams that user belongs to in given organization. +func GetUserOrgTeams(ctx context.Context, orgID, userID int64) (teams TeamList, err error) { + return teams, db.GetEngine(ctx). + Join("INNER", "team_user", "team_user.team_id = team.id"). + Where("team.org_id = ?", orgID). + And("team_user.uid=?", userID). + Find(&teams) +} + +// GetUserRepoTeams returns user repo's teams +func GetUserRepoTeams(ctx context.Context, orgID, userID, repoID int64) (teams TeamList, err error) { + return teams, db.GetEngine(ctx). + Join("INNER", "team_user", "team_user.team_id = team.id"). + Join("INNER", "team_repo", "team_repo.team_id = team.id"). + Where("team.org_id = ?", orgID). + And("team_user.uid=?", userID). + And("team_repo.repo_id=?", repoID). + Find(&teams) +} diff --git a/models/organization/team_user.go b/models/organization/team_user.go index 7a024f1c6d9b..816daf3d34b3 100644 --- a/models/organization/team_user.go +++ b/models/organization/team_user.go @@ -72,26 +72,6 @@ func GetTeamMembers(ctx context.Context, opts *SearchMembersOptions) ([]*user_mo return members, nil } -// GetUserOrgTeams returns all teams that user belongs to in given organization. -func GetUserOrgTeams(ctx context.Context, orgID, userID int64) (teams []*Team, err error) { - return teams, db.GetEngine(ctx). - Join("INNER", "team_user", "team_user.team_id = team.id"). - Where("team.org_id = ?", orgID). - And("team_user.uid=?", userID). - Find(&teams) -} - -// GetUserRepoTeams returns user repo's teams -func GetUserRepoTeams(ctx context.Context, orgID, userID, repoID int64) (teams []*Team, err error) { - return teams, db.GetEngine(ctx). - Join("INNER", "team_user", "team_user.team_id = team.id"). - Join("INNER", "team_repo", "team_repo.team_id = team.id"). - Where("team.org_id = ?", orgID). - And("team_user.uid=?", userID). - And("team_repo.repo_id=?", repoID). - Find(&teams) -} - // IsUserInTeams returns if a user in some teams func IsUserInTeams(ctx context.Context, userID int64, teamIDs []int64) (bool, error) { return db.GetEngine(ctx).Where("uid=?", userID).In("team_id", teamIDs).Exist(new(TeamUser)) diff --git a/models/project/project.go b/models/project/project.go index f432d0bc4c5b..8bac9115ba90 100644 --- a/models/project/project.go +++ b/models/project/project.go @@ -8,6 +8,9 @@ import ( "fmt" "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" @@ -78,12 +81,15 @@ func (err ErrProjectBoardNotExist) Unwrap() error { // Project represents a project board type Project struct { - ID int64 `xorm:"pk autoincr"` - Title string `xorm:"INDEX NOT NULL"` - Description string `xorm:"TEXT"` - RepoID int64 `xorm:"INDEX"` - CreatorID int64 `xorm:"NOT NULL"` - IsClosed bool `xorm:"INDEX"` + ID int64 `xorm:"pk autoincr"` + Title string `xorm:"INDEX NOT NULL"` + Description string `xorm:"TEXT"` + OwnerID int64 `xorm:"INDEX"` + Owner *user_model.User `xorm:"-"` + RepoID int64 `xorm:"INDEX"` + Repo *repo_model.Repository `xorm:"-"` + CreatorID int64 `xorm:"NOT NULL"` + IsClosed bool `xorm:"INDEX"` BoardType BoardType Type Type @@ -94,6 +100,46 @@ type Project struct { ClosedDateUnix timeutil.TimeStamp } +func (p *Project) LoadOwner(ctx context.Context) (err error) { + if p.Owner != nil { + return nil + } + p.Owner, err = user_model.GetUserByID(ctx, p.OwnerID) + return err +} + +func (p *Project) LoadRepo(ctx context.Context) (err error) { + if p.RepoID == 0 || p.Repo != nil { + return nil + } + p.Repo, err = repo_model.GetRepositoryByID(ctx, p.RepoID) + return err +} + +func (p *Project) Link() string { + if p.OwnerID > 0 { + err := p.LoadOwner(db.DefaultContext) + if err != nil { + log.Error("LoadOwner: %v", err) + return "" + } + return fmt.Sprintf("/%s/-/projects/%d", p.Owner.Name, p.ID) + } + if p.RepoID > 0 { + err := p.LoadRepo(db.DefaultContext) + if err != nil { + log.Error("LoadRepo: %v", err) + return "" + } + return fmt.Sprintf("/%s/projects/%d", p.Repo.RepoPath(), p.ID) + } + return "" +} + +func (p *Project) IsOrganizationProject() bool { + return p.Type == TypeOrganization +} + func init() { db.RegisterModel(new(Project)) } @@ -110,7 +156,7 @@ func GetProjectsConfig() []ProjectsConfig { // IsTypeValid checks if a project type is valid func IsTypeValid(p Type) bool { switch p { - case TypeRepository: + case TypeRepository, TypeOrganization: return true default: return false @@ -119,6 +165,7 @@ func IsTypeValid(p Type) bool { // SearchOptions are options for GetProjects type SearchOptions struct { + OwnerID int64 RepoID int64 Page int IsClosed util.OptionalBool @@ -126,12 +173,11 @@ type SearchOptions struct { Type Type } -// GetProjects returns a list of all projects that have been created in the repository -func GetProjects(ctx context.Context, opts SearchOptions) ([]*Project, int64, error) { - e := db.GetEngine(ctx) - projects := make([]*Project, 0, setting.UI.IssuePagingNum) - - var cond builder.Cond = builder.Eq{"repo_id": opts.RepoID} +func (opts *SearchOptions) toConds() builder.Cond { + cond := builder.NewCond() + if opts.RepoID > 0 { + cond = cond.And(builder.Eq{"repo_id": opts.RepoID}) + } switch opts.IsClosed { case util.OptionalBoolTrue: cond = cond.And(builder.Eq{"is_closed": true}) @@ -142,6 +188,22 @@ func GetProjects(ctx context.Context, opts SearchOptions) ([]*Project, int64, er if opts.Type > 0 { cond = cond.And(builder.Eq{"type": opts.Type}) } + if opts.OwnerID > 0 { + cond = cond.And(builder.Eq{"owner_id": opts.OwnerID}) + } + return cond +} + +// CountProjects counts projects +func CountProjects(ctx context.Context, opts SearchOptions) (int64, error) { + return db.GetEngine(ctx).Where(opts.toConds()).Count(new(Project)) +} + +// FindProjects returns a list of all projects that have been created in the repository +func FindProjects(ctx context.Context, opts SearchOptions) ([]*Project, int64, error) { + e := db.GetEngine(ctx) + projects := make([]*Project, 0, setting.UI.IssuePagingNum) + cond := opts.toConds() count, err := e.Where(cond).Count(new(Project)) if err != nil { @@ -188,8 +250,10 @@ func NewProject(p *Project) error { return err } - if _, err := db.Exec(ctx, "UPDATE `repository` SET num_projects = num_projects + 1 WHERE id = ?", p.RepoID); err != nil { - return err + if p.RepoID > 0 { + if _, err := db.Exec(ctx, "UPDATE `repository` SET num_projects = num_projects + 1 WHERE id = ?", p.RepoID); err != nil { + return err + } } if err := createBoardsForProjectsType(ctx, p); err != nil { diff --git a/models/project/project_test.go b/models/project/project_test.go index 4fde0fc7ce3a..c2d9005c43b8 100644 --- a/models/project/project_test.go +++ b/models/project/project_test.go @@ -22,7 +22,7 @@ func TestIsProjectTypeValid(t *testing.T) { }{ {TypeIndividual, false}, {TypeRepository, true}, - {TypeOrganization, false}, + {TypeOrganization, true}, {UnknownType, false}, } @@ -34,13 +34,13 @@ func TestIsProjectTypeValid(t *testing.T) { func TestGetProjects(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) - projects, _, err := GetProjects(db.DefaultContext, SearchOptions{RepoID: 1}) + projects, _, err := FindProjects(db.DefaultContext, SearchOptions{RepoID: 1}) assert.NoError(t, err) // 1 value for this repo exists in the fixtures assert.Len(t, projects, 1) - projects, _, err = GetProjects(db.DefaultContext, SearchOptions{RepoID: 3}) + projects, _, err = FindProjects(db.DefaultContext, SearchOptions{RepoID: 3}) assert.NoError(t, err) // 1 value for this repo exists in the fixtures diff --git a/modules/context/org.go b/modules/context/org.go index 39df29a86072..ff3a5ae7ec3f 100644 --- a/modules/context/org.go +++ b/modules/context/org.go @@ -9,7 +9,9 @@ import ( "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/perm" + "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" ) @@ -28,6 +30,32 @@ type Organization struct { Teams []*organization.Team } +func (org *Organization) CanWriteUnit(ctx *Context, unitType unit.Type) bool { + if ctx.Doer == nil { + return false + } + return org.UnitPermission(ctx, ctx.Doer.ID, unitType) >= perm.AccessModeWrite +} + +func (org *Organization) UnitPermission(ctx *Context, doerID int64, unitType unit.Type) perm.AccessMode { + if doerID > 0 { + teams, err := organization.GetUserOrgTeams(ctx, org.Organization.ID, doerID) + if err != nil { + log.Error("GetUserOrgTeams: %v", err) + return perm.AccessModeNone + } + if len(teams) > 0 { + return teams.UnitMaxAccess(unitType) + } + } + + if org.Organization.Visibility == structs.VisibleTypePublic { + return perm.AccessModeRead + } + + return perm.AccessModeNone +} + // HandleOrgAssignment handles organization assignment func HandleOrgAssignment(ctx *Context, args ...bool) { var ( diff --git a/routers/web/org/main_test.go b/routers/web/org/main_test.go new file mode 100644 index 000000000000..41323a3601ea --- /dev/null +++ b/routers/web/org/main_test.go @@ -0,0 +1,17 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org_test + +import ( + "path/filepath" + "testing" + + "code.gitea.io/gitea/models/unittest" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m, &unittest.TestOptions{ + GiteaRootPath: filepath.Join("..", "..", ".."), + }) +} diff --git a/routers/web/org/projects.go b/routers/web/org/projects.go new file mode 100644 index 000000000000..1ce44d4866d8 --- /dev/null +++ b/routers/web/org/projects.go @@ -0,0 +1,670 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + + issues_model "code.gitea.io/gitea/models/issues" + project_model "code.gitea.io/gitea/models/project" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/forms" +) + +const ( + tplProjects base.TplName = "org/projects/list" + tplProjectsNew base.TplName = "org/projects/new" + tplProjectsView base.TplName = "org/projects/view" + tplGenericProjectsNew base.TplName = "user/project" +) + +// MustEnableProjects check if projects are enabled in settings +func MustEnableProjects(ctx *context.Context) { + if unit.TypeProjects.UnitGlobalDisabled() { + ctx.NotFound("EnableKanbanBoard", nil) + return + } +} + +// Projects renders the home page of projects +func Projects(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.project_board") + + sortType := ctx.FormTrim("sort") + + isShowClosed := strings.ToLower(ctx.FormTrim("state")) == "closed" + page := ctx.FormInt("page") + if page <= 1 { + page = 1 + } + + projects, total, err := project_model.FindProjects(ctx, project_model.SearchOptions{ + OwnerID: ctx.ContextUser.ID, + Page: page, + IsClosed: util.OptionalBoolOf(isShowClosed), + SortType: sortType, + Type: project_model.TypeOrganization, + }) + if err != nil { + ctx.ServerError("FindProjects", err) + return + } + + opTotal, err := project_model.CountProjects(ctx, project_model.SearchOptions{ + OwnerID: ctx.ContextUser.ID, + IsClosed: util.OptionalBoolOf(!isShowClosed), + Type: project_model.TypeOrganization, + }) + if err != nil { + ctx.ServerError("CountProjects", err) + return + } + + if isShowClosed { + ctx.Data["OpenCount"] = opTotal + ctx.Data["ClosedCount"] = total + } else { + ctx.Data["OpenCount"] = total + ctx.Data["ClosedCount"] = opTotal + } + + ctx.Data["Projects"] = projects + shared_user.RenderUserHeader(ctx) + + if isShowClosed { + ctx.Data["State"] = "closed" + } else { + ctx.Data["State"] = "open" + } + + for _, project := range projects { + project.RenderedContent = project.Description + } + + numPages := 0 + if total > 0 { + numPages = (int(total) - 1/setting.UI.IssuePagingNum) + } + + pager := context.NewPagination(int(total), setting.UI.IssuePagingNum, page, numPages) + pager.AddParam(ctx, "state", "State") + ctx.Data["Page"] = pager + + ctx.Data["CanWriteProjects"] = canWriteUnit(ctx) + ctx.Data["IsShowClosed"] = isShowClosed + ctx.Data["PageIsViewProjects"] = true + ctx.Data["SortType"] = sortType + + ctx.HTML(http.StatusOK, tplProjects) +} + +func canWriteUnit(ctx *context.Context) bool { + if ctx.ContextUser.IsOrganization() { + return ctx.Org.CanWriteUnit(ctx, unit.TypeProjects) + } + return ctx.Doer != nil && ctx.ContextUser.ID == ctx.Doer.ID +} + +// NewProject render creating a project page +func NewProject(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.projects.new") + ctx.Data["ProjectTypes"] = project_model.GetProjectsConfig() + ctx.Data["CanWriteProjects"] = canWriteUnit(ctx) + ctx.Data["HomeLink"] = ctx.ContextUser.HomeLink() + shared_user.RenderUserHeader(ctx) + ctx.HTML(http.StatusOK, tplProjectsNew) +} + +// NewProjectPost creates a new project +func NewProjectPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateProjectForm) + ctx.Data["Title"] = ctx.Tr("repo.projects.new") + shared_user.RenderUserHeader(ctx) + + if ctx.HasError() { + ctx.Data["CanWriteProjects"] = canWriteUnit(ctx) + ctx.Data["PageIsViewProjects"] = true + ctx.Data["ProjectTypes"] = project_model.GetProjectsConfig() + ctx.HTML(http.StatusOK, tplProjectsNew) + return + } + + if err := project_model.NewProject(&project_model.Project{ + OwnerID: ctx.ContextUser.ID, + Title: form.Title, + Description: form.Content, + CreatorID: ctx.Doer.ID, + BoardType: form.BoardType, + Type: project_model.TypeOrganization, + }); err != nil { + ctx.ServerError("NewProject", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.projects.create_success", form.Title)) + ctx.Redirect(ctx.ContextUser.HomeLink() + "/-/projects") +} + +// ChangeProjectStatus updates the status of a project between "open" and "close" +func ChangeProjectStatus(ctx *context.Context) { + toClose := false + switch ctx.Params(":action") { + case "open": + toClose = false + case "close": + toClose = true + default: + ctx.Redirect(ctx.Repo.RepoLink + "/projects") + } + id := ctx.ParamsInt64(":id") + + if err := project_model.ChangeProjectStatusByRepoIDAndID(ctx.Repo.Repository.ID, id, toClose); err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.NotFound("", err) + } else { + ctx.ServerError("ChangeProjectStatusByIDAndRepoID", err) + } + return + } + ctx.Redirect(ctx.Repo.RepoLink + "/projects?state=" + url.QueryEscape(ctx.Params(":action"))) +} + +// DeleteProject delete a project +func DeleteProject(ctx *context.Context) { + p, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + if p.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("", nil) + return + } + + if err := project_model.DeleteProjectByID(ctx, p.ID); err != nil { + ctx.Flash.Error("DeleteProjectByID: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.projects.deletion_success")) + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": ctx.Repo.RepoLink + "/projects", + }) +} + +// EditProject allows a project to be edited +func EditProject(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.projects.edit") + ctx.Data["PageIsEditProjects"] = true + ctx.Data["PageIsViewProjects"] = true + ctx.Data["CanWriteProjects"] = canWriteUnit(ctx) + shared_user.RenderUserHeader(ctx) + + p, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + if p.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("", nil) + return + } + + ctx.Data["title"] = p.Title + ctx.Data["content"] = p.Description + + ctx.HTML(http.StatusOK, tplProjectsNew) +} + +// EditProjectPost response for editing a project +func EditProjectPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateProjectForm) + ctx.Data["Title"] = ctx.Tr("repo.projects.edit") + ctx.Data["PageIsEditProjects"] = true + ctx.Data["PageIsViewProjects"] = true + ctx.Data["CanWriteProjects"] = canWriteUnit(ctx) + shared_user.RenderUserHeader(ctx) + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplProjectsNew) + return + } + + p, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + if p.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("", nil) + return + } + + p.Title = form.Title + p.Description = form.Content + if err = project_model.UpdateProject(ctx, p); err != nil { + ctx.ServerError("UpdateProjects", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.projects.edit_success", p.Title)) + ctx.Redirect(ctx.Repo.RepoLink + "/projects") +} + +// ViewProject renders the project board for a project +func ViewProject(ctx *context.Context) { + project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + if project.OwnerID != ctx.ContextUser.ID { + ctx.NotFound("", nil) + return + } + + boards, err := project_model.GetBoards(ctx, project.ID) + if err != nil { + ctx.ServerError("GetProjectBoards", err) + return + } + + if boards[0].ID == 0 { + boards[0].Title = ctx.Tr("repo.projects.type.uncategorized") + } + + issuesMap, err := issues_model.LoadIssuesFromBoardList(ctx, boards) + if err != nil { + ctx.ServerError("LoadIssuesOfBoards", err) + return + } + + 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: util.OptionalBoolTrue, + }); err == nil { + linkedPrsMap[issue.ID] = linkedPrs + } + } + } + } + + project.RenderedContent = project.Description + ctx.Data["LinkedPRs"] = linkedPrsMap + ctx.Data["PageIsViewProjects"] = true + ctx.Data["CanWriteProjects"] = canWriteUnit(ctx) + ctx.Data["Project"] = project + ctx.Data["IssuesMap"] = issuesMap + ctx.Data["Boards"] = boards + shared_user.RenderUserHeader(ctx) + + ctx.HTML(http.StatusOK, tplProjectsView) +} + +func getActionIssues(ctx *context.Context) []*issues_model.Issue { + commaSeparatedIssueIDs := ctx.FormString("issue_ids") + if len(commaSeparatedIssueIDs) == 0 { + return nil + } + issueIDs := make([]int64, 0, 10) + for _, stringIssueID := range strings.Split(commaSeparatedIssueIDs, ",") { + issueID, err := strconv.ParseInt(stringIssueID, 10, 64) + if err != nil { + ctx.ServerError("ParseInt", err) + return nil + } + issueIDs = append(issueIDs, issueID) + } + issues, err := issues_model.GetIssuesByIDs(ctx, issueIDs) + if err != nil { + ctx.ServerError("GetIssuesByIDs", err) + return nil + } + // Check access rights for all issues + issueUnitEnabled := ctx.Repo.CanRead(unit.TypeIssues) + prUnitEnabled := ctx.Repo.CanRead(unit.TypePullRequests) + for _, issue := range issues { + if issue.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("some issue's RepoID is incorrect", errors.New("some issue's RepoID is incorrect")) + return nil + } + if issue.IsPull && !prUnitEnabled || !issue.IsPull && !issueUnitEnabled { + ctx.NotFound("IssueOrPullRequestUnitNotAllowed", nil) + return nil + } + if err = issue.LoadAttributes(ctx); err != nil { + ctx.ServerError("LoadAttributes", err) + return nil + } + } + return issues +} + +// UpdateIssueProject change an issue's project +func UpdateIssueProject(ctx *context.Context) { + issues := getActionIssues(ctx) + if ctx.Written() { + return + } + + projectID := ctx.FormInt64("id") + for _, issue := range issues { + oldProjectID := issue.ProjectID() + if oldProjectID == projectID { + continue + } + + if err := issues_model.ChangeProjectAssign(issue, ctx.Doer, projectID); err != nil { + ctx.ServerError("ChangeProjectAssign", err) + return + } + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "ok": true, + }) +} + +// DeleteProjectBoard allows for the deletion of a project board +func DeleteProjectBoard(ctx *context.Context) { + 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.ParamsInt64(":id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + + pb, err := project_model.GetBoard(ctx, ctx.ParamsInt64(":boardID")) + if err != nil { + ctx.ServerError("GetProjectBoard", err) + return + } + if pb.ProjectID != ctx.ParamsInt64(":id") { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectBoard[%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("ProjectBoard[%d] is not in Owner[%d] as expected", pb.ID, ctx.ContextUser.ID), + }) + return + } + + if err := project_model.DeleteBoardByID(ctx.ParamsInt64(":boardID")); err != nil { + ctx.ServerError("DeleteProjectBoardByID", err) + return + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "ok": true, + }) +} + +// AddBoardToProjectPost allows a new board to be added to a project. +func AddBoardToProjectPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.EditProjectBoardForm) + + project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + + if err := project_model.NewBoard(&project_model.Board{ + ProjectID: project.ID, + Title: form.Title, + Color: form.Color, + CreatorID: ctx.Doer.ID, + }); err != nil { + ctx.ServerError("NewProjectBoard", err) + return + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "ok": true, + }) +} + +// CheckProjectBoardChangePermissions check permission +func CheckProjectBoardChangePermissions(ctx *context.Context) (*project_model.Project, *project_model.Board) { + 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.ParamsInt64(":id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return nil, nil + } + + board, err := project_model.GetBoard(ctx, ctx.ParamsInt64(":boardID")) + if err != nil { + ctx.ServerError("GetProjectBoard", err) + return nil, nil + } + if board.ProjectID != ctx.ParamsInt64(":id") { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectBoard[%d] is not in Project[%d] as expected", board.ID, project.ID), + }) + return nil, nil + } + + if project.OwnerID != ctx.ContextUser.ID { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectBoard[%d] is not in Repository[%d] as expected", board.ID, project.ID), + }) + return nil, nil + } + return project, board +} + +// EditProjectBoard allows a project board's to be updated +func EditProjectBoard(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.EditProjectBoardForm) + _, board := CheckProjectBoardChangePermissions(ctx) + if ctx.Written() { + return + } + + if form.Title != "" { + board.Title = form.Title + } + + board.Color = form.Color + + if form.Sorting != 0 { + board.Sorting = form.Sorting + } + + if err := project_model.UpdateBoard(ctx, board); err != nil { + ctx.ServerError("UpdateProjectBoard", err) + return + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "ok": true, + }) +} + +// SetDefaultProjectBoard set default board for uncategorized issues/pulls +func SetDefaultProjectBoard(ctx *context.Context) { + project, board := CheckProjectBoardChangePermissions(ctx) + if ctx.Written() { + return + } + + if err := project_model.SetDefaultBoard(project.ID, board.ID); err != nil { + ctx.ServerError("SetDefaultBoard", err) + return + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "ok": true, + }) +} + +// MoveIssues moves or keeps issues in a column and sorts them inside that column +func MoveIssues(ctx *context.Context) { + 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.ParamsInt64(":id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.NotFound("ProjectNotExist", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + if project.OwnerID != ctx.ContextUser.ID { + ctx.NotFound("InvalidRepoID", nil) + return + } + + var board *project_model.Board + + if ctx.ParamsInt64(":boardID") == 0 { + board = &project_model.Board{ + ID: 0, + ProjectID: project.ID, + Title: ctx.Tr("repo.projects.type.uncategorized"), + } + } else { + board, err = project_model.GetBoard(ctx, ctx.ParamsInt64(":boardID")) + if err != nil { + if project_model.IsErrProjectBoardNotExist(err) { + ctx.NotFound("ProjectBoardNotExist", nil) + } else { + ctx.ServerError("GetProjectBoard", err) + } + return + } + if board.ProjectID != project.ID { + ctx.NotFound("BoardNotInProject", 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) + } + + 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 { + if issues_model.IsErrIssueNotExist(err) { + ctx.NotFound("IssueNotExisting", nil) + } else { + ctx.ServerError("GetIssueByID", 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.MoveIssuesOnProjectBoard(board, sortedIssueIDs); err != nil { + ctx.ServerError("MoveIssuesOnProjectBoard", err) + return + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "ok": true, + }) +} diff --git a/routers/web/org/projects_test.go b/routers/web/org/projects_test.go new file mode 100644 index 000000000000..3450fa8e72db --- /dev/null +++ b/routers/web/org/projects_test.go @@ -0,0 +1,28 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org_test + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/routers/web/org" + + "github.com/stretchr/testify/assert" +) + +func TestCheckProjectBoardChangePermissions(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx := test.MockContext(t, "user2/-/projects/4/4") + test.LoadUser(t, ctx, 2) + ctx.ContextUser = ctx.Doer // user2 + ctx.SetParams(":id", "4") + ctx.SetParams(":boardID", "4") + + project, board := org.CheckProjectBoardChangePermissions(ctx) + assert.NotNil(t, project) + assert.NotNil(t, board) + assert.False(t, ctx.Written()) +} diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index 59ab717a1dde..44ac81f65d76 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -363,7 +363,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti } if ctx.Repo.CanWriteIssuesOrPulls(ctx.Params(":type") == "pulls") { - projects, _, err := project_model.GetProjects(ctx, project_model.SearchOptions{ + projects, _, err := project_model.FindProjects(ctx, project_model.SearchOptions{ RepoID: repo.ID, Type: project_model.TypeRepository, IsClosed: util.OptionalBoolOf(isShowClosed), @@ -474,8 +474,7 @@ func RetrieveRepoMilestonesAndAssignees(ctx *context.Context, repo *repo_model.R func retrieveProjects(ctx *context.Context, repo *repo_model.Repository) { var err error - - ctx.Data["OpenProjects"], _, err = project_model.GetProjects(ctx, project_model.SearchOptions{ + projects, _, err := project_model.FindProjects(ctx, project_model.SearchOptions{ RepoID: repo.ID, Page: -1, IsClosed: util.OptionalBoolFalse, @@ -485,8 +484,20 @@ func retrieveProjects(ctx *context.Context, repo *repo_model.Repository) { ctx.ServerError("GetProjects", err) return } + projects2, _, err := project_model.FindProjects(ctx, project_model.SearchOptions{ + OwnerID: repo.OwnerID, + Page: -1, + IsClosed: util.OptionalBoolFalse, + Type: project_model.TypeOrganization, + }) + if err != nil { + ctx.ServerError("GetProjects", err) + return + } - ctx.Data["ClosedProjects"], _, err = project_model.GetProjects(ctx, project_model.SearchOptions{ + ctx.Data["OpenProjects"] = append(projects, projects2...) + + projects, _, err = project_model.FindProjects(ctx, project_model.SearchOptions{ RepoID: repo.ID, Page: -1, IsClosed: util.OptionalBoolTrue, @@ -496,6 +507,18 @@ func retrieveProjects(ctx *context.Context, repo *repo_model.Repository) { ctx.ServerError("GetProjects", err) return } + projects2, _, err = project_model.FindProjects(ctx, project_model.SearchOptions{ + OwnerID: repo.OwnerID, + Page: -1, + IsClosed: util.OptionalBoolTrue, + Type: project_model.TypeOrganization, + }) + if err != nil { + ctx.ServerError("GetProjects", err) + return + } + + ctx.Data["ClosedProjects"] = append(projects, projects2...) } // repoReviewerSelection items to bee shown @@ -988,7 +1011,7 @@ func ValidateRepoMetas(ctx *context.Context, form forms.CreateIssueForm, isPull ctx.ServerError("GetProjectByID", err) return nil, nil, 0, 0 } - if p.RepoID != ctx.Repo.Repository.ID { + if p.RepoID != ctx.Repo.Repository.ID && p.OwnerID != ctx.Repo.Repository.OwnerID { ctx.NotFound("", nil) return nil, nil, 0, 0 } diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go index 75cd290b8f0c..3becf799c552 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -70,7 +70,7 @@ func Projects(ctx *context.Context) { total = repo.NumClosedProjects } - projects, count, err := project_model.GetProjects(ctx, project_model.SearchOptions{ + projects, count, err := project_model.FindProjects(ctx, project_model.SearchOptions{ RepoID: repo.ID, Page: page, IsClosed: util.OptionalBoolOf(isShowClosed), @@ -112,7 +112,7 @@ func Projects(ctx *context.Context) { pager.AddParam(ctx, "state", "State") ctx.Data["Page"] = pager - ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects) + ctx.Data["CanWriteProjects"] = true ctx.Data["IsShowClosed"] = isShowClosed ctx.Data["IsProjectsPage"] = true ctx.Data["SortType"] = sortType @@ -653,47 +653,3 @@ func MoveIssues(ctx *context.Context) { "ok": true, }) } - -// CreateProject renders the generic project creation page -func CreateProject(ctx *context.Context) { - ctx.Data["Title"] = ctx.Tr("repo.projects.new") - ctx.Data["ProjectTypes"] = project_model.GetProjectsConfig() - ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects) - - ctx.HTML(http.StatusOK, tplGenericProjectsNew) -} - -// CreateProjectPost creates an individual and/or organization project -func CreateProjectPost(ctx *context.Context, form forms.UserCreateProjectForm) { - user := checkContextUser(ctx, form.UID) - if ctx.Written() { - return - } - - ctx.Data["ContextUser"] = user - - if ctx.HasError() { - ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects) - ctx.HTML(http.StatusOK, tplGenericProjectsNew) - return - } - - projectType := project_model.TypeIndividual - if user.IsOrganization() { - projectType = project_model.TypeOrganization - } - - if err := project_model.NewProject(&project_model.Project{ - Title: form.Title, - Description: form.Content, - CreatorID: user.ID, - BoardType: form.BoardType, - Type: projectType, - }); err != nil { - ctx.ServerError("NewProject", err) - return - } - - ctx.Flash.Success(ctx.Tr("repo.projects.create_success", form.Title)) - ctx.Redirect(setting.AppSubURL + "/") -} diff --git a/routers/web/shared/user/header.go b/routers/web/shared/user/header.go new file mode 100644 index 000000000000..94e59e2a490f --- /dev/null +++ b/routers/web/shared/user/header.go @@ -0,0 +1,14 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/setting" +) + +func RenderUserHeader(ctx *context.Context) { + ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled + ctx.Data["ContextUser"] = ctx.ContextUser +} diff --git a/routers/web/user/package.go b/routers/web/user/package.go index c0aba7583fc0..ed4f0dd79727 100644 --- a/routers/web/user/package.go +++ b/routers/web/user/package.go @@ -19,6 +19,7 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" + shared_user "code.gitea.io/gitea/routers/web/shared/user" "code.gitea.io/gitea/services/forms" packages_service "code.gitea.io/gitea/services/packages" ) @@ -83,10 +84,10 @@ func ListPackages(ctx *context.Context) { return } + shared_user.RenderUserHeader(ctx) + ctx.Data["Title"] = ctx.Tr("packages.title") ctx.Data["IsPackagesPage"] = true - ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled - ctx.Data["ContextUser"] = ctx.ContextUser ctx.Data["Query"] = query ctx.Data["PackageType"] = packageType ctx.Data["AvailableTypes"] = packages_model.TypeList @@ -156,10 +157,10 @@ func RedirectToLastVersion(ctx *context.Context) { func ViewPackageVersion(ctx *context.Context) { pd := ctx.Package.Descriptor + shared_user.RenderUserHeader(ctx) + ctx.Data["Title"] = pd.Package.Name ctx.Data["IsPackagesPage"] = true - ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled - ctx.Data["ContextUser"] = ctx.ContextUser ctx.Data["PackageDescriptor"] = pd var ( @@ -235,10 +236,10 @@ func ListPackageVersions(ctx *context.Context) { query := ctx.FormTrim("q") sort := ctx.FormTrim("sort") + shared_user.RenderUserHeader(ctx) + ctx.Data["Title"] = ctx.Tr("packages.title") ctx.Data["IsPackagesPage"] = true - ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled - ctx.Data["ContextUser"] = ctx.ContextUser ctx.Data["PackageDescriptor"] = &packages_model.PackageDescriptor{ Package: p, Owner: ctx.Package.Owner, @@ -311,10 +312,10 @@ func ListPackageVersions(ctx *context.Context) { func PackageSettings(ctx *context.Context) { pd := ctx.Package.Descriptor + shared_user.RenderUserHeader(ctx) + ctx.Data["Title"] = pd.Package.Name ctx.Data["IsPackagesPage"] = true - ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled - ctx.Data["ContextUser"] = ctx.ContextUser ctx.Data["PackageDescriptor"] = pd repos, _, _ := repo_model.GetUserRepositories(&repo_model.SearchRepoOptions{ diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go index 0002d56de01b..0e342991d616 100644 --- a/routers/web/user/profile.go +++ b/routers/web/user/profile.go @@ -224,7 +224,7 @@ func Profile(ctx *context.Context) { total = int(count) case "projects": - ctx.Data["OpenProjects"], _, err = project_model.GetProjects(ctx, project_model.SearchOptions{ + ctx.Data["OpenProjects"], _, err = project_model.FindProjects(ctx, project_model.SearchOptions{ Page: -1, IsClosed: util.OptionalBoolFalse, Type: project_model.TypeIndividual, diff --git a/routers/web/web.go b/routers/web/web.go index f0fedd071518..d37d82820de8 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -835,6 +835,46 @@ func RegisterRoutes(m *web.Route) { }) }, ignSignIn, context.PackageAssignment(), reqPackageAccess(perm.AccessModeRead)) } + + m.Group("/projects", func() { + m.Get("", org.Projects) + m.Get("/{id}", org.ViewProject) + m.Group("", func() { //nolint:dupl + m.Get("/new", org.NewProject) + m.Post("/new", web.Bind(forms.CreateProjectForm{}), org.NewProjectPost) + m.Group("/{id}", func() { + m.Post("", web.Bind(forms.EditProjectBoardForm{}), org.AddBoardToProjectPost) + m.Post("/delete", org.DeleteProject) + + m.Get("/edit", org.EditProject) + m.Post("/edit", web.Bind(forms.CreateProjectForm{}), org.EditProjectPost) + m.Post("/{action:open|close}", org.ChangeProjectStatus) + + m.Group("/{boardID}", func() { + m.Put("", web.Bind(forms.EditProjectBoardForm{}), org.EditProjectBoard) + m.Delete("", org.DeleteProjectBoard) + m.Post("/default", org.SetDefaultProjectBoard) + + m.Post("/move", org.MoveIssues) + }) + }) + }, reqSignIn, func(ctx *context.Context) { + if ctx.ContextUser == nil { + ctx.NotFound("NewProject", nil) + return + } + if ctx.ContextUser.IsOrganization() { + if !ctx.Org.CanWriteUnit(ctx, unit.TypeProjects) { + ctx.NotFound("NewProject", nil) + return + } + } else if ctx.ContextUser.ID != ctx.Doer.ID { + ctx.NotFound("NewProject", nil) + return + } + }) + }, repo.MustEnableProjects) + m.Get("/code", user.CodeSearch) }, context_service.UserAssignmentWeb()) @@ -1168,7 +1208,7 @@ func RegisterRoutes(m *web.Route) { m.Group("/projects", func() { m.Get("", repo.Projects) m.Get("/{id}", repo.ViewProject) - m.Group("", func() { + m.Group("", func() { //nolint:dupl m.Get("/new", repo.NewProject) m.Post("/new", web.Bind(forms.CreateProjectForm{}), repo.NewProjectPost) m.Group("/{id}", func() { diff --git a/services/context/user.go b/services/context/user.go index 9dc84c3ac15e..7642cba4e1f0 100644 --- a/services/context/user.go +++ b/services/context/user.go @@ -8,6 +8,7 @@ import ( "net/http" "strings" + org_model "code.gitea.io/gitea/models/organization" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/context" ) @@ -56,6 +57,14 @@ func userAssignment(ctx *context.Context, errCb func(int, string, interface{})) } else { errCb(http.StatusInternalServerError, "GetUserByName", err) } + } else { + if ctx.ContextUser.IsOrganization() { + if ctx.Org == nil { + ctx.Org = &context.Organization{} + } + ctx.Org.Organization = (*org_model.Organization)(ctx.ContextUser) + ctx.Data["Org"] = ctx.Org.Organization + } } } } diff --git a/templates/org/menu.tmpl b/templates/org/menu.tmpl index 87242b94d332..5f543424fce3 100644 --- a/templates/org/menu.tmpl +++ b/templates/org/menu.tmpl @@ -3,6 +3,9 @@ {{svg "octicon-repo"}} {{.locale.Tr "user.repositories"}} + + {{svg "octicon-project"}} {{.locale.Tr "user.projects"}} + {{if .IsPackageEnabled}} {{svg "octicon-package"}} {{.locale.Tr "packages.title"}} diff --git a/templates/org/projects/list.tmpl b/templates/org/projects/list.tmpl new file mode 100644 index 000000000000..544ed387423c --- /dev/null +++ b/templates/org/projects/list.tmpl @@ -0,0 +1,6 @@ +{{template "base/head" .}} +
+ {{template "user/overview/header" .}} + {{template "projects/list" .}} +
+{{template "base/footer" .}} diff --git a/templates/org/projects/new.tmpl b/templates/org/projects/new.tmpl new file mode 100644 index 000000000000..b3d6c6001e37 --- /dev/null +++ b/templates/org/projects/new.tmpl @@ -0,0 +1,6 @@ +{{template "base/head" .}} +
+ {{template "user/overview/header" .}} + {{template "projects/new" .}} +
+{{template "base/footer" .}} diff --git a/templates/org/projects/view.tmpl b/templates/org/projects/view.tmpl new file mode 100644 index 000000000000..03327e253001 --- /dev/null +++ b/templates/org/projects/view.tmpl @@ -0,0 +1,6 @@ +{{template "base/head" .}} +
+ {{template "user/overview/header" .}} + {{template "projects/view" .}} +
+{{template "base/footer" .}} diff --git a/templates/projects/list.tmpl b/templates/projects/list.tmpl new file mode 100644 index 000000000000..ae2eaec6eaa7 --- /dev/null +++ b/templates/projects/list.tmpl @@ -0,0 +1,98 @@ +
+
+ {{if .CanWriteProjects}} + +
+ {{end}} + + {{template "base/alert" .}} + + + +
+ {{range .Projects}} +
  • + {{svg "octicon-project"}} {{.Title}} +
    + {{$closedDate:= TimeSinceUnix .ClosedDateUnix $.locale}} + {{if .IsClosed}} + {{svg "octicon-clock"}} {{$.locale.Tr "repo.milestones.closed" $closedDate|Str2html}} + {{end}} + + {{svg "octicon-issue-opened" 16 "mr-3"}} + {{JsPrettyNumber .NumOpenIssues}} {{$.locale.Tr "repo.issues.open_title"}} + {{svg "octicon-check" 16 "mr-3"}} + {{JsPrettyNumber .NumClosedIssues}} {{$.locale.Tr "repo.issues.closed_title"}} + +
    + {{if and (or $.CanWriteIssues $.CanWritePulls) (not $.Repository.IsArchived)}} + + {{end}} + {{if .Description}} +
    + {{.RenderedContent|Str2html}} +
    + {{end}} +
  • + {{end}} + + {{template "base/paginate" .}} +
    +
    +
    + +{{if or .CanWriteIssues .CanWritePulls}} + +{{end}} diff --git a/templates/projects/new.tmpl b/templates/projects/new.tmpl new file mode 100644 index 000000000000..1069102792b1 --- /dev/null +++ b/templates/projects/new.tmpl @@ -0,0 +1,66 @@ +
    +
    + +
    +

    + {{if .PageIsEditProjects}} + {{.locale.Tr "repo.projects.edit"}} +
    {{.locale.Tr "repo.projects.edit_subheader"}}
    + {{else}} + {{.locale.Tr "repo.projects.new"}} +
    {{.locale.Tr "repo.projects.new_subheader"}}
    + {{end}} +

    + {{template "base/alert" .}} +
    + {{.CsrfTokenHtml}} +
    +
    + + +
    +
    + + +
    + + {{if not .PageIsEditProjects}} + + + {{end}} +
    +
    +
    +
    + {{if .PageIsEditProjects}} + + {{.locale.Tr "repo.milestones.cancel"}} + + + {{else}} + + {{end}} +
    +
    + +
    +
    +
    diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl new file mode 100644 index 000000000000..ac72acb82b6a --- /dev/null +++ b/templates/projects/view.tmpl @@ -0,0 +1,279 @@ +
    +
    +
    +
    +
    +
    + {{if .CanWriteProjects}} + {{.locale.Tr "new_project_board"}} + {{end}} + +
    +
    +
    + +
    +
    +
    + +
    + {{range $board := .Boards}} + +
    +
    +
    +
    + {{.NumIssues}} +
    + {{.Title}} +
    + {{if and $.CanWriteProjects (ne .ID 0)}} + + {{end}} +
    +
    + +
    + + {{range (index $.IssuesMap .ID)}} + + +
    +
    +
    + + {{if .IsPull}} + {{if .PullRequest.HasMerged}} + {{svg "octicon-git-merge" 16 "text purple"}} + {{else}} + {{if .IsClosed}} + {{svg "octicon-git-pull-request" 16 "text red"}} + {{else}} + {{svg "octicon-git-pull-request" 16 "text green"}} + {{end}} + {{end}} + {{else}} + {{if .IsClosed}} + {{svg "octicon-issue-closed" 16 "text red"}} + {{else}} + {{svg "octicon-issue-opened" 16 "text green"}} + {{end}} + {{end}} + + + {{.Title}} + +
    +
    + + {{.Repo.FullName}}#{{.Index}} + {{$timeStr := TimeSinceUnix .GetLastEventTimestamp $.locale}} + {{if .OriginalAuthor}} + {{$.locale.Tr .GetLastEventLabelFake $timeStr (.OriginalAuthor|Escape) | Safe}} + {{else if gt .Poster.ID 0}} + {{$.locale.Tr .GetLastEventLabel $timeStr (.Poster.HomeLink|Escape) (.Poster.GetDisplayName | Escape) | Safe}} + {{else}} + {{$.locale.Tr .GetLastEventLabelFake $timeStr (.Poster.GetDisplayName | Escape) | Safe}} + {{end}} + +
    + {{- if .MilestoneID}} + + {{- end}} + {{- range index $.LinkedPRs .ID}} + + {{- end}} +
    + + {{if or .Labels .Assignees}} +
    + {{range .Labels}} + {{.Name | RenderEmoji}} + {{end}} +
    + {{range .Assignees}} + {{avatar . 28 "mini mr-3"}} + {{end}} +
    +
    + {{end}} +
    + + + {{end}} +
    +
    + {{end}} +
    + +
    + +
    + +{{if or .CanWriteIssues .CanWritePulls}} + +{{end}} diff --git a/templates/repo/issue/view_content/sidebar.tmpl b/templates/repo/issue/view_content/sidebar.tmpl index 6cb00fdd1d24..ca947e36129e 100644 --- a/templates/repo/issue/view_content/sidebar.tmpl +++ b/templates/repo/issue/view_content/sidebar.tmpl @@ -219,8 +219,8 @@ {{.locale.Tr "repo.issues.new.open_projects"}} {{range .OpenProjects}} - - {{svg "octicon-project" 18 "mr-3"}} + + {{if .IsOrganizationProject}}{{svg "octicon-project-symlink" 18 "mr-3"}}{{else}}{{svg "octicon-project" 18 "mr-3"}}{{end}} {{.Title}} {{end}} @@ -231,8 +231,8 @@ {{.locale.Tr "repo.issues.new.closed_projects"}} {{range .ClosedProjects}} - - {{svg "octicon-project" 18 "mr-3"}} + + {{if .IsOrganizationProject}}{{svg "octicon-project-symlink" 18 "mr-3"}}{{else}}{{svg "octicon-project" 18 "mr-3"}}{{end}} {{.Title}} {{end}} @@ -243,8 +243,8 @@ {{.locale.Tr "repo.issues.new.no_projects"}}
    {{if .Issue.ProjectID}} - - {{svg "octicon-project" 18 "mr-3"}} + + {{if .IsOrganizationProject}}{{svg "octicon-project-symlink" 18 "mr-3"}}{{else}}{{svg "octicon-project" 18 "mr-3"}}{{end}} {{.Issue.Project.Title}} {{end}} diff --git a/templates/user/overview/header.tmpl b/templates/user/overview/header.tmpl index 61b19c60326f..8fb882718c97 100644 --- a/templates/user/overview/header.tmpl +++ b/templates/user/overview/header.tmpl @@ -22,6 +22,9 @@ {{svg "octicon-repo"}} {{.locale.Tr "user.repositories"}} + + {{svg "octicon-project"}} {{.locale.Tr "user.projects"}} + {{if (not .UnitPackagesGlobalDisabled)}} {{svg "octicon-package"}} {{.locale.Tr "packages.title"}} diff --git a/templates/user/profile.tmpl b/templates/user/profile.tmpl index 6c31723e0f3b..74211eb67b85 100644 --- a/templates/user/profile.tmpl +++ b/templates/user/profile.tmpl @@ -106,6 +106,9 @@ {{svg "octicon-repo"}} {{.locale.Tr "user.repositories"}} + + {{svg "octicon-project"}} {{.locale.Tr "user.projects"}} + {{if .IsPackageEnabled}} {{svg "octicon-package"}} {{.locale.Tr "packages.title"}}