mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-11-09 00:22:38 -06:00
Address PR Swarm lock-order decision across task writes, restoration, and project/user purges. Reject moves discovered after view locks without acquiring views out of order.
772 lines
22 KiB
Go
772 lines
22 KiB
Go
// Vikunja is a to-do list application to facilitate your life.
|
|
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
package models
|
|
|
|
import (
|
|
"context"
|
|
"slices"
|
|
"time"
|
|
|
|
"code.vikunja.io/api/pkg/cron"
|
|
"code.vikunja.io/api/pkg/db"
|
|
"code.vikunja.io/api/pkg/events"
|
|
"code.vikunja.io/api/pkg/log"
|
|
"code.vikunja.io/api/pkg/user"
|
|
"code.vikunja.io/api/pkg/web"
|
|
|
|
"xorm.io/builder"
|
|
"xorm.io/xorm"
|
|
)
|
|
|
|
// Includes the project itself.
|
|
func subtreeIDs(s *xorm.Session, projectID int64, cond builder.Cond) (ids []int64, err error) {
|
|
ids = []int64{}
|
|
err = s.Table("project_ancestors").
|
|
Join("INNER", "projects", "projects.id = project_ancestors.project_id").
|
|
Where(builder.And(
|
|
builder.Eq{"project_ancestors.ancestor_id": projectID},
|
|
cond,
|
|
)).
|
|
Cols("project_ancestors.project_id").
|
|
Find(&ids)
|
|
return
|
|
}
|
|
|
|
func trashProject(s *xorm.Session, project *Project, a web.Auth) error {
|
|
ids, err := subtreeIDs(s, project.ID, builder.IsNull{"projects.deleted_at"})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Owners may trash their own default project, nobody else's.
|
|
blockedID := int64(0)
|
|
has, err := s.Table("users").
|
|
Join("INNER", "projects", "projects.id = users.default_project_id AND projects.owner_id = users.id").
|
|
Where(builder.And(
|
|
builder.In("projects.id", ids),
|
|
builder.Neq{"projects.owner_id": a.GetID()},
|
|
)).
|
|
Cols("projects.id").
|
|
Get(&blockedID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if has {
|
|
return &ErrCannotDeleteDefaultProject{ProjectID: blockedID}
|
|
}
|
|
|
|
projects := []*Project{}
|
|
if err = s.In("id", ids).Find(&projects); err != nil {
|
|
return err
|
|
}
|
|
|
|
doer := doerFromAuth(s, a)
|
|
_, err = s.In("id", ids).Cols("deleted_by_id").NoAutoTime().Update(&Project{DeletedByID: doer.ID})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// The deleted tag turns this into one update, so every project gets the same deleted_at.
|
|
_, err = s.In("id", ids).Delete(&Project{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = s.In("default_project_id", ids).
|
|
Cols("default_project_id").
|
|
Update(&user.User{DefaultProjectID: 0})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, p := range projects {
|
|
events.DispatchOnCommit(s, &ProjectDeletedEvent{
|
|
Project: p,
|
|
Doer: doer,
|
|
})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func lockProjectTreeViewsForPositionUpdate(s *xorm.Session, projectIDs ...int64) ([]*ProjectView, error) {
|
|
ids := []int64{}
|
|
err := s.Table(&ProjectAncestor{}).
|
|
Where(builder.In("ancestor_id", projectIDs)).
|
|
Distinct("project_id").
|
|
Find(&ids)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return lockProjectViewsForPositionUpdate(s, ids...)
|
|
}
|
|
|
|
// Time entries survive without a container, kept by their authors. A nil doer is the system.
|
|
func hardDeleteProject(s *xorm.Session, p *Project, doer *user.User) error {
|
|
views, err := lockProjectTreeViewsForPositionUpdate(s, p.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return hardDeleteProjectInViews(s, p, doer, views)
|
|
}
|
|
|
|
func hardDeleteProjectInViews(s *xorm.Session, p *Project, doer *user.User, views []*ProjectView) error {
|
|
ancestorIDs := []int64{}
|
|
err := s.Table(&ProjectAncestor{}).
|
|
Where(builder.And(
|
|
builder.Eq{"project_id": p.ID},
|
|
builder.Gt{"depth": 0},
|
|
)).
|
|
Cols("ancestor_id").
|
|
Find(&ancestorIDs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return hardDeleteProjectTree(s, p, doer, ancestorIDs, views)
|
|
}
|
|
|
|
// Children get their ancestors passed down: deleting p's closure rows also drops their links to p.
|
|
func hardDeleteProjectTree(s *xorm.Session, p *Project, doer *user.User, ancestorIDs []int64, views []*ProjectView) (err error) {
|
|
// Including trashed tasks: there is nothing to restore them into once the project is gone.
|
|
tasks := []*Task{}
|
|
err = s.Unscoped().Where("project_id = ?", p.ID).Find(&tasks)
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, task := range tasks {
|
|
// Moves the task's time entries onto the project, detached below
|
|
err = hardDeleteTask(s, task)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
_, err = s.Table("time_entries").
|
|
Where("project_id = ?", p.ID).
|
|
Update(map[string]any{
|
|
"project_id": 0,
|
|
"task_id": 0,
|
|
})
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
err = p.DeleteBackgroundFileIfExists(s)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
_, err = s.Where("default_project_id = ?", p.ID).
|
|
Cols("default_project_id").
|
|
Update(&user.User{DefaultProjectID: 0})
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
viewIDs := []int64{}
|
|
for _, v := range views {
|
|
if v.ProjectID == p.ID {
|
|
viewIDs = append(viewIDs, v.ID)
|
|
}
|
|
}
|
|
|
|
_, err = s.In("project_view_id", viewIDs).Delete(&Bucket{})
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
_, err = s.In("id", viewIDs).Delete(&ProjectView{})
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// Every user's favorite, not just the doer's
|
|
_, err = s.Where("entity_id = ? AND kind = ?", p.ID, FavoriteKindProject).Delete(&Favorite{})
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
_, err = s.Where("project_id = ?", p.ID).Delete(&LinkSharing{})
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
_, err = s.Where("project_id = ?", p.ID).Delete(&ProjectUser{})
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
_, err = s.Where("project_id = ?", p.ID).Delete(&TeamProject{})
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
err = deleteProjectAncestors(s, p.ID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
_, err = s.Where("project_id = ?", p.ID).Delete(&TaskIndexAlias{})
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
_, err = s.ID(p.ID).Delete(&ProjectTaskCounter{})
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
_, err = s.ID(p.ID).Unscoped().Delete(&Project{})
|
|
if err != nil {
|
|
return
|
|
}
|
|
events.DispatchOnCommit(s, &ProjectPurgedEvent{
|
|
Project: p,
|
|
Doer: doer,
|
|
AncestorIDs: ancestorIDs,
|
|
})
|
|
|
|
children := []*Project{}
|
|
err = s.Unscoped().Where("parent_project_id = ?", p.ID).Find(&children)
|
|
if err != nil {
|
|
return
|
|
}
|
|
childAncestorIDs := append(slices.Clone(ancestorIDs), p.ID)
|
|
for _, child := range children {
|
|
err = hardDeleteProjectTree(s, child, doer, childAncestorIDs, views)
|
|
if err != nil {
|
|
return
|
|
}
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
func getTrashedProject(s *xorm.Session, projectID int64) (*Project, error) {
|
|
project := &Project{}
|
|
has, err := s.Unscoped().
|
|
Where(builder.And(builder.Eq{"id": projectID}, builder.NotNull{"deleted_at"})).
|
|
Get(project)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !has {
|
|
return nil, ErrProjectDoesNotExist{ID: projectID}
|
|
}
|
|
return project, nil
|
|
}
|
|
|
|
// Unreachable trashed projects are ErrProjectDoesNotExist so their existence doesn't leak.
|
|
// Like regular reads, link shares never reach a trashed project.
|
|
func getReadableTrashedProject(s *xorm.Session, a web.Auth, projectID int64) (*Project, Permission, error) {
|
|
if _, isShare := a.(*LinkSharing); isShare {
|
|
return nil, 0, ErrProjectDoesNotExist{ID: projectID}
|
|
}
|
|
project, err := getTrashedProject(s, projectID)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
if isInstanceAdmin(s, a) || project.OwnerID == a.GetID() {
|
|
return project, PermissionAdmin, nil
|
|
}
|
|
access, err := getProjectAccessIncludingTrash(s, a.GetID())
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
permission, reachable := access.permission(project.ID)
|
|
if !reachable {
|
|
return nil, 0, ErrProjectDoesNotExist{ID: projectID}
|
|
}
|
|
return project, permission, nil
|
|
}
|
|
|
|
// Readers who could reach the project get ErrProjectIsInTrash; everyone else keeps notFound.
|
|
func trashedProjectReadError(s *xorm.Session, a web.Auth, projectID int64, notFound error) error {
|
|
trashed, permission, err := getReadableTrashedProject(s, a, projectID)
|
|
if IsErrProjectDoesNotExist(err) {
|
|
return notFound
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
inTrash := ErrProjectIsInTrash{
|
|
ID: trashed.ID,
|
|
DeletedAt: trashed.DeletedAt,
|
|
}
|
|
inTrash.CanRestore, err = canRestoreTrashedProject(s, trashed, permission)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if trashed.DeletedByID != 0 {
|
|
deleters, err := getUsersOrLinkSharesFromIDs(s, []int64{trashed.DeletedByID})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
inTrash.DeletedBy = deleters[trashed.DeletedByID]
|
|
}
|
|
inTrash.TrashedRootID, inTrash.CanRestoreRoot, err = readableTrashedRoot(s, a, trashed)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return inTrash
|
|
}
|
|
|
|
func canRestoreTrashedProject(s *xorm.Session, p *Project, permission Permission) (bool, error) {
|
|
if permission != PermissionAdmin {
|
|
return false, nil
|
|
}
|
|
blocker, err := restoreBlocker(s, p)
|
|
return blocker == nil, err
|
|
}
|
|
|
|
// The topmost trashed ancestor of p if the caller can reach it (else 0), and whether the caller can restore it.
|
|
func readableTrashedRoot(s *xorm.Session, a web.Auth, p *Project) (rootID int64, canRestore bool, err error) {
|
|
trashedAncestors := []*ProjectAncestor{}
|
|
err = s.Table("project_ancestors").
|
|
Join("INNER", "projects", "projects.id = project_ancestors.ancestor_id").
|
|
Where(builder.And(
|
|
builder.Eq{"project_ancestors.project_id": p.ID},
|
|
builder.Gt{"project_ancestors.depth": 0},
|
|
builder.NotNull{"projects.deleted_at"},
|
|
)).
|
|
Cols("project_ancestors.ancestor_id", "project_ancestors.depth").
|
|
OrderBy("project_ancestors.depth").
|
|
Find(&trashedAncestors)
|
|
if err != nil {
|
|
return 0, false, err
|
|
}
|
|
// Stop at the first live ancestor: restoring the root below it brings p back.
|
|
for i, ancestor := range trashedAncestors {
|
|
if ancestor.Depth != int64(i+1) {
|
|
break
|
|
}
|
|
rootID = ancestor.AncestorID
|
|
}
|
|
if rootID == 0 {
|
|
return 0, false, nil
|
|
}
|
|
root, permission, err := getReadableTrashedProject(s, a, rootID)
|
|
if IsErrProjectDoesNotExist(err) {
|
|
return 0, false, nil
|
|
}
|
|
if err != nil {
|
|
return 0, false, err
|
|
}
|
|
canRestore, err = canRestoreTrashedProject(s, root, permission)
|
|
return rootID, canRestore, err
|
|
}
|
|
|
|
// Loads the trashed project into p.
|
|
func (p *Project) canDoTrashedProject(s *xorm.Session, a web.Auth) (bool, error) {
|
|
trashed, permission, err := getReadableTrashedProject(s, a, p.ID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
*p = *trashed
|
|
return permission == PermissionAdmin, nil
|
|
}
|
|
|
|
// CanRestore requires admin access to the trashed project.
|
|
func (p *Project) CanRestore(s *xorm.Session, a web.Auth) (bool, error) {
|
|
return p.canDoTrashedProject(s, a)
|
|
}
|
|
|
|
func restoreBlocker(s *xorm.Session, p *Project) (blocker error, err error) {
|
|
parentID := p.parentID()
|
|
if parentID == 0 {
|
|
return nil, nil
|
|
}
|
|
parent := &Project{}
|
|
has, err := s.Unscoped().ID(parentID).Get(parent)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Purging takes all sub-projects along and the orphan repair re-parents the rest.
|
|
if !has {
|
|
return ErrParentProjectDoesNotExist{
|
|
ProjectID: p.ID,
|
|
ParentProjectID: parentID,
|
|
}, nil
|
|
}
|
|
switch {
|
|
case !parent.DeletedAt.IsZero():
|
|
return ErrParentProjectIsInTrash{
|
|
ProjectID: p.ID,
|
|
ParentProjectID: parentID,
|
|
}, nil
|
|
case parent.IsArchived:
|
|
return ErrParentProjectIsArchived{
|
|
ProjectID: p.ID,
|
|
ParentProjectID: parentID,
|
|
}, nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// Restore brings back all trashed sub-projects, whenever they were trashed; trashed tasks stay.
|
|
func (p *Project) Restore(s *xorm.Session, a web.Auth) error {
|
|
blocker, err := restoreBlocker(s, p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if blocker != nil {
|
|
return blocker
|
|
}
|
|
|
|
ids, err := subtreeIDs(s, p.ID, builder.NotNull{"projects.deleted_at"})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = s.Table("projects").
|
|
In("id", ids).
|
|
Update(map[string]any{
|
|
"deleted_at": nil,
|
|
"deleted_by_id": nil,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = s.In("id", ids).Cols("updated").Update(&Project{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// CalDAV clients dropped the collections and only pick the tasks up again through this
|
|
_, err = s.In("project_id", ids).Cols("updated").Update(&Task{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
restored := []*Project{}
|
|
if err = s.In("id", ids).Find(&restored); err != nil {
|
|
return err
|
|
}
|
|
doer := doerFromAuth(s, a)
|
|
for _, project := range restored {
|
|
events.DispatchOnCommit(s, &ProjectRestoredEvent{
|
|
Project: project,
|
|
Doer: doer,
|
|
})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CanDeletePermanently requires admin access to the trashed project.
|
|
func (p *Project) CanDeletePermanently(s *xorm.Session, a web.Auth) (bool, error) {
|
|
return p.canDoTrashedProject(s, a)
|
|
}
|
|
|
|
// DeletePermanently purges the project with all its sub-projects.
|
|
func (p *Project) DeletePermanently(s *xorm.Session, a web.Auth) error {
|
|
return hardDeleteProject(s, p, doerFromAuth(s, a))
|
|
}
|
|
|
|
// TrashedSubProject is a trashed descendant shown under its topmost trashed ancestor in the trash.
|
|
type TrashedSubProject struct {
|
|
ID int64 `xorm:"id" json:"id" doc:"The id of the sub-project."`
|
|
Title string `xorm:"title" json:"title" doc:"The title of the sub-project."`
|
|
ParentProjectID int64 `xorm:"parent_project_id" json:"parent_project_id" doc:"The id of its parent, either the listed project or another of its sub-projects."`
|
|
Depth int64 `xorm:"depth" json:"depth" doc:"How many levels below the listed project it sits, starting at 1."`
|
|
}
|
|
|
|
// TrashedProject is a topmost trashed project in the trash listing.
|
|
type TrashedProject struct {
|
|
Project
|
|
TrashedSubProjects []*TrashedSubProject `json:"trashed_sub_projects,omitempty" readOnly:"true" doc:"The trashed sub-projects at any depth that restoring this project brings back, shallowest first."`
|
|
}
|
|
|
|
type trashedSubProjectRow struct {
|
|
AncestorID int64 `xorm:"ancestor_id"`
|
|
TrashedSubProject `xorm:"extends"`
|
|
}
|
|
|
|
// Topmost trashed projects the user is admin of; trashed sub-projects are folded into their trashed ancestor.
|
|
func trashedProjectsCond(s *xorm.Session, a web.Auth) (builder.Cond, error) {
|
|
if _, isShare := a.(*LinkSharing); isShare {
|
|
return nil, ErrGenericForbidden{}
|
|
}
|
|
access, err := getProjectAccessIncludingTrash(s, a.GetID())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
adminIDs := []int64{}
|
|
for _, id := range access.sortedIDs {
|
|
if access.permissions[id] == PermissionAdmin {
|
|
adminIDs = append(adminIDs, id)
|
|
}
|
|
}
|
|
// Only trashed ids: the access map holds every live project too, which would bloat the IN list.
|
|
trashedAdminIDs := []int64{}
|
|
for chunk := range slices.Chunk(adminIDs, maxInListSize) {
|
|
batch := []int64{}
|
|
err = s.Table("projects").
|
|
Where(builder.And(
|
|
builder.In("id", chunk),
|
|
builder.NotNull{"deleted_at"},
|
|
)).
|
|
Cols("id").
|
|
Find(&batch)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
trashedAdminIDs = append(trashedAdminIDs, batch...)
|
|
}
|
|
|
|
return builder.And(
|
|
builder.NotNull{"projects.deleted_at"},
|
|
builder.In("projects.id", trashedAdminIDs),
|
|
builder.Or(
|
|
builder.IsNull{"projects.parent_project_id"},
|
|
builder.NotIn("projects.parent_project_id",
|
|
builder.Select("id").From("projects").Where(builder.NotNull{"deleted_at"}),
|
|
),
|
|
),
|
|
), nil
|
|
}
|
|
|
|
// GetTrashedProjects returns the project trash of the auth, newest first.
|
|
func GetTrashedProjects(s *xorm.Session, a web.Auth, page, perPage int) (trashed []*TrashedProject, total int64, err error) {
|
|
cond, err := trashedProjectsCond(s, a)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
total, err = s.Unscoped().Where(cond).Count(&Project{})
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
projects := []*Project{}
|
|
err = s.Unscoped().
|
|
Where(cond).
|
|
OrderBy("projects.deleted_at DESC, projects.id DESC").
|
|
Limit(getLimitFromPageIndex(page, perPage)).
|
|
Find(&projects)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
trashed = make([]*TrashedProject, 0, len(projects))
|
|
if len(projects) == 0 {
|
|
return trashed, total, nil
|
|
}
|
|
|
|
projectIDs := make([]int64, 0, len(projects))
|
|
userIDs := []int64{}
|
|
for _, project := range projects {
|
|
projectIDs = append(projectIDs, project.ID)
|
|
userIDs = append(userIDs, project.OwnerID)
|
|
if project.DeletedByID != 0 {
|
|
userIDs = append(userIDs, project.DeletedByID)
|
|
}
|
|
}
|
|
|
|
subProjects := []*trashedSubProjectRow{}
|
|
err = s.Table("project_ancestors").
|
|
Select("project_ancestors.ancestor_id, project_ancestors.depth, projects.id, projects.title, "+
|
|
"COALESCE(projects.parent_project_id, 0) AS parent_project_id").
|
|
Join("INNER", "projects", "projects.id = project_ancestors.project_id").
|
|
Where(builder.And(
|
|
builder.In("project_ancestors.ancestor_id", projectIDs),
|
|
builder.Gt{"project_ancestors.depth": 0},
|
|
builder.NotNull{"projects.deleted_at"},
|
|
)).
|
|
OrderBy("project_ancestors.depth, projects.title, projects.id").
|
|
Find(&subProjects)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
subProjectsByRoot := make(map[int64][]*TrashedSubProject, len(projects))
|
|
for _, sub := range subProjects {
|
|
subProjectsByRoot[sub.AncestorID] = append(subProjectsByRoot[sub.AncestorID], &sub.TrashedSubProject)
|
|
}
|
|
|
|
users, err := getUsersOrLinkSharesFromIDs(s, userIDs)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
for _, project := range projects {
|
|
project.Owner = users[project.OwnerID]
|
|
project.DeletedBy = users[project.DeletedByID]
|
|
trashed = append(trashed, &TrashedProject{
|
|
Project: *project,
|
|
TrashedSubProjects: subProjectsByRoot[project.ID],
|
|
})
|
|
}
|
|
|
|
return trashed, total, nil
|
|
}
|
|
|
|
// CountProjectTrash counts the trash rows EmptyProjectTrash would purge. Each row takes its
|
|
// trashed sub-projects along, so only who trashed the listed project counts.
|
|
func CountProjectTrash(s *xorm.Session, a web.Auth) (*TrashCount, error) {
|
|
cond, err := trashedProjectsCond(s, a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
count := &TrashCount{}
|
|
count.Total, err = s.Unscoped().Where(cond).Count(&Project{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
count.ByOthers, err = s.Unscoped().Where(builder.And(cond, trashedByOthersCond("projects", a))).Count(&Project{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
// EmptyProjectTrash purges every project in the auth's trash. Without includeOthers,
|
|
// projects someone else trashed stay.
|
|
func EmptyProjectTrash(s *xorm.Session, a web.Auth, includeOthers bool) (purged int, err error) {
|
|
cond, err := trashedProjectsCond(s, a)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if !includeOthers {
|
|
cond = builder.And(cond, builder.Eq{"projects.deleted_by_id": a.GetID()})
|
|
}
|
|
|
|
projects := []*Project{}
|
|
if err = s.Unscoped().Where(cond).Find(&projects); err != nil {
|
|
return 0, err
|
|
}
|
|
projectIDs, _ := getProjectIDsFromProjects(projects)
|
|
views, err := lockProjectTreeViewsForPositionUpdate(s, projectIDs...)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
doer := doerFromAuth(s, a)
|
|
for _, project := range projects {
|
|
if err = hardDeleteProjectInViews(s, project, doer, views); err != nil {
|
|
return 0, err
|
|
}
|
|
}
|
|
return len(projects), nil
|
|
}
|
|
|
|
// RegisterProjectCleanupCron purges projects trashed more than TrashRetention ago.
|
|
func RegisterProjectCleanupCron() {
|
|
err := cron.Schedule("30 * * * *", func() {
|
|
deleteExpiredProjects(time.Now())
|
|
})
|
|
if err != nil {
|
|
log.Errorf("Could not register project cleanup cron: %s", err.Error())
|
|
}
|
|
}
|
|
|
|
func deleteExpiredProjects(now time.Time) {
|
|
s := db.NewSession()
|
|
projectIDs := []int64{}
|
|
err := s.Table("projects").
|
|
Where(builder.And(
|
|
builder.NotNull{"deleted_at"},
|
|
builder.Lt{"deleted_at": now.Add(-TrashRetention)},
|
|
)).
|
|
Cols("id").
|
|
Find(&projectIDs)
|
|
s.Close()
|
|
if err != nil {
|
|
log.Errorf("Could not get projects scheduled for permanent deletion: %s", err)
|
|
return
|
|
}
|
|
|
|
for _, projectID := range projectIDs {
|
|
func() {
|
|
ps := db.NewSession()
|
|
defer ps.Close()
|
|
defer events.CleanupPending(ps)
|
|
|
|
// An earlier iteration may have purged it with its parent
|
|
project, err := getTrashedProject(ps, projectID)
|
|
if IsErrProjectDoesNotExist(err) {
|
|
return
|
|
}
|
|
if err == nil {
|
|
err = hardDeleteProject(ps, project, nil)
|
|
}
|
|
if err != nil {
|
|
_ = ps.Rollback()
|
|
log.Errorf("Could not permanently delete project %d: %s", projectID, err)
|
|
return
|
|
}
|
|
if err = ps.Commit(); err != nil {
|
|
log.Errorf("Could not commit transaction: %s", err)
|
|
return
|
|
}
|
|
events.DispatchPending(context.Background(), ps)
|
|
}()
|
|
}
|
|
}
|
|
|
|
// ProjectCount sums up what moving a project to the trash affects.
|
|
type ProjectCount struct {
|
|
SubProjects int64 `json:"sub_projects" doc:"Live sub-projects at any depth, which move to the trash with the project."`
|
|
Tasks int64 `json:"tasks" doc:"Tasks in the project and its sub-projects, not counting tasks already in the trash."`
|
|
TimeEntries int64 `json:"time_entries" doc:"Time entries on the project, its sub-projects or their tasks."`
|
|
TimeEntryAuthors int64 `json:"time_entry_authors" doc:"How many people logged those time entries."`
|
|
}
|
|
|
|
// GetProjectCount counts what the project and its live sub-projects contain. Admins only.
|
|
func GetProjectCount(s *xorm.Session, a web.Auth, projectID int64) (*ProjectCount, error) {
|
|
// An admin share would see totals of sub-projects it can't reach.
|
|
if _, isShare := a.(*LinkSharing); isShare {
|
|
return nil, ErrGenericForbidden{}
|
|
}
|
|
|
|
project := &Project{ID: projectID}
|
|
can, err := project.IsAdmin(s, a)
|
|
if IsErrProjectDoesNotExist(err) {
|
|
return nil, trashedProjectReadError(s, a, projectID, err)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !can {
|
|
return nil, ErrGenericForbidden{}
|
|
}
|
|
|
|
ids, err := subtreeIDs(s, projectID, builder.IsNull{"projects.deleted_at"})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
count := &ProjectCount{SubProjects: int64(len(ids)) - 1}
|
|
count.Tasks, err = s.In("project_id", ids).Count(&Task{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
entriesCond := builder.Or(
|
|
builder.In("project_id", ids),
|
|
builder.In("task_id", builder.Select("id").From("tasks").Where(builder.And(
|
|
builder.In("project_id", ids),
|
|
builder.IsNull{"deleted_at"},
|
|
))),
|
|
)
|
|
count.TimeEntries, err = s.Table("time_entries").Where(entriesCond).Count()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
authors := []int64{}
|
|
err = s.Table("time_entries").Where(entriesCond).Distinct("user_id").Find(&authors)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
count.TimeEntryAuthors = int64(len(authors))
|
|
|
|
return count, nil
|
|
}
|