code.gitea.io/gitea@v1.22.3/routers/web/org/projects.go (about) 1 // Copyright 2022 The Gitea Authors. All rights reserved. 2 // SPDX-License-Identifier: MIT 3 4 package org 5 6 import ( 7 "errors" 8 "fmt" 9 "net/http" 10 "strings" 11 12 "code.gitea.io/gitea/models/db" 13 issues_model "code.gitea.io/gitea/models/issues" 14 project_model "code.gitea.io/gitea/models/project" 15 attachment_model "code.gitea.io/gitea/models/repo" 16 "code.gitea.io/gitea/models/unit" 17 "code.gitea.io/gitea/modules/base" 18 "code.gitea.io/gitea/modules/json" 19 "code.gitea.io/gitea/modules/optional" 20 "code.gitea.io/gitea/modules/setting" 21 "code.gitea.io/gitea/modules/templates" 22 "code.gitea.io/gitea/modules/web" 23 shared_user "code.gitea.io/gitea/routers/web/shared/user" 24 "code.gitea.io/gitea/services/context" 25 "code.gitea.io/gitea/services/forms" 26 ) 27 28 const ( 29 tplProjects base.TplName = "org/projects/list" 30 tplProjectsNew base.TplName = "org/projects/new" 31 tplProjectsView base.TplName = "org/projects/view" 32 ) 33 34 // MustEnableProjects check if projects are enabled in settings 35 func MustEnableProjects(ctx *context.Context) { 36 if unit.TypeProjects.UnitGlobalDisabled() { 37 ctx.NotFound("EnableKanbanBoard", nil) 38 return 39 } 40 } 41 42 // Projects renders the home page of projects 43 func Projects(ctx *context.Context) { 44 shared_user.PrepareContextForProfileBigAvatar(ctx) 45 ctx.Data["Title"] = ctx.Tr("repo.project_board") 46 47 sortType := ctx.FormTrim("sort") 48 49 isShowClosed := strings.ToLower(ctx.FormTrim("state")) == "closed" 50 keyword := ctx.FormTrim("q") 51 page := ctx.FormInt("page") 52 if page <= 1 { 53 page = 1 54 } 55 56 var projectType project_model.Type 57 if ctx.ContextUser.IsOrganization() { 58 projectType = project_model.TypeOrganization 59 } else { 60 projectType = project_model.TypeIndividual 61 } 62 projects, total, err := db.FindAndCount[project_model.Project](ctx, project_model.SearchOptions{ 63 ListOptions: db.ListOptions{ 64 Page: page, 65 PageSize: setting.UI.IssuePagingNum, 66 }, 67 OwnerID: ctx.ContextUser.ID, 68 IsClosed: optional.Some(isShowClosed), 69 OrderBy: project_model.GetSearchOrderByBySortType(sortType), 70 Type: projectType, 71 Title: keyword, 72 }) 73 if err != nil { 74 ctx.ServerError("FindProjects", err) 75 return 76 } 77 78 opTotal, err := db.Count[project_model.Project](ctx, project_model.SearchOptions{ 79 OwnerID: ctx.ContextUser.ID, 80 IsClosed: optional.Some(!isShowClosed), 81 Type: projectType, 82 }) 83 if err != nil { 84 ctx.ServerError("CountProjects", err) 85 return 86 } 87 88 if isShowClosed { 89 ctx.Data["OpenCount"] = opTotal 90 ctx.Data["ClosedCount"] = total 91 } else { 92 ctx.Data["OpenCount"] = total 93 ctx.Data["ClosedCount"] = opTotal 94 } 95 96 ctx.Data["Projects"] = projects 97 shared_user.RenderUserHeader(ctx) 98 99 if isShowClosed { 100 ctx.Data["State"] = "closed" 101 } else { 102 ctx.Data["State"] = "open" 103 } 104 105 for _, project := range projects { 106 project.RenderedContent = templates.RenderMarkdownToHtml(ctx, project.Description) 107 } 108 109 err = shared_user.LoadHeaderCount(ctx) 110 if err != nil { 111 ctx.ServerError("LoadHeaderCount", err) 112 return 113 } 114 115 numPages := 0 116 if total > 0 { 117 numPages = (int(total) - 1/setting.UI.IssuePagingNum) 118 } 119 120 pager := context.NewPagination(int(total), setting.UI.IssuePagingNum, page, numPages) 121 pager.AddParamString("state", fmt.Sprint(ctx.Data["State"])) 122 ctx.Data["Page"] = pager 123 124 ctx.Data["CanWriteProjects"] = canWriteProjects(ctx) 125 ctx.Data["IsShowClosed"] = isShowClosed 126 ctx.Data["PageIsViewProjects"] = true 127 ctx.Data["SortType"] = sortType 128 129 ctx.HTML(http.StatusOK, tplProjects) 130 } 131 132 func canWriteProjects(ctx *context.Context) bool { 133 if ctx.ContextUser.IsOrganization() { 134 return ctx.Org.CanWriteUnit(ctx, unit.TypeProjects) 135 } 136 return ctx.Doer != nil && ctx.ContextUser.ID == ctx.Doer.ID 137 } 138 139 // RenderNewProject render creating a project page 140 func RenderNewProject(ctx *context.Context) { 141 ctx.Data["Title"] = ctx.Tr("repo.projects.new") 142 ctx.Data["BoardTypes"] = project_model.GetBoardConfig() 143 ctx.Data["CardTypes"] = project_model.GetCardConfig() 144 ctx.Data["CanWriteProjects"] = canWriteProjects(ctx) 145 ctx.Data["PageIsViewProjects"] = true 146 ctx.Data["HomeLink"] = ctx.ContextUser.HomeLink() 147 ctx.Data["CancelLink"] = ctx.ContextUser.HomeLink() + "/-/projects" 148 shared_user.RenderUserHeader(ctx) 149 150 err := shared_user.LoadHeaderCount(ctx) 151 if err != nil { 152 ctx.ServerError("LoadHeaderCount", err) 153 return 154 } 155 156 ctx.HTML(http.StatusOK, tplProjectsNew) 157 } 158 159 // NewProjectPost creates a new project 160 func NewProjectPost(ctx *context.Context) { 161 form := web.GetForm(ctx).(*forms.CreateProjectForm) 162 ctx.Data["Title"] = ctx.Tr("repo.projects.new") 163 shared_user.RenderUserHeader(ctx) 164 165 if ctx.HasError() { 166 RenderNewProject(ctx) 167 return 168 } 169 170 newProject := project_model.Project{ 171 OwnerID: ctx.ContextUser.ID, 172 Title: form.Title, 173 Description: form.Content, 174 CreatorID: ctx.Doer.ID, 175 BoardType: form.BoardType, 176 CardType: form.CardType, 177 } 178 179 if ctx.ContextUser.IsOrganization() { 180 newProject.Type = project_model.TypeOrganization 181 } else { 182 newProject.Type = project_model.TypeIndividual 183 } 184 185 if err := project_model.NewProject(ctx, &newProject); err != nil { 186 ctx.ServerError("NewProject", err) 187 return 188 } 189 190 ctx.Flash.Success(ctx.Tr("repo.projects.create_success", form.Title)) 191 ctx.Redirect(ctx.ContextUser.HomeLink() + "/-/projects") 192 } 193 194 // ChangeProjectStatus updates the status of a project between "open" and "close" 195 func ChangeProjectStatus(ctx *context.Context) { 196 var toClose bool 197 switch ctx.Params(":action") { 198 case "open": 199 toClose = false 200 case "close": 201 toClose = true 202 default: 203 ctx.JSONRedirect(ctx.ContextUser.HomeLink() + "/-/projects") 204 return 205 } 206 id := ctx.ParamsInt64(":id") 207 208 if err := project_model.ChangeProjectStatusByRepoIDAndID(ctx, 0, id, toClose); err != nil { 209 ctx.NotFoundOrServerError("ChangeProjectStatusByRepoIDAndID", project_model.IsErrProjectNotExist, err) 210 return 211 } 212 ctx.JSONRedirect(fmt.Sprintf("%s/-/projects/%d", ctx.ContextUser.HomeLink(), id)) 213 } 214 215 // DeleteProject delete a project 216 func DeleteProject(ctx *context.Context) { 217 p, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) 218 if err != nil { 219 ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) 220 return 221 } 222 if p.OwnerID != ctx.ContextUser.ID { 223 ctx.NotFound("", nil) 224 return 225 } 226 227 if err := project_model.DeleteProjectByID(ctx, p.ID); err != nil { 228 ctx.Flash.Error("DeleteProjectByID: " + err.Error()) 229 } else { 230 ctx.Flash.Success(ctx.Tr("repo.projects.deletion_success")) 231 } 232 233 ctx.JSONRedirect(ctx.ContextUser.HomeLink() + "/-/projects") 234 } 235 236 // RenderEditProject allows a project to be edited 237 func RenderEditProject(ctx *context.Context) { 238 ctx.Data["Title"] = ctx.Tr("repo.projects.edit") 239 ctx.Data["PageIsEditProjects"] = true 240 ctx.Data["PageIsViewProjects"] = true 241 ctx.Data["CanWriteProjects"] = canWriteProjects(ctx) 242 ctx.Data["CardTypes"] = project_model.GetCardConfig() 243 244 shared_user.RenderUserHeader(ctx) 245 246 p, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) 247 if err != nil { 248 ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) 249 return 250 } 251 if p.OwnerID != ctx.ContextUser.ID { 252 ctx.NotFound("", nil) 253 return 254 } 255 256 ctx.Data["projectID"] = p.ID 257 ctx.Data["title"] = p.Title 258 ctx.Data["content"] = p.Description 259 ctx.Data["redirect"] = ctx.FormString("redirect") 260 ctx.Data["HomeLink"] = ctx.ContextUser.HomeLink() 261 ctx.Data["card_type"] = p.CardType 262 ctx.Data["CancelLink"] = fmt.Sprintf("%s/-/projects/%d", ctx.ContextUser.HomeLink(), p.ID) 263 264 ctx.HTML(http.StatusOK, tplProjectsNew) 265 } 266 267 // EditProjectPost response for editing a project 268 func EditProjectPost(ctx *context.Context) { 269 form := web.GetForm(ctx).(*forms.CreateProjectForm) 270 projectID := ctx.ParamsInt64(":id") 271 ctx.Data["Title"] = ctx.Tr("repo.projects.edit") 272 ctx.Data["PageIsEditProjects"] = true 273 ctx.Data["PageIsViewProjects"] = true 274 ctx.Data["CanWriteProjects"] = canWriteProjects(ctx) 275 ctx.Data["CardTypes"] = project_model.GetCardConfig() 276 ctx.Data["CancelLink"] = fmt.Sprintf("%s/-/projects/%d", ctx.ContextUser.HomeLink(), projectID) 277 278 shared_user.RenderUserHeader(ctx) 279 280 err := shared_user.LoadHeaderCount(ctx) 281 if err != nil { 282 ctx.ServerError("LoadHeaderCount", err) 283 return 284 } 285 286 if ctx.HasError() { 287 ctx.HTML(http.StatusOK, tplProjectsNew) 288 return 289 } 290 291 p, err := project_model.GetProjectByID(ctx, projectID) 292 if err != nil { 293 ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) 294 return 295 } 296 if p.OwnerID != ctx.ContextUser.ID { 297 ctx.NotFound("", nil) 298 return 299 } 300 301 p.Title = form.Title 302 p.Description = form.Content 303 p.CardType = form.CardType 304 if err = project_model.UpdateProject(ctx, p); err != nil { 305 ctx.ServerError("UpdateProjects", err) 306 return 307 } 308 309 ctx.Flash.Success(ctx.Tr("repo.projects.edit_success", p.Title)) 310 if ctx.FormString("redirect") == "project" { 311 ctx.Redirect(p.Link(ctx)) 312 } else { 313 ctx.Redirect(ctx.ContextUser.HomeLink() + "/-/projects") 314 } 315 } 316 317 // ViewProject renders the project board for a project 318 func ViewProject(ctx *context.Context) { 319 project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) 320 if err != nil { 321 ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) 322 return 323 } 324 if project.OwnerID != ctx.ContextUser.ID { 325 ctx.NotFound("", nil) 326 return 327 } 328 329 boards, err := project.GetBoards(ctx) 330 if err != nil { 331 ctx.ServerError("GetProjectBoards", err) 332 return 333 } 334 335 issuesMap, err := issues_model.LoadIssuesFromBoardList(ctx, boards) 336 if err != nil { 337 ctx.ServerError("LoadIssuesOfBoards", err) 338 return 339 } 340 341 if project.CardType != project_model.CardTypeTextOnly { 342 issuesAttachmentMap := make(map[int64][]*attachment_model.Attachment) 343 for _, issuesList := range issuesMap { 344 for _, issue := range issuesList { 345 if issueAttachment, err := attachment_model.GetAttachmentsByIssueIDImagesLatest(ctx, issue.ID); err == nil { 346 issuesAttachmentMap[issue.ID] = issueAttachment 347 } 348 } 349 } 350 ctx.Data["issuesAttachmentMap"] = issuesAttachmentMap 351 } 352 353 linkedPrsMap := make(map[int64][]*issues_model.Issue) 354 for _, issuesList := range issuesMap { 355 for _, issue := range issuesList { 356 var referencedIDs []int64 357 for _, comment := range issue.Comments { 358 if comment.RefIssueID != 0 && comment.RefIsPull { 359 referencedIDs = append(referencedIDs, comment.RefIssueID) 360 } 361 } 362 363 if len(referencedIDs) > 0 { 364 if linkedPrs, err := issues_model.Issues(ctx, &issues_model.IssuesOptions{ 365 IssueIDs: referencedIDs, 366 IsPull: optional.Some(true), 367 }); err == nil { 368 linkedPrsMap[issue.ID] = linkedPrs 369 } 370 } 371 } 372 } 373 374 project.RenderedContent = templates.RenderMarkdownToHtml(ctx, project.Description) 375 ctx.Data["LinkedPRs"] = linkedPrsMap 376 ctx.Data["PageIsViewProjects"] = true 377 ctx.Data["CanWriteProjects"] = canWriteProjects(ctx) 378 ctx.Data["Project"] = project 379 ctx.Data["IssuesMap"] = issuesMap 380 ctx.Data["Columns"] = boards // TODO: rename boards to columns in backend 381 shared_user.RenderUserHeader(ctx) 382 383 err = shared_user.LoadHeaderCount(ctx) 384 if err != nil { 385 ctx.ServerError("LoadHeaderCount", err) 386 return 387 } 388 389 ctx.HTML(http.StatusOK, tplProjectsView) 390 } 391 392 // DeleteProjectBoard allows for the deletion of a project board 393 func DeleteProjectBoard(ctx *context.Context) { 394 if ctx.Doer == nil { 395 ctx.JSON(http.StatusForbidden, map[string]string{ 396 "message": "Only signed in users are allowed to perform this action.", 397 }) 398 return 399 } 400 401 project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) 402 if err != nil { 403 ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) 404 return 405 } 406 407 pb, err := project_model.GetBoard(ctx, ctx.ParamsInt64(":boardID")) 408 if err != nil { 409 ctx.ServerError("GetProjectBoard", err) 410 return 411 } 412 if pb.ProjectID != ctx.ParamsInt64(":id") { 413 ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ 414 "message": fmt.Sprintf("ProjectBoard[%d] is not in Project[%d] as expected", pb.ID, project.ID), 415 }) 416 return 417 } 418 419 if project.OwnerID != ctx.ContextUser.ID { 420 ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ 421 "message": fmt.Sprintf("ProjectBoard[%d] is not in Owner[%d] as expected", pb.ID, ctx.ContextUser.ID), 422 }) 423 return 424 } 425 426 if err := project_model.DeleteBoardByID(ctx, ctx.ParamsInt64(":boardID")); err != nil { 427 ctx.ServerError("DeleteProjectBoardByID", err) 428 return 429 } 430 431 ctx.JSONOK() 432 } 433 434 // AddBoardToProjectPost allows a new board to be added to a project. 435 func AddBoardToProjectPost(ctx *context.Context) { 436 form := web.GetForm(ctx).(*forms.EditProjectBoardForm) 437 438 project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) 439 if err != nil { 440 ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) 441 return 442 } 443 444 if err := project_model.NewBoard(ctx, &project_model.Board{ 445 ProjectID: project.ID, 446 Title: form.Title, 447 Color: form.Color, 448 CreatorID: ctx.Doer.ID, 449 }); err != nil { 450 ctx.ServerError("NewProjectBoard", err) 451 return 452 } 453 454 ctx.JSONOK() 455 } 456 457 // CheckProjectBoardChangePermissions check permission 458 func CheckProjectBoardChangePermissions(ctx *context.Context) (*project_model.Project, *project_model.Board) { 459 if ctx.Doer == nil { 460 ctx.JSON(http.StatusForbidden, map[string]string{ 461 "message": "Only signed in users are allowed to perform this action.", 462 }) 463 return nil, nil 464 } 465 466 project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) 467 if err != nil { 468 ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) 469 return nil, nil 470 } 471 472 board, err := project_model.GetBoard(ctx, ctx.ParamsInt64(":boardID")) 473 if err != nil { 474 ctx.ServerError("GetProjectBoard", err) 475 return nil, nil 476 } 477 if board.ProjectID != ctx.ParamsInt64(":id") { 478 ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ 479 "message": fmt.Sprintf("ProjectBoard[%d] is not in Project[%d] as expected", board.ID, project.ID), 480 }) 481 return nil, nil 482 } 483 484 if project.OwnerID != ctx.ContextUser.ID { 485 ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ 486 "message": fmt.Sprintf("ProjectBoard[%d] is not in Repository[%d] as expected", board.ID, project.ID), 487 }) 488 return nil, nil 489 } 490 return project, board 491 } 492 493 // EditProjectBoard allows a project board's to be updated 494 func EditProjectBoard(ctx *context.Context) { 495 form := web.GetForm(ctx).(*forms.EditProjectBoardForm) 496 _, board := CheckProjectBoardChangePermissions(ctx) 497 if ctx.Written() { 498 return 499 } 500 501 if form.Title != "" { 502 board.Title = form.Title 503 } 504 505 board.Color = form.Color 506 507 if form.Sorting != 0 { 508 board.Sorting = form.Sorting 509 } 510 511 if err := project_model.UpdateBoard(ctx, board); err != nil { 512 ctx.ServerError("UpdateProjectBoard", err) 513 return 514 } 515 516 ctx.JSONOK() 517 } 518 519 // SetDefaultProjectBoard set default board for uncategorized issues/pulls 520 func SetDefaultProjectBoard(ctx *context.Context) { 521 project, board := CheckProjectBoardChangePermissions(ctx) 522 if ctx.Written() { 523 return 524 } 525 526 if err := project_model.SetDefaultBoard(ctx, project.ID, board.ID); err != nil { 527 ctx.ServerError("SetDefaultBoard", err) 528 return 529 } 530 531 ctx.JSONOK() 532 } 533 534 // MoveIssues moves or keeps issues in a column and sorts them inside that column 535 func MoveIssues(ctx *context.Context) { 536 if ctx.Doer == nil { 537 ctx.JSON(http.StatusForbidden, map[string]string{ 538 "message": "Only signed in users are allowed to perform this action.", 539 }) 540 return 541 } 542 543 project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) 544 if err != nil { 545 ctx.NotFoundOrServerError("GetProjectByID", project_model.IsErrProjectNotExist, err) 546 return 547 } 548 if project.OwnerID != ctx.ContextUser.ID { 549 ctx.NotFound("InvalidRepoID", nil) 550 return 551 } 552 553 board, err := project_model.GetBoard(ctx, ctx.ParamsInt64(":boardID")) 554 if err != nil { 555 ctx.NotFoundOrServerError("GetProjectBoard", project_model.IsErrProjectBoardNotExist, err) 556 return 557 } 558 559 if board.ProjectID != project.ID { 560 ctx.NotFound("BoardNotInProject", nil) 561 return 562 } 563 564 type movedIssuesForm struct { 565 Issues []struct { 566 IssueID int64 `json:"issueID"` 567 Sorting int64 `json:"sorting"` 568 } `json:"issues"` 569 } 570 571 form := &movedIssuesForm{} 572 if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { 573 ctx.ServerError("DecodeMovedIssuesForm", err) 574 return 575 } 576 577 issueIDs := make([]int64, 0, len(form.Issues)) 578 sortedIssueIDs := make(map[int64]int64) 579 for _, issue := range form.Issues { 580 issueIDs = append(issueIDs, issue.IssueID) 581 sortedIssueIDs[issue.Sorting] = issue.IssueID 582 } 583 movedIssues, err := issues_model.GetIssuesByIDs(ctx, issueIDs) 584 if err != nil { 585 ctx.NotFoundOrServerError("GetIssueByID", issues_model.IsErrIssueNotExist, err) 586 return 587 } 588 589 if len(movedIssues) != len(form.Issues) { 590 ctx.ServerError("some issues do not exist", errors.New("some issues do not exist")) 591 return 592 } 593 594 if _, err = movedIssues.LoadRepositories(ctx); err != nil { 595 ctx.ServerError("LoadRepositories", err) 596 return 597 } 598 599 for _, issue := range movedIssues { 600 if issue.RepoID != project.RepoID && issue.Repo.OwnerID != project.OwnerID { 601 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")) 602 return 603 } 604 } 605 606 if err = project_model.MoveIssuesOnProjectBoard(ctx, board, sortedIssueIDs); err != nil { 607 ctx.ServerError("MoveIssuesOnProjectBoard", err) 608 return 609 } 610 611 ctx.JSONOK() 612 }