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 <k@knt.li>
Reviewed-on: https://kolaente.dev/vikunja/api/pulls/603
This commit is contained in:
konrad 2020-06-30 20:53:14 +00:00
parent 6da137cd4a
commit 1181039249
7 changed files with 406 additions and 27 deletions

View file

@ -219,7 +219,7 @@ gocyclo-check:
go get -u github.com/fzipp/gocyclo; \ go get -u github.com/fzipp/gocyclo; \
go install $(GOFLAGS) github.com/fzipp/gocyclo; \ go install $(GOFLAGS) github.com/fzipp/gocyclo; \
fi 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 .PHONY: static-check
static-check: static-check:

View file

@ -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 <https://www.gnu.org/licenses/>.
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
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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.
}

View file

@ -98,7 +98,7 @@ func (l *List) CanDelete(a web.Auth) (bool, error) {
// CanCreate checks if the user can create a list // CanCreate checks if the user can create a list
func (l *List) CanCreate(a web.Auth) (bool, error) { 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} n := &Namespace{ID: l.NamespaceID}
return n.CanWrite(a) return n.CanWrite(a)
} }

View file

@ -193,3 +193,45 @@ func (ta *TaskAttachment) Delete() error {
} }
return err 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
}

View file

@ -18,7 +18,6 @@ package models
import ( import (
"code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/files"
"code.vikunja.io/api/pkg/metrics" "code.vikunja.io/api/pkg/metrics"
"code.vikunja.io/api/pkg/user" "code.vikunja.io/api/pkg/user"
"code.vikunja.io/api/pkg/utils" "code.vikunja.io/api/pkg/utils"
@ -440,26 +439,7 @@ func addMoreInfoToTasks(taskMap map[int64]*Task) (err error) {
} }
// Get task attachments // Get task attachments
attachments := []*TaskAttachment{} attachments, err := getTaskAttachmentsByTaskIDs(taskIDs)
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
}
// Get all users of a task // Get all users of a task
// aka the ones who created 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 // Put the users and files in task attachments
for _, a := range 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) 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" // @Failure 500 {object} models.Message "Internal error"
// @Router /lists/{id} [put] // @Router /lists/{id} [put]
func (t *Task) Create(a web.Auth) (err error) { 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 t.ID = 0
@ -637,9 +619,11 @@ func (t *Task) Create(a web.Auth) (err error) {
} }
// Update the assignees // Update the assignees
if updateAssignees {
if err := t.updateTaskAssignees(t.Assignees); err != nil { if err := t.updateTaskAssignees(t.Assignees); err != nil {
return err return err
} }
}
// Update the reminders // Update the reminders
if err := t.updateReminders(t.Reminders); err != nil { if err := t.updateReminders(t.Reminders); err != nil {

View file

@ -304,6 +304,13 @@ func registerAPIRoutes(a *echo.Group) {
a.POST("/lists/:list/buckets/:bucket", kanbanBucketHandler.UpdateWeb) a.POST("/lists/:list/buckets/:bucket", kanbanBucketHandler.UpdateWeb)
a.DELETE("/lists/:list/buckets/:bucket", kanbanBucketHandler.DeleteWeb) 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{ taskHandler := &handler.WebHandler{
EmptyStruct: func() handler.CObject { EmptyStruct: func() handler.CObject {
return &models.Task{} return &models.Task{}