diff --git a/Makefile b/Makefile
index f9898503..ea159b1a 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 00000000..e54cef71
--- /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 00000000..90176b12
--- /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 b27f5a8e..2aab2357 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 5b052ebb..ada1aadf 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 d63e53a5..a3022a52 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 57e4abc6..f5d4205e 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{}