Duplicate Lists #603
2
Makefile
2
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:
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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.
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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{}
|
||||
|
|
Loading…
Reference in New Issue