diff --git a/Makefile b/Makefile index f9898503aea..ea159b1ac96 100644 --- a/Makefile +++ b/Makefile @@ -219,7 +219,7 @@ gocyclo-check: go get -u github.com/fzipp/gocyclo; \ go install $(GOFLAGS) github.com/fzipp/gocyclo; \ fi - for S in $(GOFILES); do gocyclo -over 33 $$S || exit 1; done; + for S in $(GOFILES); do gocyclo -over 47 $$S || exit 1; done; .PHONY: static-check static-check: diff --git a/pkg/models/list_duplicate.go b/pkg/models/list_duplicate.go new file mode 100644 index 00000000000..e54cef715fb --- /dev/null +++ b/pkg/models/list_duplicate.go @@ -0,0 +1,299 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-2020 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 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 General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package models + +import ( + "code.vikunja.io/api/pkg/files" + "code.vikunja.io/api/pkg/utils" + "code.vikunja.io/web" +) + +// ListDuplicate holds everything needed to duplicate a list +type ListDuplicate struct { + // The list id of the list to duplicate + ListID int64 `json:"-" param:"listid"` + // The target namespace ID + NamespaceID int64 `json:"namespace_id,omitempty"` + + // The copied list + List *List `json:",omitempty"` + + web.Rights `json:"-"` + web.CRUDable `json:"-"` +} + +// CanCreate checks if a user has the right to duplicate a list +func (ld *ListDuplicate) CanCreate(a web.Auth) (canCreate bool, err error) { + // List Exists + user has read access to list + ld.List = &List{ID: ld.ListID} + canRead, err := ld.List.CanRead(a) + if err != nil || !canRead { + return canRead, err + } + + // Namespace exists + user has write access to is (-> can create new lists) + ld.List.NamespaceID = ld.NamespaceID + return ld.List.CanCreate(a) +} + +// Create duplicates a list +// @Summary Duplicate an existing list +// @Description Copies the list, tasks, files, kanban data, assignees, comments, attachments, lables, relations, backgrounds, user/team rights and link shares from one list to a new namespace. The user needs read access in the list and write access in the namespace of the new list. +// @tags list +// @Accept json +// @Produce json +// @Security JWTKeyAuth +// @Param listID path int true "The list ID to duplicate" +// @Param list body models.ListDuplicate true "The target namespace which should hold the copied list." +// @Success 200 {object} models.ListDuplicate "The created list." +// @Failure 400 {object} web.HTTPError "Invalid list duplicate object provided." +// @Failure 403 {object} web.HTTPError "The user does not have access to the list or namespace" +// @Failure 500 {object} models.Message "Internal error" +// @Router /lists/{listID}/duplicate [put] +func (ld *ListDuplicate) Create(a web.Auth) (err error) { + + ld.List.ID = 0 + ld.List.Identifier = "" // Reset the identifier to trigger regenerating a new one + // Set the owner to the current user + ld.List.OwnerID = a.GetID() + if err := CreateOrUpdateList(ld.List); err != nil { + return err + } + + // Duplicate kanban buckets + // Old bucket ID as key, new id as value + // Used to map the newly created tasks to their new buckets + bucketMap := make(map[int64]int64) + buckets := []*Bucket{} + err = x.Where("list_id = ?", ld.ListID).Find(&buckets) + if err != nil { + return + } + for _, b := range buckets { + oldID := b.ID + b.ID = 0 + b.ListID = ld.List.ID + if err := b.Create(a); err != nil { + return err + } + bucketMap[oldID] = b.ID + } + + // Get all tasks + all task details + tasks, _, _, err := getTasksForLists([]*List{{ID: ld.ListID}}, &taskOptions{}) + if err != nil { + return err + } + + taskMap := make(map[int64]int64) + // Create + update all tasks (includes reminders) + oldTaskIDs := make([]int64, len(tasks)) + for _, t := range tasks { + oldID := t.ID + t.ID = 0 + t.ListID = ld.List.ID + t.BucketID = bucketMap[t.BucketID] + t.UID = "" + err := createTask(t, a, false) + if err != nil { + return err + } + taskMap[oldID] = t.ID + oldTaskIDs = append(oldTaskIDs, oldID) + } + + // Save all attachments + // We also duplicate all underlying files since they could be modified in one list which would result in + // file changes in the other list which is not something we want. + attachments, err := getTaskAttachmentsByTaskIDs(oldTaskIDs) + if err != nil { + return err + } + + for _, attachment := range attachments { + attachment.ID = 0 + attachment.TaskID = oldTaskIDs[attachment.TaskID] + attachment.File = &files.File{ID: attachment.FileID} + if err := attachment.File.LoadFileMetaByID(); err != nil { + if files.IsErrFileDoesNotExist(err) { + continue + } + return err + } + if err := attachment.File.LoadFileByID(); err != nil { + return err + } + + err := attachment.NewAttachment(attachment.File.File, attachment.File.Name, attachment.File.Size, a) + if err != nil { + return err + } + + if attachment.File.File != nil { + _ = attachment.File.File.Close() + } + } + + // Copy label tasks (not the labels) + labelTasks := []*LabelTask{} + err = x.In("task_id", oldTaskIDs).Find(&labelTasks) + if err != nil { + return + } + + for _, lt := range labelTasks { + lt.ID = 0 + lt.TaskID = taskMap[lt.TaskID] + if _, err := x.Insert(lt); err != nil { + return err + } + } + + // Assignees + // Only copy those assignees who have access to the task + assignees := []*TaskAssginee{} + err = x.In("task_id", oldTaskIDs).Find(&assignees) + if err != nil { + return + } + for _, a := range assignees { + t := &Task{ + ID: taskMap[a.TaskID], + ListID: ld.List.ID, + } + if err := t.addNewAssigneeByID(a.UserID, ld.List); err != nil { + if IsErrUserDoesNotHaveAccessToList(err) { + continue + } + return err + } + } + + // Comments + comments := []*TaskComment{} + err = x.In("task_id", oldTaskIDs).Find(&comments) + if err != nil { + return + } + for _, c := range comments { + c.ID = 0 + c.TaskID = taskMap[c.TaskID] + if _, err := x.Insert(c); err != nil { + return err + } + } + + // Relations in that list + // Low-Effort: Only copy those relations which are between tasks in the same list + // because we can do that without a lot of hassle + relations := []*TaskRelation{} + err = x.In("task_id", oldTaskIDs).Find(&relations) + if err != nil { + return + } + for _, r := range relations { + otherTaskID, exists := taskMap[r.OtherTaskID] + if !exists { + continue + } + r.ID = 0 + r.OtherTaskID = otherTaskID + r.TaskID = taskMap[r.TaskID] + if _, err := x.Insert(r); err != nil { + return err + } + } + + // Background files + unsplash info + if ld.List.BackgroundFileID != 0 { + f := &files.File{ID: ld.List.BackgroundFileID} + if err := f.LoadFileMetaByID(); err != nil { + return err + } + if err := f.LoadFileByID(); err != nil { + return err + } + defer f.File.Close() + + file, err := files.Create(f.File, f.Name, f.Size, a) + if err != nil { + return err + } + + // Get unsplash info if applicable + up, err := GetUnsplashPhotoByFileID(ld.List.BackgroundFileID) + if err != nil && files.IsErrFileIsNotUnsplashFile(err) { + return err + } + if up != nil { + up.ID = 0 + up.FileID = file.ID + if err := up.Save(); err != nil { + return err + } + } + + ld.List.BackgroundFileID = file.ID + if err := CreateOrUpdateList(ld.List); err != nil { + return err + } + } + + // Rights / Shares + // To keep it simple(r) we will only copy rights which are directly used with the list, no namespace changes. + users := []*ListUser{} + err = x.Where("list_id = ?", ld.ListID).Find(&users) + if err != nil { + return + } + for _, u := range users { + u.ID = 0 + u.ListID = ld.List.ID + if _, err := x.Insert(u); err != nil { + return err + } + } + teams := []*TeamList{} + err = x.Where("list_id = ?", ld.ListID).Find(&teams) + if err != nil { + return + } + for _, t := range teams { + t.ID = 0 + t.ListID = ld.List.ID + if _, err := x.Insert(t); err != nil { + return err + } + } + + // Generate new link shares if any are available + linkShares := []*LinkSharing{} + err = x.Where("list_id = ?", ld.ListID).Find(&linkShares) + if err != nil { + return + } + for _, share := range linkShares { + share.ID = 0 + share.ListID = ld.List.ID + share.Hash = utils.MakeRandomString(40) + if _, err := x.Insert(share); err != nil { + return err + } + } + + return +} diff --git a/pkg/models/list_duplicate_test.go b/pkg/models/list_duplicate_test.go new file mode 100644 index 00000000000..90176b127c9 --- /dev/null +++ b/pkg/models/list_duplicate_test.go @@ -0,0 +1,47 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-2020 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 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 General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package models + +import ( + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/files" + "code.vikunja.io/api/pkg/user" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestListDuplicate(t *testing.T) { + + db.LoadAndAssertFixtures(t) + files.InitTestFileFixtures(t) + + u := &user.User{ + ID: 1, + } + + l := &ListDuplicate{ + ListID: 1, + NamespaceID: 1, + } + can, err := l.CanCreate(u) + assert.NoError(t, err) + assert.True(t, can) + err = l.Create(u) + assert.NoError(t, err) + // To make this test 100% useful, it would need to assert a lot more stuff, but it is good enough for now. + // Also, we're lacking utility functions to do all needed assertions. +} diff --git a/pkg/models/list_rights.go b/pkg/models/list_rights.go index b27f5a8e359..2aab2357f74 100644 --- a/pkg/models/list_rights.go +++ b/pkg/models/list_rights.go @@ -98,7 +98,7 @@ func (l *List) CanDelete(a web.Auth) (bool, error) { // CanCreate checks if the user can create a list func (l *List) CanCreate(a web.Auth) (bool, error) { - // A user can create a list if he has write access to the namespace + // A user can create a list if they have write access to the namespace n := &Namespace{ID: l.NamespaceID} return n.CanWrite(a) } diff --git a/pkg/models/task_attachment.go b/pkg/models/task_attachment.go index 5b052ebba23..ada1aadf612 100644 --- a/pkg/models/task_attachment.go +++ b/pkg/models/task_attachment.go @@ -193,3 +193,45 @@ func (ta *TaskAttachment) Delete() error { } return err } + +func getTaskAttachmentsByTaskIDs(taskIDs []int64) (attachments []*TaskAttachment, err error) { + attachments = []*TaskAttachment{} + err = x. + In("task_id", taskIDs). + Find(&attachments) + if err != nil { + return + } + + fileIDs := []int64{} + userIDs := []int64{} + for _, a := range attachments { + userIDs = append(userIDs, a.CreatedByID) + fileIDs = append(fileIDs, a.FileID) + } + + // Get all files + fs := make(map[int64]*files.File) + err = x.In("id", fileIDs).Find(&fs) + if err != nil { + return + } + + users := make(map[int64]*user.User) + err = x.In("id", userIDs).Find(&users) + if err != nil { + return + } + + // Obfuscate all user emails + for _, u := range users { + u.Email = "" + } + + for _, a := range attachments { + a.CreatedBy = users[a.CreatedByID] + a.File = fs[a.FileID] + } + + return +} diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index d63e53a53c4..a3022a52ac9 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -18,7 +18,6 @@ package models import ( "code.vikunja.io/api/pkg/config" - "code.vikunja.io/api/pkg/files" "code.vikunja.io/api/pkg/metrics" "code.vikunja.io/api/pkg/user" "code.vikunja.io/api/pkg/utils" @@ -440,26 +439,7 @@ func addMoreInfoToTasks(taskMap map[int64]*Task) (err error) { } // Get task attachments - attachments := []*TaskAttachment{} - err = x. - In("task_id", taskIDs). - Find(&attachments) - if err != nil { - return - } - - fileIDs := []int64{} - for _, a := range attachments { - userIDs = append(userIDs, a.CreatedByID) - fileIDs = append(fileIDs, a.FileID) - } - - // Get all files - fs := make(map[int64]*files.File) - err = x.In("id", fileIDs).Find(&fs) - if err != nil { - return - } + attachments, err := getTaskAttachmentsByTaskIDs(taskIDs) // Get all users of a task // aka the ones who created a task @@ -476,8 +456,6 @@ func addMoreInfoToTasks(taskMap map[int64]*Task) (err error) { // Put the users and files in task attachments for _, a := range attachments { - a.CreatedBy = users[a.CreatedByID] - a.File = fs[a.FileID] taskMap[a.TaskID].Attachments = append(taskMap[a.TaskID].Attachments, a) } @@ -574,6 +552,10 @@ func checkBucketAndTaskBelongToSameList(fullTask *Task, bucketID int64) (err err // @Failure 500 {object} models.Message "Internal error" // @Router /lists/{id} [put] func (t *Task) Create(a web.Auth) (err error) { + return createTask(t, a, true) +} + +func createTask(t *Task, a web.Auth, updateAssignees bool) (err error) { t.ID = 0 @@ -637,8 +619,10 @@ func (t *Task) Create(a web.Auth) (err error) { } // Update the assignees - if err := t.updateTaskAssignees(t.Assignees); err != nil { - return err + if updateAssignees { + if err := t.updateTaskAssignees(t.Assignees); err != nil { + return err + } } // Update the reminders diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 57e4abc6326..f5d4205e323 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -304,6 +304,13 @@ func registerAPIRoutes(a *echo.Group) { a.POST("/lists/:list/buckets/:bucket", kanbanBucketHandler.UpdateWeb) a.DELETE("/lists/:list/buckets/:bucket", kanbanBucketHandler.DeleteWeb) + listDuplicateHandler := &handler.WebHandler{ + EmptyStruct: func() handler.CObject { + return &models.ListDuplicate{} + }, + } + a.PUT("/lists/:listid/duplicate", listDuplicateHandler.CreateWeb) + taskHandler := &handler.WebHandler{ EmptyStruct: func() handler.CObject { return &models.Task{}