From 118103924900ccb8cdf73da95b569edf93a2cf95 Mon Sep 17 00:00:00 2001 From: konrad Date: Tue, 30 Jun 2020 20:53:14 +0000 Subject: [PATCH] Duplicate Lists (#603) Fix buckets not being duplicated correctly Fix list id param not working Add api endpoint Add swagger docs Add comment about test Make duplicating actually work Add copying link shares Add copying list backgrounds Add copying task relations Add copying task comments Add copying assignees Add copying task task label relations Add copying task attachments Add duplicating tasks Add basic struct and methods Co-authored-by: kolaente Reviewed-on: https://kolaente.dev/vikunja/api/pulls/603 --- Makefile | 2 +- pkg/models/list_duplicate.go | 299 ++++++++++++++++++++++++++++++ pkg/models/list_duplicate_test.go | 47 +++++ pkg/models/list_rights.go | 2 +- pkg/models/task_attachment.go | 42 +++++ pkg/models/tasks.go | 34 +--- pkg/routes/routes.go | 7 + 7 files changed, 406 insertions(+), 27 deletions(-) create mode 100644 pkg/models/list_duplicate.go create mode 100644 pkg/models/list_duplicate_test.go 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{}