KanBan: be able to set default board (#14147)
Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: zeripath <art27@cantab.net>
This commit is contained in:
parent
c09e11d018
commit
3091600cc8
|
@ -8,6 +8,7 @@ import (
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
"code.gitea.io/gitea/modules/timeutil"
|
"code.gitea.io/gitea/modules/timeutil"
|
||||||
|
|
||||||
|
"xorm.io/builder"
|
||||||
"xorm.io/xorm"
|
"xorm.io/xorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -164,22 +165,43 @@ func UpdateProjectBoard(board *ProjectBoard) error {
|
||||||
func updateProjectBoard(e Engine, board *ProjectBoard) error {
|
func updateProjectBoard(e Engine, board *ProjectBoard) error {
|
||||||
_, err := e.ID(board.ID).Cols(
|
_, err := e.ID(board.ID).Cols(
|
||||||
"title",
|
"title",
|
||||||
"default",
|
|
||||||
).Update(board)
|
).Update(board)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetProjectBoards fetches all boards related to a project
|
// GetProjectBoards fetches all boards related to a project
|
||||||
func GetProjectBoards(projectID int64) ([]*ProjectBoard, error) {
|
// if no default board set, first board is a temporary "Uncategorized" board
|
||||||
|
func GetProjectBoards(projectID int64) (ProjectBoardList, error) {
|
||||||
var boards = make([]*ProjectBoard, 0, 5)
|
return getProjectBoards(x, projectID)
|
||||||
|
|
||||||
sess := x.Where("project_id=?", projectID)
|
|
||||||
return boards, sess.Find(&boards)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUncategorizedBoard represents a board for issues not assigned to one
|
func getProjectBoards(e Engine, projectID int64) ([]*ProjectBoard, error) {
|
||||||
func GetUncategorizedBoard(projectID int64) (*ProjectBoard, error) {
|
var boards = make([]*ProjectBoard, 0, 5)
|
||||||
|
|
||||||
|
if err := e.Where("project_id=? AND `default`=?", projectID, false).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 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{
|
return &ProjectBoard{
|
||||||
ProjectID: projectID,
|
ProjectID: projectID,
|
||||||
Title: "Uncategorized",
|
Title: "Uncategorized",
|
||||||
|
@ -187,22 +209,55 @@ func GetUncategorizedBoard(projectID int64) (*ProjectBoard, error) {
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetDefaultBoard represents a board for issues not assigned to one
|
||||||
|
// if boardID is 0 unset default
|
||||||
|
func SetDefaultBoard(projectID, boardID int64) error {
|
||||||
|
sess := x
|
||||||
|
|
||||||
|
_, err := sess.Where(builder.Eq{
|
||||||
|
"project_id": projectID,
|
||||||
|
"`default`": true,
|
||||||
|
}).Cols("`default`").Update(&ProjectBoard{Default: false})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if boardID > 0 {
|
||||||
|
_, err = sess.ID(boardID).Where(builder.Eq{"project_id": projectID}).
|
||||||
|
Cols("`default`").Update(&ProjectBoard{Default: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// LoadIssues load issues assigned to this board
|
// LoadIssues load issues assigned to this board
|
||||||
func (b *ProjectBoard) LoadIssues() (IssueList, error) {
|
func (b *ProjectBoard) LoadIssues() (IssueList, error) {
|
||||||
var boardID int64
|
issueList := make([]*Issue, 0, 10)
|
||||||
if !b.Default {
|
|
||||||
boardID = b.ID
|
|
||||||
|
|
||||||
} else {
|
if b.ID != 0 {
|
||||||
// Issues without ProjectBoardID
|
issues, err := Issues(&IssuesOptions{
|
||||||
boardID = -1
|
ProjectBoardID: b.ID,
|
||||||
|
ProjectID: b.ProjectID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
issueList = issues
|
||||||
}
|
}
|
||||||
issues, err := Issues(&IssuesOptions{
|
|
||||||
ProjectBoardID: boardID,
|
if b.Default {
|
||||||
ProjectID: b.ProjectID,
|
issues, err := Issues(&IssuesOptions{
|
||||||
})
|
ProjectBoardID: -1, // Issues without ProjectBoardID
|
||||||
b.Issues = issues
|
ProjectID: b.ProjectID,
|
||||||
return issues, err
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
issueList = append(issueList, issues...)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.Issues = issueList
|
||||||
|
return issueList, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadIssues load issues assigned to the boards
|
// LoadIssues load issues assigned to the boards
|
||||||
|
|
|
@ -945,6 +945,8 @@ projects.board.edit_title = "New Board Name"
|
||||||
projects.board.new_title = "New Board Name"
|
projects.board.new_title = "New Board Name"
|
||||||
projects.board.new_submit = "Submit"
|
projects.board.new_submit = "Submit"
|
||||||
projects.board.new = "New Board"
|
projects.board.new = "New Board"
|
||||||
|
projects.board.set_default = "Set Default"
|
||||||
|
projects.board.set_default_desc = "Set this board as default for uncategorized issues and pulls"
|
||||||
projects.board.delete = "Delete Board"
|
projects.board.delete = "Delete Board"
|
||||||
projects.board.deletion_desc = "Deleting a project board moves all related issues to 'Uncategorized'. Continue?"
|
projects.board.deletion_desc = "Deleting a project board moves all related issues to 'Uncategorized'. Continue?"
|
||||||
projects.open = Open
|
projects.open = Open
|
||||||
|
|
|
@ -270,23 +270,17 @@ func ViewProject(ctx *context.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
uncategorizedBoard, err := models.GetUncategorizedBoard(project.ID)
|
|
||||||
uncategorizedBoard.Title = ctx.Tr("repo.projects.type.uncategorized")
|
|
||||||
if err != nil {
|
|
||||||
ctx.ServerError("GetUncategorizedBoard", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
boards, err := models.GetProjectBoards(project.ID)
|
boards, err := models.GetProjectBoards(project.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.ServerError("GetProjectBoards", err)
|
ctx.ServerError("GetProjectBoards", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
allBoards := models.ProjectBoardList{uncategorizedBoard}
|
if boards[0].ID == 0 {
|
||||||
allBoards = append(allBoards, boards...)
|
boards[0].Title = ctx.Tr("repo.projects.type.uncategorized")
|
||||||
|
}
|
||||||
|
|
||||||
if ctx.Data["Issues"], err = allBoards.LoadIssues(); err != nil {
|
if ctx.Data["Issues"], err = boards.LoadIssues(); err != nil {
|
||||||
ctx.ServerError("LoadIssuesOfBoards", err)
|
ctx.ServerError("LoadIssuesOfBoards", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -295,7 +289,7 @@ func ViewProject(ctx *context.Context) {
|
||||||
|
|
||||||
ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(models.UnitTypeProjects)
|
ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(models.UnitTypeProjects)
|
||||||
ctx.Data["Project"] = project
|
ctx.Data["Project"] = project
|
||||||
ctx.Data["Boards"] = allBoards
|
ctx.Data["Boards"] = boards
|
||||||
ctx.Data["PageIsProjects"] = true
|
ctx.Data["PageIsProjects"] = true
|
||||||
ctx.Data["RequiresDraggable"] = true
|
ctx.Data["RequiresDraggable"] = true
|
||||||
|
|
||||||
|
@ -416,21 +410,19 @@ func AddBoardToProjectPost(ctx *context.Context, form auth.EditProjectBoardTitle
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// EditProjectBoardTitle allows a project board's title to be updated
|
func checkProjectBoardChangePermissions(ctx *context.Context) (*models.Project, *models.ProjectBoard) {
|
||||||
func EditProjectBoardTitle(ctx *context.Context, form auth.EditProjectBoardTitleForm) {
|
|
||||||
|
|
||||||
if ctx.User == nil {
|
if ctx.User == nil {
|
||||||
ctx.JSON(403, map[string]string{
|
ctx.JSON(403, map[string]string{
|
||||||
"message": "Only signed in users are allowed to perform this action.",
|
"message": "Only signed in users are allowed to perform this action.",
|
||||||
})
|
})
|
||||||
return
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(models.AccessModeWrite, models.UnitTypeProjects) {
|
if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(models.AccessModeWrite, models.UnitTypeProjects) {
|
||||||
ctx.JSON(403, map[string]string{
|
ctx.JSON(403, map[string]string{
|
||||||
"message": "Only authorized users are allowed to perform this action.",
|
"message": "Only authorized users are allowed to perform this action.",
|
||||||
})
|
})
|
||||||
return
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
project, err := models.GetProjectByID(ctx.ParamsInt64(":id"))
|
project, err := models.GetProjectByID(ctx.ParamsInt64(":id"))
|
||||||
|
@ -440,25 +432,35 @@ func EditProjectBoardTitle(ctx *context.Context, form auth.EditProjectBoardTitle
|
||||||
} else {
|
} else {
|
||||||
ctx.ServerError("GetProjectByID", err)
|
ctx.ServerError("GetProjectByID", err)
|
||||||
}
|
}
|
||||||
return
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
board, err := models.GetProjectBoard(ctx.ParamsInt64(":boardID"))
|
board, err := models.GetProjectBoard(ctx.ParamsInt64(":boardID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.ServerError("GetProjectBoard", err)
|
ctx.ServerError("GetProjectBoard", err)
|
||||||
return
|
return nil, nil
|
||||||
}
|
}
|
||||||
if board.ProjectID != ctx.ParamsInt64(":id") {
|
if board.ProjectID != ctx.ParamsInt64(":id") {
|
||||||
ctx.JSON(422, map[string]string{
|
ctx.JSON(422, map[string]string{
|
||||||
"message": fmt.Sprintf("ProjectBoard[%d] is not in Project[%d] as expected", board.ID, project.ID),
|
"message": fmt.Sprintf("ProjectBoard[%d] is not in Project[%d] as expected", board.ID, project.ID),
|
||||||
})
|
})
|
||||||
return
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if project.RepoID != ctx.Repo.Repository.ID {
|
if project.RepoID != ctx.Repo.Repository.ID {
|
||||||
ctx.JSON(422, map[string]string{
|
ctx.JSON(422, map[string]string{
|
||||||
"message": fmt.Sprintf("ProjectBoard[%d] is not in Repository[%d] as expected", board.ID, ctx.Repo.Repository.ID),
|
"message": fmt.Sprintf("ProjectBoard[%d] is not in Repository[%d] as expected", board.ID, ctx.Repo.Repository.ID),
|
||||||
})
|
})
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return project, board
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditProjectBoardTitle allows a project board's title to be updated
|
||||||
|
func EditProjectBoardTitle(ctx *context.Context, form auth.EditProjectBoardTitleForm) {
|
||||||
|
|
||||||
|
_, board := checkProjectBoardChangePermissions(ctx)
|
||||||
|
if ctx.Written() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -476,6 +478,24 @@ func EditProjectBoardTitle(ctx *context.Context, form auth.EditProjectBoardTitle
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetDefaultProjectBoard set default board for uncategorized issues/pulls
|
||||||
|
func SetDefaultProjectBoard(ctx *context.Context) {
|
||||||
|
|
||||||
|
project, board := checkProjectBoardChangePermissions(ctx)
|
||||||
|
if ctx.Written() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := models.SetDefaultBoard(project.ID, board.ID); err != nil {
|
||||||
|
ctx.ServerError("SetDefaultBoard", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(200, map[string]interface{}{
|
||||||
|
"ok": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// MoveIssueAcrossBoards move a card from one board to another in a project
|
// MoveIssueAcrossBoards move a card from one board to another in a project
|
||||||
func MoveIssueAcrossBoards(ctx *context.Context) {
|
func MoveIssueAcrossBoards(ctx *context.Context) {
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
// 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 repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/models"
|
||||||
|
"code.gitea.io/gitea/modules/test"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCheckProjectBoardChangePermissions(t *testing.T) {
|
||||||
|
models.PrepareTestEnv(t)
|
||||||
|
ctx := test.MockContext(t, "user2/repo1/projects/1/2")
|
||||||
|
test.LoadUser(t, ctx, 2)
|
||||||
|
test.LoadRepo(t, ctx, 1)
|
||||||
|
ctx.SetParams(":id", "1")
|
||||||
|
ctx.SetParams(":boardID", "2")
|
||||||
|
|
||||||
|
project, board := checkProjectBoardChangePermissions(ctx)
|
||||||
|
assert.NotNil(t, project)
|
||||||
|
assert.NotNil(t, board)
|
||||||
|
assert.False(t, ctx.Written())
|
||||||
|
}
|
|
@ -800,6 +800,7 @@ func RegisterMacaronRoutes(m *macaron.Macaron) {
|
||||||
m.Group("/:boardID", func() {
|
m.Group("/:boardID", func() {
|
||||||
m.Put("", bindIgnErr(auth.EditProjectBoardTitleForm{}), repo.EditProjectBoardTitle)
|
m.Put("", bindIgnErr(auth.EditProjectBoardTitleForm{}), repo.EditProjectBoardTitle)
|
||||||
m.Delete("", repo.DeleteProjectBoard)
|
m.Delete("", repo.DeleteProjectBoard)
|
||||||
|
m.Post("/default", repo.SetDefaultProjectBoard)
|
||||||
|
|
||||||
m.Post("/:index", repo.MoveIssueAcrossBoards)
|
m.Post("/:index", repo.MoveIssueAcrossBoards)
|
||||||
})
|
})
|
||||||
|
|
|
@ -85,6 +85,12 @@
|
||||||
{{svg "octicon-pencil"}}
|
{{svg "octicon-pencil"}}
|
||||||
{{$.i18n.Tr "repo.projects.board.edit"}}
|
{{$.i18n.Tr "repo.projects.board.edit"}}
|
||||||
</a>
|
</a>
|
||||||
|
{{if not .Default}}
|
||||||
|
<a class="item show-modal button" data-modal="#set-default-project-board-modal-{{.ID}}">
|
||||||
|
{{svg "octicon-pin"}}
|
||||||
|
{{$.i18n.Tr "repo.projects.board.set_default"}}
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
<a class="item show-modal button" data-modal="#delete-board-modal-{{.ID}}">
|
<a class="item show-modal button" data-modal="#delete-board-modal-{{.ID}}">
|
||||||
{{svg "octicon-trashcan"}}
|
{{svg "octicon-trashcan"}}
|
||||||
{{$.i18n.Tr "repo.projects.board.delete"}}
|
{{$.i18n.Tr "repo.projects.board.delete"}}
|
||||||
|
@ -109,24 +115,34 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="ui basic modal" id="set-default-project-board-modal-{{.ID}}">
|
||||||
|
<div class="ui icon header">
|
||||||
|
{{$.i18n.Tr "repo.projects.board.set_default"}}
|
||||||
|
</div>
|
||||||
|
<div class="content center">
|
||||||
|
<label>
|
||||||
|
{{$.i18n.Tr "repo.projects.board.set_default_desc"}}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="text right actions">
|
||||||
|
<div class="ui cancel button">{{$.i18n.Tr "settings.cancel"}}</div>
|
||||||
|
<button class="ui red button set-default-project-board" data-url="{{$.RepoLink}}/projects/{{$.Project.ID}}/{{.ID}}/default">{{$.i18n.Tr "repo.projects.board.set_default"}}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="ui basic modal" id="delete-board-modal-{{.ID}}">
|
<div class="ui basic modal" id="delete-board-modal-{{.ID}}">
|
||||||
<div class="ui icon header">
|
<div class="ui icon header">
|
||||||
{{$.i18n.Tr "repo.projects.board.delete"}}
|
{{$.i18n.Tr "repo.projects.board.delete"}}
|
||||||
</div>
|
</div>
|
||||||
<div class="content center">
|
<div class="content center">
|
||||||
<input type="hidden" name="action" value="delete">
|
<label>
|
||||||
<div class="field">
|
{{$.i18n.Tr "repo.projects.board.deletion_desc"}}
|
||||||
<label>
|
</label>
|
||||||
{{$.i18n.Tr "repo.projects.board.deletion_desc"}}
|
</div>
|
||||||
</label>
|
<div class="text right actions">
|
||||||
</div>
|
<div class="ui cancel button">{{$.i18n.Tr "settings.cancel"}}</div>
|
||||||
|
<button class="ui red button delete-project-board" data-url="{{$.RepoLink}}/projects/{{$.Project.ID}}/{{.ID}}">{{$.i18n.Tr "repo.projects.board.delete"}}</button>
|
||||||
</div>
|
</div>
|
||||||
<form class="ui form" method="post">
|
|
||||||
<div class="text right actions">
|
|
||||||
<div class="ui cancel button">{{$.i18n.Tr "settings.cancel"}}</div>
|
|
||||||
<button class="ui red button delete-project-board" data-url="{{$.RepoLink}}/projects/{{$.Project.ID}}/{{.ID}}">{{$.i18n.Tr "repo.projects.board.delete"}}</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -27,14 +27,14 @@ export default async function initProject() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$('.edit-project-board').each(function () {
|
$('.edit-project-board').each(function () {
|
||||||
const projectTitleLabel = $(this).closest('.board-column-header').find('.board-label');
|
const projectTitleLabel = $(this).closest('.board-column-header').find('.board-label');
|
||||||
const projectTitleInput = $(this).find(
|
const projectTitleInput = $(this).find(
|
||||||
'.content > .form > .field > .project-board-title'
|
'.content > .form > .field > .project-board-title',
|
||||||
);
|
);
|
||||||
|
|
||||||
$(this)
|
$(this)
|
||||||
|
@ -59,6 +59,21 @@ export default async function initProject() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$(document).on('click', '.set-default-project-board', async function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
await $.ajax({
|
||||||
|
method: 'POST',
|
||||||
|
url: $(this).data('url'),
|
||||||
|
headers: {
|
||||||
|
'X-Csrf-Token': csrf,
|
||||||
|
'X-Remote': true,
|
||||||
|
},
|
||||||
|
contentType: 'application/json',
|
||||||
|
});
|
||||||
|
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
$('.delete-project-board').each(function () {
|
$('.delete-project-board').each(function () {
|
||||||
$(this).click(function (e) {
|
$(this).click(function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
@ -72,7 +87,7 @@ export default async function initProject() {
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
}).done(() => {
|
}).done(() => {
|
||||||
setTimeout(window.location.reload(true), 2000);
|
window.location.reload();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -93,7 +108,7 @@ export default async function initProject() {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
}).done(() => {
|
}).done(() => {
|
||||||
boardTitle.closest('form').removeClass('dirty');
|
boardTitle.closest('form').removeClass('dirty');
|
||||||
setTimeout(window.location.reload(true), 2000);
|
window.location.reload();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue