From bd97736b9c7a16023bc9abf17be6157284f655b1 Mon Sep 17 00:00:00 2001
From: Lunny Xiao <xiaolunwen@gmail.com>
Date: Tue, 29 Mar 2022 22:16:31 +0800
Subject: [PATCH] Move project files into models/project sub package (#17704)

* Move project files into models/project sub package

* Fix test

* Fix test

* Fix test

* Fix build

* Fix test

* Fix template bug

* Fix bug

* Fix lint

* Fix test

* Fix import

* Improve codes

Co-authored-by: 6543 <6543@obermui.de>
---
 models/error.go                      |  38 ----
 models/issue.go                      |  21 +-
 models/issue_comment.go              |   9 +-
 models/issue_project.go              | 181 +++++++++++++++
 models/project/board.go              | 289 ++++++++++++++++++++++++
 models/project/issue.go              | 100 +++++++++
 models/project/main_test.go          |  23 ++
 models/{ => project}/project.go      | 112 ++++++----
 models/{ => project}/project_test.go |  22 +-
 models/project_board.go              | 321 ---------------------------
 models/project_issue.go              | 218 ------------------
 models/repo.go                       |   5 +-
 models/statistic.go                  |   5 +-
 routers/web/repo/issue.go            |  19 +-
 routers/web/repo/projects.go         | 146 +++++++-----
 routers/web/user/profile.go          |   5 +-
 services/forms/repo_form.go          |   5 +-
 templates/repo/projects/view.tmpl    |   4 +-
 18 files changed, 810 insertions(+), 713 deletions(-)
 create mode 100644 models/issue_project.go
 create mode 100644 models/project/board.go
 create mode 100644 models/project/issue.go
 create mode 100644 models/project/main_test.go
 rename models/{ => project}/project.go (67%)
 rename models/{ => project}/project_test.go (77%)
 delete mode 100644 models/project_board.go
 delete mode 100644 models/project_issue.go

diff --git a/models/error.go b/models/error.go
index 50d7175725..8ea2f2f8af 100644
--- a/models/error.go
+++ b/models/error.go
@@ -1063,44 +1063,6 @@ func (err ErrLabelNotExist) Error() string {
 	return fmt.Sprintf("label does not exist [label_id: %d]", err.LabelID)
 }
 
-// __________                   __               __
-// \______   \_______  ____    |__| ____   _____/  |_  ______
-//  |     ___/\_  __ \/  _ \   |  |/ __ \_/ ___\   __\/  ___/
-//  |    |     |  | \(  <_> )  |  \  ___/\  \___|  |  \___ \
-//  |____|     |__|   \____/\__|  |\___  >\___  >__| /____  >
-//                         \______|    \/     \/          \/
-
-// ErrProjectNotExist represents a "ProjectNotExist" kind of error.
-type ErrProjectNotExist struct {
-	ID     int64
-	RepoID int64
-}
-
-// IsErrProjectNotExist checks if an error is a ErrProjectNotExist
-func IsErrProjectNotExist(err error) bool {
-	_, ok := err.(ErrProjectNotExist)
-	return ok
-}
-
-func (err ErrProjectNotExist) Error() string {
-	return fmt.Sprintf("projects does not exist [id: %d]", err.ID)
-}
-
-// ErrProjectBoardNotExist represents a "ProjectBoardNotExist" kind of error.
-type ErrProjectBoardNotExist struct {
-	BoardID int64
-}
-
-// IsErrProjectBoardNotExist checks if an error is a ErrProjectBoardNotExist
-func IsErrProjectBoardNotExist(err error) bool {
-	_, ok := err.(ErrProjectBoardNotExist)
-	return ok
-}
-
-func (err ErrProjectBoardNotExist) Error() string {
-	return fmt.Sprintf("project board does not exist [id: %d]", err.BoardID)
-}
-
 //    _____  .__.__                   __
 //   /     \ |__|  |   ____   _______/  |_  ____   ____   ____
 //  /  \ /  \|  |  | _/ __ \ /  ___/\   __\/  _ \ /    \_/ __ \
diff --git a/models/issue.go b/models/issue.go
index 31f7a0edb0..d2a2b6a329 100644
--- a/models/issue.go
+++ b/models/issue.go
@@ -19,6 +19,7 @@ import (
 	"code.gitea.io/gitea/models/issues"
 	"code.gitea.io/gitea/models/organization"
 	"code.gitea.io/gitea/models/perm"
+	project_model "code.gitea.io/gitea/models/project"
 	repo_model "code.gitea.io/gitea/models/repo"
 	"code.gitea.io/gitea/models/unit"
 	user_model "code.gitea.io/gitea/models/user"
@@ -45,14 +46,14 @@ type Issue struct {
 	PosterID         int64                  `xorm:"INDEX"`
 	Poster           *user_model.User       `xorm:"-"`
 	OriginalAuthor   string
-	OriginalAuthorID int64      `xorm:"index"`
-	Title            string     `xorm:"name"`
-	Content          string     `xorm:"LONGTEXT"`
-	RenderedContent  string     `xorm:"-"`
-	Labels           []*Label   `xorm:"-"`
-	MilestoneID      int64      `xorm:"INDEX"`
-	Milestone        *Milestone `xorm:"-"`
-	Project          *Project   `xorm:"-"`
+	OriginalAuthorID int64                  `xorm:"index"`
+	Title            string                 `xorm:"name"`
+	Content          string                 `xorm:"LONGTEXT"`
+	RenderedContent  string                 `xorm:"-"`
+	Labels           []*Label               `xorm:"-"`
+	MilestoneID      int64                  `xorm:"INDEX"`
+	Milestone        *Milestone             `xorm:"-"`
+	Project          *project_model.Project `xorm:"-"`
 	Priority         int
 	AssigneeID       int64            `xorm:"-"`
 	Assignee         *user_model.User `xorm:"-"`
@@ -2135,7 +2136,7 @@ func deleteIssue(ctx context.Context, issue *Issue) error {
 		&IssueWatch{},
 		&Stopwatch{},
 		&TrackedTime{},
-		&ProjectIssue{},
+		&project_model.ProjectIssue{},
 		&repo_model.Attachment{},
 		&PullRequest{},
 	); err != nil {
@@ -2469,7 +2470,7 @@ func deleteIssuesByRepoID(sess db.Engine, repoID int64) (attachmentPaths []strin
 	}
 
 	if _, err = sess.In("issue_id", deleteCond).
-		Delete(&ProjectIssue{}); err != nil {
+		Delete(&project_model.ProjectIssue{}); err != nil {
 		return
 	}
 
diff --git a/models/issue_comment.go b/models/issue_comment.go
index 7927b0037b..500ed6d038 100644
--- a/models/issue_comment.go
+++ b/models/issue_comment.go
@@ -17,6 +17,7 @@ import (
 	"code.gitea.io/gitea/models/db"
 	"code.gitea.io/gitea/models/issues"
 	"code.gitea.io/gitea/models/organization"
+	project_model "code.gitea.io/gitea/models/project"
 	repo_model "code.gitea.io/gitea/models/repo"
 	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/modules/git"
@@ -204,8 +205,8 @@ type Comment struct {
 	RemovedLabels    []*Label `xorm:"-"`
 	OldProjectID     int64
 	ProjectID        int64
-	OldProject       *Project `xorm:"-"`
-	Project          *Project `xorm:"-"`
+	OldProject       *project_model.Project `xorm:"-"`
+	Project          *project_model.Project `xorm:"-"`
 	OldMilestoneID   int64
 	MilestoneID      int64
 	OldMilestone     *Milestone `xorm:"-"`
@@ -469,7 +470,7 @@ func (c *Comment) LoadLabel() error {
 // LoadProject if comment.Type is CommentTypeProject, then load project.
 func (c *Comment) LoadProject() error {
 	if c.OldProjectID > 0 {
-		var oldProject Project
+		var oldProject project_model.Project
 		has, err := db.GetEngine(db.DefaultContext).ID(c.OldProjectID).Get(&oldProject)
 		if err != nil {
 			return err
@@ -479,7 +480,7 @@ func (c *Comment) LoadProject() error {
 	}
 
 	if c.ProjectID > 0 {
-		var project Project
+		var project project_model.Project
 		has, err := db.GetEngine(db.DefaultContext).ID(c.ProjectID).Get(&project)
 		if err != nil {
 			return err
diff --git a/models/issue_project.go b/models/issue_project.go
new file mode 100644
index 0000000000..526ac95152
--- /dev/null
+++ b/models/issue_project.go
@@ -0,0 +1,181 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package models
+
+import (
+	"context"
+	"fmt"
+
+	"code.gitea.io/gitea/models/db"
+	project_model "code.gitea.io/gitea/models/project"
+	user_model "code.gitea.io/gitea/models/user"
+)
+
+// LoadProject load the project the issue was assigned to
+func (i *Issue) LoadProject() (err error) {
+	return i.loadProject(db.GetEngine(db.DefaultContext))
+}
+
+func (i *Issue) loadProject(e db.Engine) (err error) {
+	if i.Project == nil {
+		var p project_model.Project
+		if _, err = e.Table("project").
+			Join("INNER", "project_issue", "project.id=project_issue.project_id").
+			Where("project_issue.issue_id = ?", i.ID).
+			Get(&p); err != nil {
+			return err
+		}
+		i.Project = &p
+	}
+	return
+}
+
+// ProjectID return project id if issue was assigned to one
+func (i *Issue) ProjectID() int64 {
+	return i.projectID(db.GetEngine(db.DefaultContext))
+}
+
+func (i *Issue) projectID(e db.Engine) int64 {
+	var ip project_model.ProjectIssue
+	has, err := e.Where("issue_id=?", i.ID).Get(&ip)
+	if err != nil || !has {
+		return 0
+	}
+	return ip.ProjectID
+}
+
+// ProjectBoardID return project board id if issue was assigned to one
+func (i *Issue) ProjectBoardID() int64 {
+	return i.projectBoardID(db.GetEngine(db.DefaultContext))
+}
+
+func (i *Issue) projectBoardID(e db.Engine) int64 {
+	var ip project_model.ProjectIssue
+	has, err := e.Where("issue_id=?", i.ID).Get(&ip)
+	if err != nil || !has {
+		return 0
+	}
+	return ip.ProjectBoardID
+}
+
+// LoadIssuesFromBoard load issues assigned to this board
+func LoadIssuesFromBoard(b *project_model.Board) (IssueList, error) {
+	issueList := make([]*Issue, 0, 10)
+
+	if b.ID != 0 {
+		issues, err := Issues(&IssuesOptions{
+			ProjectBoardID: b.ID,
+			ProjectID:      b.ProjectID,
+		})
+		if err != nil {
+			return nil, err
+		}
+		issueList = issues
+	}
+
+	if b.Default {
+		issues, err := Issues(&IssuesOptions{
+			ProjectBoardID: -1, // Issues without ProjectBoardID
+			ProjectID:      b.ProjectID,
+		})
+		if err != nil {
+			return nil, err
+		}
+		issueList = append(issueList, issues...)
+	}
+
+	if err := IssueList(issueList).LoadComments(); err != nil {
+		return nil, err
+	}
+
+	return issueList, nil
+}
+
+// LoadIssuesFromBoardList load issues assigned to the boards
+func LoadIssuesFromBoardList(bs project_model.BoardList) (map[int64]IssueList, error) {
+	issuesMap := make(map[int64]IssueList, len(bs))
+	for i := range bs {
+		il, err := LoadIssuesFromBoard(bs[i])
+		if err != nil {
+			return nil, err
+		}
+		issuesMap[bs[i].ID] = il
+	}
+	return issuesMap, nil
+}
+
+// ChangeProjectAssign changes the project associated with an issue
+func ChangeProjectAssign(issue *Issue, doer *user_model.User, newProjectID int64) error {
+	ctx, committer, err := db.TxContext()
+	if err != nil {
+		return err
+	}
+	defer committer.Close()
+
+	if err := addUpdateIssueProject(ctx, issue, doer, newProjectID); err != nil {
+		return err
+	}
+
+	return committer.Commit()
+}
+
+func addUpdateIssueProject(ctx context.Context, issue *Issue, doer *user_model.User, newProjectID int64) error {
+	e := db.GetEngine(ctx)
+	oldProjectID := issue.projectID(e)
+
+	if _, err := e.Where("project_issue.issue_id=?", issue.ID).Delete(&project_model.ProjectIssue{}); err != nil {
+		return err
+	}
+
+	if err := issue.loadRepo(ctx); err != nil {
+		return err
+	}
+
+	if oldProjectID > 0 || newProjectID > 0 {
+		if _, err := createComment(ctx, &CreateCommentOptions{
+			Type:         CommentTypeProject,
+			Doer:         doer,
+			Repo:         issue.Repo,
+			Issue:        issue,
+			OldProjectID: oldProjectID,
+			ProjectID:    newProjectID,
+		}); err != nil {
+			return err
+		}
+	}
+
+	_, err := e.Insert(&project_model.ProjectIssue{
+		IssueID:   issue.ID,
+		ProjectID: newProjectID,
+	})
+	return err
+}
+
+// MoveIssueAcrossProjectBoards move a card from one board to another
+func MoveIssueAcrossProjectBoards(issue *Issue, board *project_model.Board) error {
+	ctx, committer, err := db.TxContext()
+	if err != nil {
+		return err
+	}
+	defer committer.Close()
+	sess := db.GetEngine(ctx)
+
+	var pis project_model.ProjectIssue
+	has, err := sess.Where("issue_id=?", issue.ID).Get(&pis)
+	if err != nil {
+		return err
+	}
+
+	if !has {
+		return fmt.Errorf("issue has to be added to a project first")
+	}
+
+	pis.ProjectBoardID = board.ID
+	if _, err := sess.ID(pis.ID).Cols("project_board_id").Update(&pis); err != nil {
+		return err
+	}
+
+	return committer.Commit()
+}
diff --git a/models/project/board.go b/models/project/board.go
new file mode 100644
index 0000000000..f770a18f59
--- /dev/null
+++ b/models/project/board.go
@@ -0,0 +1,289 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package project
+
+import (
+	"context"
+	"fmt"
+	"regexp"
+
+	"code.gitea.io/gitea/models/db"
+	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/modules/timeutil"
+
+	"xorm.io/builder"
+)
+
+type (
+	// BoardType is used to represent a project board type
+	BoardType uint8
+
+	// BoardList is a list of all project boards in a repository
+	BoardList []*Board
+)
+
+const (
+	// BoardTypeNone is a project board type that has no predefined columns
+	BoardTypeNone BoardType = iota
+
+	// BoardTypeBasicKanban is a project board type that has basic predefined columns
+	BoardTypeBasicKanban
+
+	// BoardTypeBugTriage is a project board type that has predefined columns suited to hunting down bugs
+	BoardTypeBugTriage
+)
+
+// BoardColorPattern is a regexp witch can validate BoardColor
+var BoardColorPattern = regexp.MustCompile("^#[0-9a-fA-F]{6}$")
+
+// Board is used to represent boards on a project
+type Board struct {
+	ID      int64 `xorm:"pk autoincr"`
+	Title   string
+	Default bool   `xorm:"NOT NULL DEFAULT false"` // issues not assigned to a specific board will be assigned to this board
+	Sorting int8   `xorm:"NOT NULL DEFAULT 0"`
+	Color   string `xorm:"VARCHAR(7)"`
+
+	ProjectID int64 `xorm:"INDEX NOT NULL"`
+	CreatorID int64 `xorm:"NOT NULL"`
+
+	CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
+	UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
+}
+
+// TableName return the real table name
+func (Board) TableName() string {
+	return "project_board"
+}
+
+// NumIssues return counter of all issues assigned to the board
+func (b *Board) NumIssues() int {
+	c, err := db.GetEngine(db.DefaultContext).Table("project_issue").
+		Where("project_id=?", b.ProjectID).
+		And("project_board_id=?", b.ID).
+		GroupBy("issue_id").
+		Cols("issue_id").
+		Count()
+	if err != nil {
+		return 0
+	}
+	return int(c)
+}
+
+func init() {
+	db.RegisterModel(new(Board))
+}
+
+// IsBoardTypeValid checks if the project board type is valid
+func IsBoardTypeValid(p BoardType) bool {
+	switch p {
+	case BoardTypeNone, BoardTypeBasicKanban, BoardTypeBugTriage:
+		return true
+	default:
+		return false
+	}
+}
+
+func createBoardsForProjectsType(ctx context.Context, project *Project) error {
+	var items []string
+
+	switch project.BoardType {
+
+	case BoardTypeBugTriage:
+		items = setting.Project.ProjectBoardBugTriageType
+
+	case BoardTypeBasicKanban:
+		items = setting.Project.ProjectBoardBasicKanbanType
+
+	case BoardTypeNone:
+		fallthrough
+	default:
+		return nil
+	}
+
+	if len(items) == 0 {
+		return nil
+	}
+
+	boards := make([]Board, 0, len(items))
+
+	for _, v := range items {
+		boards = append(boards, Board{
+			CreatedUnix: timeutil.TimeStampNow(),
+			CreatorID:   project.CreatorID,
+			Title:       v,
+			ProjectID:   project.ID,
+		})
+	}
+
+	return db.Insert(ctx, boards)
+}
+
+// NewBoard adds a new project board to a given project
+func NewBoard(board *Board) error {
+	if len(board.Color) != 0 && !BoardColorPattern.MatchString(board.Color) {
+		return fmt.Errorf("bad color code: %s", board.Color)
+	}
+
+	_, err := db.GetEngine(db.DefaultContext).Insert(board)
+	return err
+}
+
+// DeleteBoardByID removes all issues references to the project board.
+func DeleteBoardByID(boardID int64) error {
+	ctx, committer, err := db.TxContext()
+	if err != nil {
+		return err
+	}
+	defer committer.Close()
+
+	if err := deleteBoardByID(ctx, boardID); err != nil {
+		return err
+	}
+
+	return committer.Commit()
+}
+
+func deleteBoardByID(ctx context.Context, boardID int64) error {
+	e := db.GetEngine(ctx)
+	board, err := getBoard(e, boardID)
+	if err != nil {
+		if IsErrProjectBoardNotExist(err) {
+			return nil
+		}
+
+		return err
+	}
+
+	if err = board.removeIssues(e); err != nil {
+		return err
+	}
+
+	if _, err := e.ID(board.ID).Delete(board); err != nil {
+		return err
+	}
+	return nil
+}
+
+func deleteBoardByProjectID(e db.Engine, projectID int64) error {
+	_, err := e.Where("project_id=?", projectID).Delete(&Board{})
+	return err
+}
+
+// GetBoard fetches the current board of a project
+func GetBoard(boardID int64) (*Board, error) {
+	return getBoard(db.GetEngine(db.DefaultContext), boardID)
+}
+
+func getBoard(e db.Engine, boardID int64) (*Board, error) {
+	board := new(Board)
+
+	has, err := e.ID(boardID).Get(board)
+	if err != nil {
+		return nil, err
+	} else if !has {
+		return nil, ErrProjectBoardNotExist{BoardID: boardID}
+	}
+
+	return board, nil
+}
+
+// UpdateBoard updates a project board
+func UpdateBoard(board *Board) error {
+	return updateBoard(db.GetEngine(db.DefaultContext), board)
+}
+
+func updateBoard(e db.Engine, board *Board) error {
+	var fieldToUpdate []string
+
+	if board.Sorting != 0 {
+		fieldToUpdate = append(fieldToUpdate, "sorting")
+	}
+
+	if board.Title != "" {
+		fieldToUpdate = append(fieldToUpdate, "title")
+	}
+
+	if len(board.Color) != 0 && !BoardColorPattern.MatchString(board.Color) {
+		return fmt.Errorf("bad color code: %s", board.Color)
+	}
+	fieldToUpdate = append(fieldToUpdate, "color")
+
+	_, err := e.ID(board.ID).Cols(fieldToUpdate...).Update(board)
+
+	return err
+}
+
+// GetBoards fetches all boards related to a project
+// if no default board set, first board is a temporary "Uncategorized" board
+func GetBoards(projectID int64) (BoardList, error) {
+	return getBoards(db.GetEngine(db.DefaultContext), projectID)
+}
+
+func getBoards(e db.Engine, projectID int64) ([]*Board, error) {
+	boards := make([]*Board, 0, 5)
+
+	if err := e.Where("project_id=? AND `default`=?", projectID, false).OrderBy("Sorting").Find(&boards); err != nil {
+		return nil, err
+	}
+
+	defaultB, err := getDefaultBoard(e, projectID)
+	if err != nil {
+		return nil, err
+	}
+
+	return append([]*Board{defaultB}, boards...), nil
+}
+
+// getDefaultBoard return default board and create a dummy if none exist
+func getDefaultBoard(e db.Engine, projectID int64) (*Board, error) {
+	var board Board
+	exist, err := e.Where("project_id=? AND `default`=?", projectID, true).Get(&board)
+	if err != nil {
+		return nil, err
+	}
+	if exist {
+		return &board, nil
+	}
+
+	// represents a board for issues not assigned to one
+	return &Board{
+		ProjectID: projectID,
+		Title:     "Uncategorized",
+		Default:   true,
+	}, nil
+}
+
+// SetDefaultBoard represents a board for issues not assigned to one
+// if boardID is 0 unset default
+func SetDefaultBoard(projectID, boardID int64) error {
+	_, err := db.GetEngine(db.DefaultContext).Where(builder.Eq{
+		"project_id": projectID,
+		"`default`":  true,
+	}).Cols("`default`").Update(&Board{Default: false})
+	if err != nil {
+		return err
+	}
+
+	if boardID > 0 {
+		_, err = db.GetEngine(db.DefaultContext).ID(boardID).Where(builder.Eq{"project_id": projectID}).
+			Cols("`default`").Update(&Board{Default: true})
+	}
+
+	return err
+}
+
+// UpdateBoardSorting update project board sorting
+func UpdateBoardSorting(bs BoardList) error {
+	for i := range bs {
+		_, err := db.GetEngine(db.DefaultContext).ID(bs[i].ID).Cols(
+			"sorting",
+		).Update(bs[i])
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}
diff --git a/models/project/issue.go b/models/project/issue.go
new file mode 100644
index 0000000000..0976185c49
--- /dev/null
+++ b/models/project/issue.go
@@ -0,0 +1,100 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package project
+
+import (
+	"context"
+	"fmt"
+
+	"code.gitea.io/gitea/models/db"
+)
+
+// ProjectIssue saves relation from issue to a project
+type ProjectIssue struct { //revive:disable-line:exported
+	ID        int64 `xorm:"pk autoincr"`
+	IssueID   int64 `xorm:"INDEX"`
+	ProjectID int64 `xorm:"INDEX"`
+
+	// If 0, then it has not been added to a specific board in the project
+	ProjectBoardID int64 `xorm:"INDEX"`
+}
+
+func init() {
+	db.RegisterModel(new(ProjectIssue))
+}
+
+func deleteProjectIssuesByProjectID(e db.Engine, projectID int64) error {
+	_, err := e.Where("project_id=?", projectID).Delete(&ProjectIssue{})
+	return err
+}
+
+// NumIssues return counter of all issues assigned to a project
+func (p *Project) NumIssues() int {
+	c, err := db.GetEngine(db.DefaultContext).Table("project_issue").
+		Where("project_id=?", p.ID).
+		GroupBy("issue_id").
+		Cols("issue_id").
+		Count()
+	if err != nil {
+		return 0
+	}
+	return int(c)
+}
+
+// NumClosedIssues return counter of closed issues assigned to a project
+func (p *Project) NumClosedIssues() int {
+	c, err := db.GetEngine(db.DefaultContext).Table("project_issue").
+		Join("INNER", "issue", "project_issue.issue_id=issue.id").
+		Where("project_issue.project_id=? AND issue.is_closed=?", p.ID, true).
+		Cols("issue_id").
+		Count()
+	if err != nil {
+		return 0
+	}
+	return int(c)
+}
+
+// NumOpenIssues return counter of open issues assigned to a project
+func (p *Project) NumOpenIssues() int {
+	c, err := db.GetEngine(db.DefaultContext).Table("project_issue").
+		Join("INNER", "issue", "project_issue.issue_id=issue.id").
+		Where("project_issue.project_id=? AND issue.is_closed=?", p.ID, false).Count("issue.id")
+	if err != nil {
+		return 0
+	}
+	return int(c)
+}
+
+// MoveIssuesOnProjectBoard moves or keeps issues in a column and sorts them inside that column
+func MoveIssuesOnProjectBoard(board *Board, sortedIssueIDs map[int64]int64) error {
+	return db.WithTx(func(ctx context.Context) error {
+		sess := db.GetEngine(ctx)
+
+		issueIDs := make([]int64, 0, len(sortedIssueIDs))
+		for _, issueID := range sortedIssueIDs {
+			issueIDs = append(issueIDs, issueID)
+		}
+		count, err := sess.Table(new(ProjectIssue)).Where("project_id=?", board.ProjectID).In("issue_id", issueIDs).Count()
+		if err != nil {
+			return err
+		}
+		if int(count) != len(sortedIssueIDs) {
+			return fmt.Errorf("all issues have to be added to a project first")
+		}
+
+		for sorting, issueID := range sortedIssueIDs {
+			_, err = sess.Exec("UPDATE `project_issue` SET project_board_id=?, sorting=? WHERE issue_id=?", board.ID, sorting, issueID)
+			if err != nil {
+				return err
+			}
+		}
+		return nil
+	})
+}
+
+func (pb *Board) removeIssues(e db.Engine) error {
+	_, err := e.Exec("UPDATE `project_issue` SET project_board_id = 0 WHERE project_board_id = ? ", pb.ID)
+	return err
+}
diff --git a/models/project/main_test.go b/models/project/main_test.go
new file mode 100644
index 0000000000..5296a0f40e
--- /dev/null
+++ b/models/project/main_test.go
@@ -0,0 +1,23 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package project
+
+import (
+	"path/filepath"
+	"testing"
+
+	"code.gitea.io/gitea/models/unittest"
+
+	_ "code.gitea.io/gitea/models/repo"
+)
+
+func TestMain(m *testing.M) {
+	unittest.MainTest(m, filepath.Join("..", ".."),
+		"project.yml",
+		"project_board.yml",
+		"project_issue.yml",
+		"repository.yml",
+	)
+}
diff --git a/models/project.go b/models/project/project.go
similarity index 67%
rename from models/project.go
rename to models/project/project.go
index e6a650674b..a639879e78 100644
--- a/models/project.go
+++ b/models/project/project.go
@@ -2,9 +2,10 @@
 // Use of this source code is governed by a MIT-style
 // license that can be found in the LICENSE file.
 
-package models
+package project
 
 import (
+	"context"
 	"errors"
 	"fmt"
 
@@ -19,25 +20,56 @@ import (
 type (
 	// ProjectsConfig is used to identify the type of board that is being created
 	ProjectsConfig struct {
-		BoardType   ProjectBoardType
+		BoardType   BoardType
 		Translation string
 	}
 
-	// ProjectType is used to identify the type of project in question and ownership
-	ProjectType uint8
+	// Type is used to identify the type of project in question and ownership
+	Type uint8
 )
 
 const (
-	// ProjectTypeIndividual is a type of project board that is owned by an individual
-	ProjectTypeIndividual ProjectType = iota + 1
+	// TypeIndividual is a type of project board that is owned by an individual
+	TypeIndividual Type = iota + 1
 
-	// ProjectTypeRepository is a project that is tied to a repository
-	ProjectTypeRepository
+	// TypeRepository is a project that is tied to a repository
+	TypeRepository
 
-	// ProjectTypeOrganization is a project that is tied to an organisation
-	ProjectTypeOrganization
+	// TypeOrganization is a project that is tied to an organisation
+	TypeOrganization
 )
 
+// ErrProjectNotExist represents a "ProjectNotExist" kind of error.
+type ErrProjectNotExist struct {
+	ID     int64
+	RepoID int64
+}
+
+// IsErrProjectNotExist checks if an error is a ErrProjectNotExist
+func IsErrProjectNotExist(err error) bool {
+	_, ok := err.(ErrProjectNotExist)
+	return ok
+}
+
+func (err ErrProjectNotExist) Error() string {
+	return fmt.Sprintf("projects does not exist [id: %d]", err.ID)
+}
+
+// ErrProjectBoardNotExist represents a "ProjectBoardNotExist" kind of error.
+type ErrProjectBoardNotExist struct {
+	BoardID int64
+}
+
+// IsErrProjectBoardNotExist checks if an error is a ErrProjectBoardNotExist
+func IsErrProjectBoardNotExist(err error) bool {
+	_, ok := err.(ErrProjectBoardNotExist)
+	return ok
+}
+
+func (err ErrProjectBoardNotExist) Error() string {
+	return fmt.Sprintf("project board does not exist [id: %d]", err.BoardID)
+}
+
 // Project represents a project board
 type Project struct {
 	ID          int64  `xorm:"pk autoincr"`
@@ -46,8 +78,8 @@ type Project struct {
 	RepoID      int64  `xorm:"INDEX"`
 	CreatorID   int64  `xorm:"NOT NULL"`
 	IsClosed    bool   `xorm:"INDEX"`
-	BoardType   ProjectBoardType
-	Type        ProjectType
+	BoardType   BoardType
+	Type        Type
 
 	RenderedContent string `xorm:"-"`
 
@@ -63,37 +95,39 @@ func init() {
 // GetProjectsConfig retrieves the types of configurations projects could have
 func GetProjectsConfig() []ProjectsConfig {
 	return []ProjectsConfig{
-		{ProjectBoardTypeNone, "repo.projects.type.none"},
-		{ProjectBoardTypeBasicKanban, "repo.projects.type.basic_kanban"},
-		{ProjectBoardTypeBugTriage, "repo.projects.type.bug_triage"},
+		{BoardTypeNone, "repo.projects.type.none"},
+		{BoardTypeBasicKanban, "repo.projects.type.basic_kanban"},
+		{BoardTypeBugTriage, "repo.projects.type.bug_triage"},
 	}
 }
 
-// IsProjectTypeValid checks if a project type is valid
-func IsProjectTypeValid(p ProjectType) bool {
+// IsTypeValid checks if a project type is valid
+func IsTypeValid(p Type) bool {
 	switch p {
-	case ProjectTypeRepository:
+	case TypeRepository:
 		return true
 	default:
 		return false
 	}
 }
 
-// ProjectSearchOptions are options for GetProjects
-type ProjectSearchOptions struct {
+// SearchOptions are options for GetProjects
+type SearchOptions struct {
 	RepoID   int64
 	Page     int
 	IsClosed util.OptionalBool
 	SortType string
-	Type     ProjectType
+	Type     Type
 }
 
 // GetProjects returns a list of all projects that have been created in the repository
-func GetProjects(opts ProjectSearchOptions) ([]*Project, int64, error) {
-	return getProjects(db.GetEngine(db.DefaultContext), opts)
+func GetProjects(opts SearchOptions) ([]*Project, int64, error) {
+	return GetProjectsCtx(db.DefaultContext, opts)
 }
 
-func getProjects(e db.Engine, opts ProjectSearchOptions) ([]*Project, int64, error) {
+// GetProjectsCtx returns a list of all projects that have been created in the repository
+func GetProjectsCtx(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}
@@ -135,11 +169,11 @@ func getProjects(e db.Engine, opts ProjectSearchOptions) ([]*Project, int64, err
 
 // NewProject creates a new Project
 func NewProject(p *Project) error {
-	if !IsProjectBoardTypeValid(p.BoardType) {
-		p.BoardType = ProjectBoardTypeNone
+	if !IsBoardTypeValid(p.BoardType) {
+		p.BoardType = BoardTypeNone
 	}
 
-	if !IsProjectTypeValid(p.Type) {
+	if !IsTypeValid(p.Type) {
 		return errors.New("project type is not valid")
 	}
 
@@ -157,7 +191,7 @@ func NewProject(p *Project) error {
 		return err
 	}
 
-	if err := createBoardsForProjectsType(db.GetEngine(ctx), p); err != nil {
+	if err := createBoardsForProjectsType(ctx, p); err != nil {
 		return err
 	}
 
@@ -200,7 +234,7 @@ func updateRepositoryProjectCount(e db.Engine, repoID int64) error {
 		builder.Eq{
 			"`num_projects`": builder.Select("count(*)").From("`project`").
 				Where(builder.Eq{"`project`.`repo_id`": repoID}.
-					And(builder.Eq{"`project`.`type`": ProjectTypeRepository})),
+					And(builder.Eq{"`project`.`type`": TypeRepository})),
 		}).From("`repository`").Where(builder.Eq{"id": repoID})); err != nil {
 		return err
 	}
@@ -209,7 +243,7 @@ func updateRepositoryProjectCount(e db.Engine, repoID int64) error {
 		builder.Eq{
 			"`num_closed_projects`": builder.Select("count(*)").From("`project`").
 				Where(builder.Eq{"`project`.`repo_id`": repoID}.
-					And(builder.Eq{"`project`.`type`": ProjectTypeRepository}).
+					And(builder.Eq{"`project`.`type`": TypeRepository}).
 					And(builder.Eq{"`project`.`is_closed`": true})),
 		}).From("`repository`").Where(builder.Eq{"id": repoID})); err != nil {
 		return err
@@ -224,18 +258,17 @@ func ChangeProjectStatusByRepoIDAndID(repoID, projectID int64, isClosed bool) er
 		return err
 	}
 	defer committer.Close()
-	sess := db.GetEngine(ctx)
 
 	p := new(Project)
 
-	has, err := sess.ID(projectID).Where("repo_id = ?", repoID).Get(p)
+	has, err := db.GetEngine(ctx).ID(projectID).Where("repo_id = ?", repoID).Get(p)
 	if err != nil {
 		return err
 	} else if !has {
 		return ErrProjectNotExist{ID: projectID, RepoID: repoID}
 	}
 
-	if err := changeProjectStatus(sess, p, isClosed); err != nil {
+	if err := changeProjectStatus(ctx, p, isClosed); err != nil {
 		return err
 	}
 
@@ -250,16 +283,17 @@ func ChangeProjectStatus(p *Project, isClosed bool) error {
 	}
 	defer committer.Close()
 
-	if err := changeProjectStatus(db.GetEngine(ctx), p, isClosed); err != nil {
+	if err := changeProjectStatus(ctx, p, isClosed); err != nil {
 		return err
 	}
 
 	return committer.Commit()
 }
 
-func changeProjectStatus(e db.Engine, p *Project, isClosed bool) error {
+func changeProjectStatus(ctx context.Context, p *Project, isClosed bool) error {
 	p.IsClosed = isClosed
 	p.ClosedDateUnix = timeutil.TimeStampNow()
+	e := db.GetEngine(ctx)
 	count, err := e.ID(p.ID).Where("repo_id = ? AND is_closed = ?", p.RepoID, !isClosed).Cols("is_closed", "closed_date_unix").Update(p)
 	if err != nil {
 		return err
@@ -279,14 +313,16 @@ func DeleteProjectByID(id int64) error {
 	}
 	defer committer.Close()
 
-	if err := deleteProjectByID(db.GetEngine(ctx), id); err != nil {
+	if err := DeleteProjectByIDCtx(ctx, id); err != nil {
 		return err
 	}
 
 	return committer.Commit()
 }
 
-func deleteProjectByID(e db.Engine, id int64) error {
+// DeleteProjectByIDCtx deletes a project from a repository.
+func DeleteProjectByIDCtx(ctx context.Context, id int64) error {
+	e := db.GetEngine(ctx)
 	p, err := getProjectByID(e, id)
 	if err != nil {
 		if IsErrProjectNotExist(err) {
@@ -299,7 +335,7 @@ func deleteProjectByID(e db.Engine, id int64) error {
 		return err
 	}
 
-	if err := deleteProjectBoardByProjectID(e, id); err != nil {
+	if err := deleteBoardByProjectID(e, id); err != nil {
 		return err
 	}
 
diff --git a/models/project_test.go b/models/project/project_test.go
similarity index 77%
rename from models/project_test.go
rename to models/project/project_test.go
index 70dabb7674..211a890874 100644
--- a/models/project_test.go
+++ b/models/project/project_test.go
@@ -2,7 +2,7 @@
 // Use of this source code is governed by a MIT-style
 // license that can be found in the LICENSE file.
 
-package models
+package project
 
 import (
 	"testing"
@@ -14,33 +14,33 @@ import (
 )
 
 func TestIsProjectTypeValid(t *testing.T) {
-	const UnknownType ProjectType = 15
+	const UnknownType Type = 15
 
 	cases := []struct {
-		typ   ProjectType
+		typ   Type
 		valid bool
 	}{
-		{ProjectTypeIndividual, false},
-		{ProjectTypeRepository, true},
-		{ProjectTypeOrganization, false},
+		{TypeIndividual, false},
+		{TypeRepository, true},
+		{TypeOrganization, false},
 		{UnknownType, false},
 	}
 
 	for _, v := range cases {
-		assert.Equal(t, v.valid, IsProjectTypeValid(v.typ))
+		assert.Equal(t, v.valid, IsTypeValid(v.typ))
 	}
 }
 
 func TestGetProjects(t *testing.T) {
 	assert.NoError(t, unittest.PrepareTestDatabase())
 
-	projects, _, err := GetProjects(ProjectSearchOptions{RepoID: 1})
+	projects, _, err := GetProjects(SearchOptions{RepoID: 1})
 	assert.NoError(t, err)
 
 	// 1 value for this repo exists in the fixtures
 	assert.Len(t, projects, 1)
 
-	projects, _, err = GetProjects(ProjectSearchOptions{RepoID: 3})
+	projects, _, err = GetProjects(SearchOptions{RepoID: 3})
 	assert.NoError(t, err)
 
 	// 1 value for this repo exists in the fixtures
@@ -51,8 +51,8 @@ func TestProject(t *testing.T) {
 	assert.NoError(t, unittest.PrepareTestDatabase())
 
 	project := &Project{
-		Type:        ProjectTypeRepository,
-		BoardType:   ProjectBoardTypeBasicKanban,
+		Type:        TypeRepository,
+		BoardType:   BoardTypeBasicKanban,
 		Title:       "New Project",
 		RepoID:      1,
 		CreatedUnix: timeutil.TimeStampNow(),
diff --git a/models/project_board.go b/models/project_board.go
deleted file mode 100644
index d40cfd06f0..0000000000
--- a/models/project_board.go
+++ /dev/null
@@ -1,321 +0,0 @@
-// Copyright 2020 The Gitea Authors. All rights reserved.
-// Use of this source code is governed by a MIT-style
-// license that can be found in the LICENSE file.
-
-package models
-
-import (
-	"fmt"
-	"regexp"
-
-	"code.gitea.io/gitea/models/db"
-	"code.gitea.io/gitea/modules/setting"
-	"code.gitea.io/gitea/modules/timeutil"
-
-	"xorm.io/builder"
-)
-
-type (
-	// ProjectBoardType is used to represent a project board type
-	ProjectBoardType uint8
-
-	// ProjectBoardList is a list of all project boards in a repository
-	ProjectBoardList []*ProjectBoard
-)
-
-const (
-	// ProjectBoardTypeNone is a project board type that has no predefined columns
-	ProjectBoardTypeNone ProjectBoardType = iota
-
-	// ProjectBoardTypeBasicKanban is a project board type that has basic predefined columns
-	ProjectBoardTypeBasicKanban
-
-	// ProjectBoardTypeBugTriage is a project board type that has predefined columns suited to hunting down bugs
-	ProjectBoardTypeBugTriage
-)
-
-// BoardColorPattern is a regexp witch can validate BoardColor
-var BoardColorPattern = regexp.MustCompile("^#[0-9a-fA-F]{6}$")
-
-// ProjectBoard is used to represent boards on a project
-type ProjectBoard struct {
-	ID      int64 `xorm:"pk autoincr"`
-	Title   string
-	Default bool   `xorm:"NOT NULL DEFAULT false"` // issues not assigned to a specific board will be assigned to this board
-	Sorting int8   `xorm:"NOT NULL DEFAULT 0"`
-	Color   string `xorm:"VARCHAR(7)"`
-
-	ProjectID int64 `xorm:"INDEX NOT NULL"`
-	CreatorID int64 `xorm:"NOT NULL"`
-
-	CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
-	UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
-
-	Issues []*Issue `xorm:"-"`
-}
-
-func init() {
-	db.RegisterModel(new(ProjectBoard))
-}
-
-// IsProjectBoardTypeValid checks if the project board type is valid
-func IsProjectBoardTypeValid(p ProjectBoardType) bool {
-	switch p {
-	case ProjectBoardTypeNone, ProjectBoardTypeBasicKanban, ProjectBoardTypeBugTriage:
-		return true
-	default:
-		return false
-	}
-}
-
-func createBoardsForProjectsType(sess db.Engine, project *Project) error {
-	var items []string
-
-	switch project.BoardType {
-
-	case ProjectBoardTypeBugTriage:
-		items = setting.Project.ProjectBoardBugTriageType
-
-	case ProjectBoardTypeBasicKanban:
-		items = setting.Project.ProjectBoardBasicKanbanType
-
-	case ProjectBoardTypeNone:
-		fallthrough
-	default:
-		return nil
-	}
-
-	if len(items) == 0 {
-		return nil
-	}
-
-	boards := make([]ProjectBoard, 0, len(items))
-
-	for _, v := range items {
-		boards = append(boards, ProjectBoard{
-			CreatedUnix: timeutil.TimeStampNow(),
-			CreatorID:   project.CreatorID,
-			Title:       v,
-			ProjectID:   project.ID,
-		})
-	}
-
-	_, err := sess.Insert(boards)
-	return err
-}
-
-// NewProjectBoard adds a new project board to a given project
-func NewProjectBoard(board *ProjectBoard) error {
-	if len(board.Color) != 0 && !BoardColorPattern.MatchString(board.Color) {
-		return fmt.Errorf("bad color code: %s", board.Color)
-	}
-
-	_, err := db.GetEngine(db.DefaultContext).Insert(board)
-	return err
-}
-
-// DeleteProjectBoardByID removes all issues references to the project board.
-func DeleteProjectBoardByID(boardID int64) error {
-	ctx, committer, err := db.TxContext()
-	if err != nil {
-		return err
-	}
-	defer committer.Close()
-
-	if err := deleteProjectBoardByID(db.GetEngine(ctx), boardID); err != nil {
-		return err
-	}
-
-	return committer.Commit()
-}
-
-func deleteProjectBoardByID(e db.Engine, boardID int64) error {
-	board, err := getProjectBoard(e, boardID)
-	if err != nil {
-		if IsErrProjectBoardNotExist(err) {
-			return nil
-		}
-
-		return err
-	}
-
-	if err = board.removeIssues(e); err != nil {
-		return err
-	}
-
-	if _, err := e.ID(board.ID).Delete(board); err != nil {
-		return err
-	}
-	return nil
-}
-
-func deleteProjectBoardByProjectID(e db.Engine, projectID int64) error {
-	_, err := e.Where("project_id=?", projectID).Delete(&ProjectBoard{})
-	return err
-}
-
-// GetProjectBoard fetches the current board of a project
-func GetProjectBoard(boardID int64) (*ProjectBoard, error) {
-	return getProjectBoard(db.GetEngine(db.DefaultContext), boardID)
-}
-
-func getProjectBoard(e db.Engine, boardID int64) (*ProjectBoard, error) {
-	board := new(ProjectBoard)
-
-	has, err := e.ID(boardID).Get(board)
-	if err != nil {
-		return nil, err
-	} else if !has {
-		return nil, ErrProjectBoardNotExist{BoardID: boardID}
-	}
-
-	return board, nil
-}
-
-// UpdateProjectBoard updates a project board
-func UpdateProjectBoard(board *ProjectBoard) error {
-	return updateProjectBoard(db.GetEngine(db.DefaultContext), board)
-}
-
-func updateProjectBoard(e db.Engine, board *ProjectBoard) error {
-	var fieldToUpdate []string
-
-	if board.Sorting != 0 {
-		fieldToUpdate = append(fieldToUpdate, "sorting")
-	}
-
-	if board.Title != "" {
-		fieldToUpdate = append(fieldToUpdate, "title")
-	}
-
-	if len(board.Color) != 0 && !BoardColorPattern.MatchString(board.Color) {
-		return fmt.Errorf("bad color code: %s", board.Color)
-	}
-	fieldToUpdate = append(fieldToUpdate, "color")
-
-	_, err := e.ID(board.ID).Cols(fieldToUpdate...).Update(board)
-
-	return err
-}
-
-// GetProjectBoards fetches all boards related to a project
-// if no default board set, first board is a temporary "Uncategorized" board
-func GetProjectBoards(projectID int64) (ProjectBoardList, error) {
-	return getProjectBoards(db.GetEngine(db.DefaultContext), projectID)
-}
-
-func getProjectBoards(e db.Engine, projectID int64) ([]*ProjectBoard, error) {
-	boards := make([]*ProjectBoard, 0, 5)
-
-	if err := e.Where("project_id=? AND `default`=?", projectID, false).OrderBy("Sorting").Find(&boards); err != nil {
-		return nil, err
-	}
-
-	defaultB, err := getDefaultBoard(e, projectID)
-	if err != nil {
-		return nil, err
-	}
-
-	return append([]*ProjectBoard{defaultB}, boards...), nil
-}
-
-// getDefaultBoard return default board and create a dummy if none exist
-func getDefaultBoard(e db.Engine, projectID int64) (*ProjectBoard, error) {
-	var board ProjectBoard
-	exist, err := e.Where("project_id=? AND `default`=?", projectID, true).Get(&board)
-	if err != nil {
-		return nil, err
-	}
-	if exist {
-		return &board, nil
-	}
-
-	// represents a board for issues not assigned to one
-	return &ProjectBoard{
-		ProjectID: projectID,
-		Title:     "Uncategorized",
-		Default:   true,
-	}, nil
-}
-
-// SetDefaultBoard represents a board for issues not assigned to one
-// if boardID is 0 unset default
-func SetDefaultBoard(projectID, boardID int64) error {
-	_, err := db.GetEngine(db.DefaultContext).Where(builder.Eq{
-		"project_id": projectID,
-		"`default`":  true,
-	}).Cols("`default`").Update(&ProjectBoard{Default: false})
-	if err != nil {
-		return err
-	}
-
-	if boardID > 0 {
-		_, err = db.GetEngine(db.DefaultContext).ID(boardID).Where(builder.Eq{"project_id": projectID}).
-			Cols("`default`").Update(&ProjectBoard{Default: true})
-	}
-
-	return err
-}
-
-// LoadIssues load issues assigned to this board
-func (b *ProjectBoard) LoadIssues() (IssueList, error) {
-	issueList := make([]*Issue, 0, 10)
-
-	if b.ID != 0 {
-		issues, err := Issues(&IssuesOptions{
-			ProjectBoardID: b.ID,
-			ProjectID:      b.ProjectID,
-			SortType:       "project-column-sorting",
-		})
-		if err != nil {
-			return nil, err
-		}
-		issueList = issues
-	}
-
-	if b.Default {
-		issues, err := Issues(&IssuesOptions{
-			ProjectBoardID: -1, // Issues without ProjectBoardID
-			ProjectID:      b.ProjectID,
-			SortType:       "project-column-sorting",
-		})
-		if err != nil {
-			return nil, err
-		}
-		issueList = append(issueList, issues...)
-	}
-
-	if err := IssueList(issueList).LoadComments(); err != nil {
-		return nil, err
-	}
-
-	b.Issues = issueList
-	return issueList, nil
-}
-
-// LoadIssues load issues assigned to the boards
-func (bs ProjectBoardList) LoadIssues() (IssueList, error) {
-	issues := make(IssueList, 0, len(bs)*10)
-	for i := range bs {
-		il, err := bs[i].LoadIssues()
-		if err != nil {
-			return nil, err
-		}
-		bs[i].Issues = il
-		issues = append(issues, il...)
-	}
-	return issues, nil
-}
-
-// UpdateProjectBoardSorting update project board sorting
-func UpdateProjectBoardSorting(bs ProjectBoardList) error {
-	for i := range bs {
-		_, err := db.GetEngine(db.DefaultContext).ID(bs[i].ID).Cols(
-			"sorting",
-		).Update(bs[i])
-		if err != nil {
-			return err
-		}
-	}
-	return nil
-}
diff --git a/models/project_issue.go b/models/project_issue.go
deleted file mode 100644
index c7735addcc..0000000000
--- a/models/project_issue.go
+++ /dev/null
@@ -1,218 +0,0 @@
-// Copyright 2020 The Gitea Authors. All rights reserved.
-// Use of this source code is governed by a MIT-style
-// license that can be found in the LICENSE file.
-
-package models
-
-import (
-	"context"
-	"fmt"
-
-	"code.gitea.io/gitea/models/db"
-	user_model "code.gitea.io/gitea/models/user"
-)
-
-// ProjectIssue saves relation from issue to a project
-type ProjectIssue struct {
-	ID        int64 `xorm:"pk autoincr"`
-	IssueID   int64 `xorm:"INDEX"`
-	ProjectID int64 `xorm:"INDEX"`
-
-	// If 0, then it has not been added to a specific board in the project
-	ProjectBoardID int64 `xorm:"INDEX"`
-	Sorting        int64 `xorm:"NOT NULL DEFAULT 0"`
-}
-
-func init() {
-	db.RegisterModel(new(ProjectIssue))
-}
-
-func deleteProjectIssuesByProjectID(e db.Engine, projectID int64) error {
-	_, err := e.Where("project_id=?", projectID).Delete(&ProjectIssue{})
-	return err
-}
-
-//  ___
-// |_ _|___ ___ _   _  ___
-//  | |/ __/ __| | | |/ _ \
-//  | |\__ \__ \ |_| |  __/
-// |___|___/___/\__,_|\___|
-
-// LoadProject load the project the issue was assigned to
-func (i *Issue) LoadProject() (err error) {
-	return i.loadProject(db.GetEngine(db.DefaultContext))
-}
-
-func (i *Issue) loadProject(e db.Engine) (err error) {
-	if i.Project == nil {
-		var p Project
-		if _, err = e.Table("project").
-			Join("INNER", "project_issue", "project.id=project_issue.project_id").
-			Where("project_issue.issue_id = ?", i.ID).
-			Get(&p); err != nil {
-			return err
-		}
-		i.Project = &p
-	}
-	return
-}
-
-// ProjectID return project id if issue was assigned to one
-func (i *Issue) ProjectID() int64 {
-	return i.projectID(db.GetEngine(db.DefaultContext))
-}
-
-func (i *Issue) projectID(e db.Engine) int64 {
-	var ip ProjectIssue
-	has, err := e.Where("issue_id=?", i.ID).Get(&ip)
-	if err != nil || !has {
-		return 0
-	}
-	return ip.ProjectID
-}
-
-// ProjectBoardID return project board id if issue was assigned to one
-func (i *Issue) ProjectBoardID() int64 {
-	return i.projectBoardID(db.GetEngine(db.DefaultContext))
-}
-
-func (i *Issue) projectBoardID(e db.Engine) int64 {
-	var ip ProjectIssue
-	has, err := e.Where("issue_id=?", i.ID).Get(&ip)
-	if err != nil || !has {
-		return 0
-	}
-	return ip.ProjectBoardID
-}
-
-//  ____            _           _
-// |  _ \ _ __ ___ (_) ___  ___| |_
-// | |_) | '__/ _ \| |/ _ \/ __| __|
-// |  __/| | | (_) | |  __/ (__| |_
-// |_|   |_|  \___// |\___|\___|\__|
-//               |__/
-
-// NumIssues return counter of all issues assigned to a project
-func (p *Project) NumIssues() int {
-	c, err := db.GetEngine(db.DefaultContext).Table("project_issue").
-		Where("project_id=?", p.ID).
-		GroupBy("issue_id").
-		Cols("issue_id").
-		Count()
-	if err != nil {
-		return 0
-	}
-	return int(c)
-}
-
-// NumClosedIssues return counter of closed issues assigned to a project
-func (p *Project) NumClosedIssues() int {
-	c, err := db.GetEngine(db.DefaultContext).Table("project_issue").
-		Join("INNER", "issue", "project_issue.issue_id=issue.id").
-		Where("project_issue.project_id=? AND issue.is_closed=?", p.ID, true).
-		Cols("issue_id").
-		Count()
-	if err != nil {
-		return 0
-	}
-	return int(c)
-}
-
-// NumOpenIssues return counter of open issues assigned to a project
-func (p *Project) NumOpenIssues() int {
-	c, err := db.GetEngine(db.DefaultContext).Table("project_issue").
-		Join("INNER", "issue", "project_issue.issue_id=issue.id").
-		Where("project_issue.project_id=? AND issue.is_closed=?", p.ID, false).
-		Cols("issue_id").
-		Count()
-	if err != nil {
-		return 0
-	}
-	return int(c)
-}
-
-// ChangeProjectAssign changes the project associated with an issue
-func ChangeProjectAssign(issue *Issue, doer *user_model.User, newProjectID int64) error {
-	ctx, committer, err := db.TxContext()
-	if err != nil {
-		return err
-	}
-	defer committer.Close()
-
-	if err := addUpdateIssueProject(ctx, issue, doer, newProjectID); err != nil {
-		return err
-	}
-
-	return committer.Commit()
-}
-
-func addUpdateIssueProject(ctx context.Context, issue *Issue, doer *user_model.User, newProjectID int64) error {
-	e := db.GetEngine(ctx)
-	oldProjectID := issue.projectID(e)
-
-	if _, err := e.Where("project_issue.issue_id=?", issue.ID).Delete(&ProjectIssue{}); err != nil {
-		return err
-	}
-
-	if err := issue.loadRepo(ctx); err != nil {
-		return err
-	}
-
-	if oldProjectID > 0 || newProjectID > 0 {
-		if _, err := createComment(ctx, &CreateCommentOptions{
-			Type:         CommentTypeProject,
-			Doer:         doer,
-			Repo:         issue.Repo,
-			Issue:        issue,
-			OldProjectID: oldProjectID,
-			ProjectID:    newProjectID,
-		}); err != nil {
-			return err
-		}
-	}
-
-	_, err := e.Insert(&ProjectIssue{
-		IssueID:   issue.ID,
-		ProjectID: newProjectID,
-	})
-	return err
-}
-
-//  ____            _           _   ____                      _
-// |  _ \ _ __ ___ (_) ___  ___| |_| __ )  ___   __ _ _ __ __| |
-// | |_) | '__/ _ \| |/ _ \/ __| __|  _ \ / _ \ / _` | '__/ _` |
-// |  __/| | | (_) | |  __/ (__| |_| |_) | (_) | (_| | | | (_| |
-// |_|   |_|  \___// |\___|\___|\__|____/ \___/ \__,_|_|  \__,_|
-//               |__/
-
-// MoveIssuesOnProjectBoard moves or keeps issues in a column and sorts them inside that column
-func MoveIssuesOnProjectBoard(board *ProjectBoard, sortedIssueIDs map[int64]int64) error {
-	return db.WithTx(func(ctx context.Context) error {
-		sess := db.GetEngine(ctx)
-
-		issueIDs := make([]int64, 0, len(sortedIssueIDs))
-		for _, issueID := range sortedIssueIDs {
-			issueIDs = append(issueIDs, issueID)
-		}
-		count, err := sess.Table(new(ProjectIssue)).Where("project_id=?", board.ProjectID).In("issue_id", issueIDs).Count()
-		if err != nil {
-			return err
-		}
-		if int(count) != len(sortedIssueIDs) {
-			return fmt.Errorf("all issues have to be added to a project first")
-		}
-
-		for sorting, issueID := range sortedIssueIDs {
-			_, err = sess.Exec("UPDATE `project_issue` SET project_board_id=?, sorting=? WHERE issue_id=?", board.ID, sorting, issueID)
-			if err != nil {
-				return err
-			}
-		}
-		return nil
-	})
-}
-
-func (pb *ProjectBoard) removeIssues(e db.Engine) error {
-	_, err := e.Exec("UPDATE `project_issue` SET project_board_id = 0, sorting = 0 WHERE project_board_id = ? ", pb.ID)
-	return err
-}
diff --git a/models/repo.go b/models/repo.go
index 5a6a6b1a31..70539e849b 100644
--- a/models/repo.go
+++ b/models/repo.go
@@ -21,6 +21,7 @@ import (
 	"code.gitea.io/gitea/models/db"
 	"code.gitea.io/gitea/models/organization"
 	"code.gitea.io/gitea/models/perm"
+	project_model "code.gitea.io/gitea/models/project"
 	repo_model "code.gitea.io/gitea/models/repo"
 	"code.gitea.io/gitea/models/unit"
 	user_model "code.gitea.io/gitea/models/user"
@@ -748,14 +749,14 @@ func DeleteRepository(doer *user_model.User, uid, repoID int64) error {
 		}
 	}
 
-	projects, _, err := getProjects(sess, ProjectSearchOptions{
+	projects, _, err := project_model.GetProjectsCtx(ctx, project_model.SearchOptions{
 		RepoID: repoID,
 	})
 	if err != nil {
 		return fmt.Errorf("get projects: %v", err)
 	}
 	for i := range projects {
-		if err := deleteProjectByID(sess, projects[i].ID); err != nil {
+		if err := project_model.DeleteProjectByIDCtx(ctx, projects[i].ID); err != nil {
 			return fmt.Errorf("delete project [%d]: %v", projects[i].ID, err)
 		}
 	}
diff --git a/models/statistic.go b/models/statistic.go
index 67be4c5d42..a63cc2878b 100644
--- a/models/statistic.go
+++ b/models/statistic.go
@@ -9,6 +9,7 @@ import (
 	"code.gitea.io/gitea/models/auth"
 	"code.gitea.io/gitea/models/db"
 	"code.gitea.io/gitea/models/organization"
+	project_model "code.gitea.io/gitea/models/project"
 	repo_model "code.gitea.io/gitea/models/repo"
 	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/models/webhook"
@@ -106,7 +107,7 @@ func GetStatistic() (stats Statistic) {
 	stats.Counter.HookTask, _ = e.Count(new(webhook.HookTask))
 	stats.Counter.Team, _ = e.Count(new(organization.Team))
 	stats.Counter.Attachment, _ = e.Count(new(repo_model.Attachment))
-	stats.Counter.Project, _ = e.Count(new(Project))
-	stats.Counter.ProjectBoard, _ = e.Count(new(ProjectBoard))
+	stats.Counter.Project, _ = e.Count(new(project_model.Project))
+	stats.Counter.ProjectBoard, _ = e.Count(new(project_model.Board))
 	return
 }
diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go
index ee4738c970..5af91c8e5f 100644
--- a/routers/web/repo/issue.go
+++ b/routers/web/repo/issue.go
@@ -20,6 +20,7 @@ import (
 	"code.gitea.io/gitea/models"
 	"code.gitea.io/gitea/models/db"
 	"code.gitea.io/gitea/models/organization"
+	project_model "code.gitea.io/gitea/models/project"
 	repo_model "code.gitea.io/gitea/models/repo"
 	"code.gitea.io/gitea/models/unit"
 	user_model "code.gitea.io/gitea/models/user"
@@ -336,9 +337,9 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti
 	}
 
 	if ctx.Repo.CanWriteIssuesOrPulls(ctx.Params(":type") == "pulls") {
-		projects, _, err := models.GetProjects(models.ProjectSearchOptions{
+		projects, _, err := project_model.GetProjects(project_model.SearchOptions{
 			RepoID:   repo.ID,
-			Type:     models.ProjectTypeRepository,
+			Type:     project_model.TypeRepository,
 			IsClosed: util.OptionalBoolOf(isShowClosed),
 		})
 		if err != nil {
@@ -446,22 +447,22 @@ 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 = models.GetProjects(models.ProjectSearchOptions{
+	ctx.Data["OpenProjects"], _, err = project_model.GetProjects(project_model.SearchOptions{
 		RepoID:   repo.ID,
 		Page:     -1,
 		IsClosed: util.OptionalBoolFalse,
-		Type:     models.ProjectTypeRepository,
+		Type:     project_model.TypeRepository,
 	})
 	if err != nil {
 		ctx.ServerError("GetProjects", err)
 		return
 	}
 
-	ctx.Data["ClosedProjects"], _, err = models.GetProjects(models.ProjectSearchOptions{
+	ctx.Data["ClosedProjects"], _, err = project_model.GetProjects(project_model.SearchOptions{
 		RepoID:   repo.ID,
 		Page:     -1,
 		IsClosed: util.OptionalBoolTrue,
-		Type:     models.ProjectTypeRepository,
+		Type:     project_model.TypeRepository,
 	})
 	if err != nil {
 		ctx.ServerError("GetProjects", err)
@@ -814,7 +815,7 @@ func NewIssue(ctx *context.Context) {
 
 	projectID := ctx.FormInt64("project")
 	if projectID > 0 {
-		project, err := models.GetProjectByID(projectID)
+		project, err := project_model.GetProjectByID(projectID)
 		if err != nil {
 			log.Error("GetProjectByID: %d: %v", projectID, err)
 		} else if project.RepoID != ctx.Repo.Repository.ID {
@@ -926,7 +927,7 @@ func ValidateRepoMetas(ctx *context.Context, form forms.CreateIssueForm, isPull
 	}
 
 	if form.ProjectID > 0 {
-		p, err := models.GetProjectByID(form.ProjectID)
+		p, err := project_model.GetProjectByID(form.ProjectID)
 		if err != nil {
 			ctx.ServerError("GetProjectByID", err)
 			return nil, nil, 0, 0
@@ -1413,7 +1414,7 @@ func ViewIssue(ctx *context.Context) {
 				return
 			}
 
-			ghostProject := &models.Project{
+			ghostProject := &project_model.Project{
 				ID:    -1,
 				Title: ctx.Tr("repo.issues.deleted_project"),
 			}
diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go
index 9df2520a52..a6f843d848 100644
--- a/routers/web/repo/projects.go
+++ b/routers/web/repo/projects.go
@@ -12,6 +12,7 @@ import (
 
 	"code.gitea.io/gitea/models"
 	"code.gitea.io/gitea/models/perm"
+	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"
@@ -69,12 +70,12 @@ func Projects(ctx *context.Context) {
 		total = repo.NumClosedProjects
 	}
 
-	projects, count, err := models.GetProjects(models.ProjectSearchOptions{
+	projects, count, err := project_model.GetProjects(project_model.SearchOptions{
 		RepoID:   repo.ID,
 		Page:     page,
 		IsClosed: util.OptionalBoolOf(isShowClosed),
 		SortType: sortType,
-		Type:     models.ProjectTypeRepository,
+		Type:     project_model.TypeRepository,
 	})
 	if err != nil {
 		ctx.ServerError("GetProjects", err)
@@ -122,7 +123,7 @@ func Projects(ctx *context.Context) {
 // NewProject render creating a project page
 func NewProject(ctx *context.Context) {
 	ctx.Data["Title"] = ctx.Tr("repo.projects.new")
-	ctx.Data["ProjectTypes"] = models.GetProjectsConfig()
+	ctx.Data["ProjectTypes"] = project_model.GetProjectsConfig()
 	ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects)
 	ctx.HTML(http.StatusOK, tplProjectsNew)
 }
@@ -134,18 +135,18 @@ func NewProjectPost(ctx *context.Context) {
 
 	if ctx.HasError() {
 		ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects)
-		ctx.Data["ProjectTypes"] = models.GetProjectsConfig()
+		ctx.Data["ProjectTypes"] = project_model.GetProjectsConfig()
 		ctx.HTML(http.StatusOK, tplProjectsNew)
 		return
 	}
 
-	if err := models.NewProject(&models.Project{
+	if err := project_model.NewProject(&project_model.Project{
 		RepoID:      ctx.Repo.Repository.ID,
 		Title:       form.Title,
 		Description: form.Content,
 		CreatorID:   ctx.Doer.ID,
 		BoardType:   form.BoardType,
-		Type:        models.ProjectTypeRepository,
+		Type:        project_model.TypeRepository,
 	}); err != nil {
 		ctx.ServerError("NewProject", err)
 		return
@@ -168,8 +169,8 @@ func ChangeProjectStatus(ctx *context.Context) {
 	}
 	id := ctx.ParamsInt64(":id")
 
-	if err := models.ChangeProjectStatusByRepoIDAndID(ctx.Repo.Repository.ID, id, toClose); err != nil {
-		if models.IsErrProjectNotExist(err) {
+	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)
@@ -181,9 +182,9 @@ func ChangeProjectStatus(ctx *context.Context) {
 
 // DeleteProject delete a project
 func DeleteProject(ctx *context.Context) {
-	p, err := models.GetProjectByID(ctx.ParamsInt64(":id"))
+	p, err := project_model.GetProjectByID(ctx.ParamsInt64(":id"))
 	if err != nil {
-		if models.IsErrProjectNotExist(err) {
+		if project_model.IsErrProjectNotExist(err) {
 			ctx.NotFound("", nil)
 		} else {
 			ctx.ServerError("GetProjectByID", err)
@@ -195,7 +196,7 @@ func DeleteProject(ctx *context.Context) {
 		return
 	}
 
-	if err := models.DeleteProjectByID(p.ID); err != nil {
+	if err := project_model.DeleteProjectByID(p.ID); err != nil {
 		ctx.Flash.Error("DeleteProjectByID: " + err.Error())
 	} else {
 		ctx.Flash.Success(ctx.Tr("repo.projects.deletion_success"))
@@ -212,9 +213,9 @@ func EditProject(ctx *context.Context) {
 	ctx.Data["PageIsEditProjects"] = true
 	ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects)
 
-	p, err := models.GetProjectByID(ctx.ParamsInt64(":id"))
+	p, err := project_model.GetProjectByID(ctx.ParamsInt64(":id"))
 	if err != nil {
-		if models.IsErrProjectNotExist(err) {
+		if project_model.IsErrProjectNotExist(err) {
 			ctx.NotFound("", nil)
 		} else {
 			ctx.ServerError("GetProjectByID", err)
@@ -244,9 +245,9 @@ func EditProjectPost(ctx *context.Context) {
 		return
 	}
 
-	p, err := models.GetProjectByID(ctx.ParamsInt64(":id"))
+	p, err := project_model.GetProjectByID(ctx.ParamsInt64(":id"))
 	if err != nil {
-		if models.IsErrProjectNotExist(err) {
+		if project_model.IsErrProjectNotExist(err) {
 			ctx.NotFound("", nil)
 		} else {
 			ctx.ServerError("GetProjectByID", err)
@@ -260,7 +261,7 @@ func EditProjectPost(ctx *context.Context) {
 
 	p.Title = form.Title
 	p.Description = form.Content
-	if err = models.UpdateProject(p); err != nil {
+	if err = project_model.UpdateProject(p); err != nil {
 		ctx.ServerError("UpdateProjects", err)
 		return
 	}
@@ -271,9 +272,9 @@ func EditProjectPost(ctx *context.Context) {
 
 // ViewProject renders the project board for a project
 func ViewProject(ctx *context.Context) {
-	project, err := models.GetProjectByID(ctx.ParamsInt64(":id"))
+	project, err := project_model.GetProjectByID(ctx.ParamsInt64(":id"))
 	if err != nil {
-		if models.IsErrProjectNotExist(err) {
+		if project_model.IsErrProjectNotExist(err) {
 			ctx.NotFound("", nil)
 		} else {
 			ctx.ServerError("GetProjectByID", err)
@@ -285,7 +286,7 @@ func ViewProject(ctx *context.Context) {
 		return
 	}
 
-	boards, err := models.GetProjectBoards(project.ID)
+	boards, err := project_model.GetBoards(project.ID)
 	if err != nil {
 		ctx.ServerError("GetProjectBoards", err)
 		return
@@ -295,27 +296,29 @@ func ViewProject(ctx *context.Context) {
 		boards[0].Title = ctx.Tr("repo.projects.type.uncategorized")
 	}
 
-	issueList, err := boards.LoadIssues()
+	issuesMap, err := models.LoadIssuesFromBoardList(boards)
 	if err != nil {
 		ctx.ServerError("LoadIssuesOfBoards", err)
 		return
 	}
 
 	linkedPrsMap := make(map[int64][]*models.Issue)
-	for _, issue := range issueList {
-		var referencedIds []int64
-		for _, comment := range issue.Comments {
-			if comment.RefIssueID != 0 && comment.RefIsPull {
-				referencedIds = append(referencedIds, comment.RefIssueID)
+	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 := models.Issues(&models.IssuesOptions{
-				IssueIDs: referencedIds,
-				IsPull:   util.OptionalBoolTrue,
-			}); err == nil {
-				linkedPrsMap[issue.ID] = linkedPrs
+			if len(referencedIds) > 0 {
+				if linkedPrs, err := models.Issues(&models.IssuesOptions{
+					IssueIDs: referencedIds,
+					IsPull:   util.OptionalBoolTrue,
+				}); err == nil {
+					linkedPrsMap[issue.ID] = linkedPrs
+				}
 			}
 		}
 	}
@@ -335,6 +338,7 @@ func ViewProject(ctx *context.Context) {
 	ctx.Data["IsProjectsPage"] = true
 	ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects)
 	ctx.Data["Project"] = project
+	ctx.Data["IssuesMap"] = issuesMap
 	ctx.Data["Boards"] = boards
 
 	ctx.HTML(http.StatusOK, tplProjectsView)
@@ -381,9 +385,9 @@ func DeleteProjectBoard(ctx *context.Context) {
 		return
 	}
 
-	project, err := models.GetProjectByID(ctx.ParamsInt64(":id"))
+	project, err := project_model.GetProjectByID(ctx.ParamsInt64(":id"))
 	if err != nil {
-		if models.IsErrProjectNotExist(err) {
+		if project_model.IsErrProjectNotExist(err) {
 			ctx.NotFound("", nil)
 		} else {
 			ctx.ServerError("GetProjectByID", err)
@@ -391,7 +395,7 @@ func DeleteProjectBoard(ctx *context.Context) {
 		return
 	}
 
-	pb, err := models.GetProjectBoard(ctx.ParamsInt64(":boardID"))
+	pb, err := project_model.GetBoard(ctx.ParamsInt64(":boardID"))
 	if err != nil {
 		ctx.ServerError("GetProjectBoard", err)
 		return
@@ -410,7 +414,7 @@ func DeleteProjectBoard(ctx *context.Context) {
 		return
 	}
 
-	if err := models.DeleteProjectBoardByID(ctx.ParamsInt64(":boardID")); err != nil {
+	if err := project_model.DeleteBoardByID(ctx.ParamsInt64(":boardID")); err != nil {
 		ctx.ServerError("DeleteProjectBoardByID", err)
 		return
 	}
@@ -430,9 +434,9 @@ func AddBoardToProjectPost(ctx *context.Context) {
 		return
 	}
 
-	project, err := models.GetProjectByID(ctx.ParamsInt64(":id"))
+	project, err := project_model.GetProjectByID(ctx.ParamsInt64(":id"))
 	if err != nil {
-		if models.IsErrProjectNotExist(err) {
+		if project_model.IsErrProjectNotExist(err) {
 			ctx.NotFound("", nil)
 		} else {
 			ctx.ServerError("GetProjectByID", err)
@@ -440,7 +444,7 @@ func AddBoardToProjectPost(ctx *context.Context) {
 		return
 	}
 
-	if err := models.NewProjectBoard(&models.ProjectBoard{
+	if err := project_model.NewBoard(&project_model.Board{
 		ProjectID: project.ID,
 		Title:     form.Title,
 		Color:     form.Color,
@@ -455,7 +459,7 @@ func AddBoardToProjectPost(ctx *context.Context) {
 	})
 }
 
-func checkProjectBoardChangePermissions(ctx *context.Context) (*models.Project, *models.ProjectBoard) {
+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.",
@@ -470,9 +474,9 @@ func checkProjectBoardChangePermissions(ctx *context.Context) (*models.Project,
 		return nil, nil
 	}
 
-	project, err := models.GetProjectByID(ctx.ParamsInt64(":id"))
+	project, err := project_model.GetProjectByID(ctx.ParamsInt64(":id"))
 	if err != nil {
-		if models.IsErrProjectNotExist(err) {
+		if project_model.IsErrProjectNotExist(err) {
 			ctx.NotFound("", nil)
 		} else {
 			ctx.ServerError("GetProjectByID", err)
@@ -480,7 +484,7 @@ func checkProjectBoardChangePermissions(ctx *context.Context) (*models.Project,
 		return nil, nil
 	}
 
-	board, err := models.GetProjectBoard(ctx.ParamsInt64(":boardID"))
+	board, err := project_model.GetBoard(ctx.ParamsInt64(":boardID"))
 	if err != nil {
 		ctx.ServerError("GetProjectBoard", err)
 		return nil, nil
@@ -519,7 +523,7 @@ func EditProjectBoard(ctx *context.Context) {
 		board.Sorting = form.Sorting
 	}
 
-	if err := models.UpdateProjectBoard(board); err != nil {
+	if err := project_model.UpdateBoard(board); err != nil {
 		ctx.ServerError("UpdateProjectBoard", err)
 		return
 	}
@@ -536,7 +540,7 @@ func SetDefaultProjectBoard(ctx *context.Context) {
 		return
 	}
 
-	if err := models.SetDefaultBoard(project.ID, board.ID); err != nil {
+	if err := project_model.SetDefaultBoard(project.ID, board.ID); err != nil {
 		ctx.ServerError("SetDefaultBoard", err)
 		return
 	}
@@ -562,9 +566,9 @@ func MoveIssues(ctx *context.Context) {
 		return
 	}
 
-	project, err := models.GetProjectByID(ctx.ParamsInt64(":id"))
+	project, err := project_model.GetProjectByID(ctx.ParamsInt64(":id"))
 	if err != nil {
-		if models.IsErrProjectNotExist(err) {
+		if project_model.IsErrProjectNotExist(err) {
 			ctx.NotFound("ProjectNotExist", nil)
 		} else {
 			ctx.ServerError("GetProjectByID", err)
@@ -576,19 +580,18 @@ func MoveIssues(ctx *context.Context) {
 		return
 	}
 
-	var board *models.ProjectBoard
+	var board *project_model.Board
 
 	if ctx.ParamsInt64(":boardID") == 0 {
-		board = &models.ProjectBoard{
+		board = &project_model.Board{
 			ID:        0,
 			ProjectID: project.ID,
 			Title:     ctx.Tr("repo.projects.type.uncategorized"),
 		}
 	} else {
-		// column
-		board, err = models.GetProjectBoard(ctx.ParamsInt64(":boardID"))
+		board, err = project_model.GetBoard(ctx.ParamsInt64(":boardID"))
 		if err != nil {
-			if models.IsErrProjectBoardNotExist(err) {
+			if project_model.IsErrProjectBoardNotExist(err) {
 				ctx.NotFound("ProjectBoardNotExist", nil)
 			} else {
 				ctx.ServerError("GetProjectBoard", err)
@@ -634,7 +637,7 @@ func MoveIssues(ctx *context.Context) {
 		return
 	}
 
-	if err = models.MoveIssuesOnProjectBoard(board, sortedIssueIDs); err != nil {
+	if err = project_model.MoveIssuesOnProjectBoard(board, sortedIssueIDs); err != nil {
 		ctx.ServerError("MoveIssuesOnProjectBoard", err)
 		return
 	}
@@ -647,8 +650,43 @@ func MoveIssues(ctx *context.Context) {
 // CreateProject renders the generic project creation page
 func CreateProject(ctx *context.Context) {
 	ctx.Data["Title"] = ctx.Tr("repo.projects.new")
-	ctx.Data["ProjectTypes"] = models.GetProjectsConfig()
+	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/user/profile.go b/routers/web/user/profile.go
index 99198e8866..f628840375 100644
--- a/routers/web/user/profile.go
+++ b/routers/web/user/profile.go
@@ -13,6 +13,7 @@ import (
 	"code.gitea.io/gitea/models"
 	"code.gitea.io/gitea/models/db"
 	"code.gitea.io/gitea/models/organization"
+	project_model "code.gitea.io/gitea/models/project"
 	repo_model "code.gitea.io/gitea/models/repo"
 	user_model "code.gitea.io/gitea/models/user"
 	"code.gitea.io/gitea/modules/context"
@@ -216,10 +217,10 @@ func Profile(ctx *context.Context) {
 
 		total = int(count)
 	case "projects":
-		ctx.Data["OpenProjects"], _, err = models.GetProjects(models.ProjectSearchOptions{
+		ctx.Data["OpenProjects"], _, err = project_model.GetProjects(project_model.SearchOptions{
 			Page:     -1,
 			IsClosed: util.OptionalBoolFalse,
-			Type:     models.ProjectTypeIndividual,
+			Type:     project_model.TypeIndividual,
 		})
 		if err != nil {
 			ctx.ServerError("GetProjects", err)
diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go
index 3760c71f2a..e968ac55ea 100644
--- a/services/forms/repo_form.go
+++ b/services/forms/repo_form.go
@@ -11,6 +11,7 @@ import (
 	"strings"
 
 	"code.gitea.io/gitea/models"
+	project_model "code.gitea.io/gitea/models/project"
 	"code.gitea.io/gitea/modules/context"
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/structs"
@@ -499,7 +500,7 @@ func (i IssueLockForm) HasValidReason() bool {
 type CreateProjectForm struct {
 	Title     string `binding:"Required;MaxSize(100)"`
 	Content   string
-	BoardType models.ProjectBoardType
+	BoardType project_model.BoardType
 }
 
 // UserCreateProjectForm is a from for creating an individual or organization
@@ -507,7 +508,7 @@ type CreateProjectForm struct {
 type UserCreateProjectForm struct {
 	Title     string `binding:"Required;MaxSize(100)"`
 	Content   string
-	BoardType models.ProjectBoardType
+	BoardType project_model.BoardType
 	UID       int64 `binding:"Required"`
 }
 
diff --git a/templates/repo/projects/view.tmpl b/templates/repo/projects/view.tmpl
index 4ca21cc38e..7d4d21f8fe 100644
--- a/templates/repo/projects/view.tmpl
+++ b/templates/repo/projects/view.tmpl
@@ -84,7 +84,7 @@
 				<div class="board-column-header df ac sb">
 					<div class="ui large label board-label py-2">
 						<div class="ui small circular grey label board-card-cnt">
-							{{len .Issues}}
+							{{.NumIssues}}
 						</div>
 						{{.Title}}
 					</div>
@@ -175,7 +175,7 @@
 
 				<div class="ui cards board" data-url="{{$.RepoLink}}/projects/{{$.Project.ID}}/{{.ID}}" data-project="{{$.Project.ID}}" data-board="{{.ID}}" id="board_{{.ID}}">
 
-					{{ range .Issues }}
+					{{ range (index $.IssuesMap .ID) }}
 
 					<!-- start issue card -->
 					<div class="card board-card" data-issue="{{.ID}}">