From d8f3a96b06fc40d4b30954cc71a3bb43890f8cfc Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 4 Mar 2026 16:00:08 +0100 Subject: [PATCH] feat: add task duplicate backend model and tests --- pkg/models/task_duplicate.go | 172 ++++++++++++++++++++++++++++++ pkg/models/task_duplicate_test.go | 89 ++++++++++++++++ 2 files changed, 261 insertions(+) create mode 100644 pkg/models/task_duplicate.go create mode 100644 pkg/models/task_duplicate_test.go diff --git a/pkg/models/task_duplicate.go b/pkg/models/task_duplicate.go new file mode 100644 index 000000000..6be9bbec1 --- /dev/null +++ b/pkg/models/task_duplicate.go @@ -0,0 +1,172 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present 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 Affero 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 Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package models + +import ( + "code.vikunja.io/api/pkg/files" + "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/web" + + "xorm.io/xorm" +) + +// TaskDuplicate holds everything needed to duplicate a task +type TaskDuplicate struct { + // The task id of the task to duplicate + TaskID int64 `json:"-" param:"projecttask"` + + // The duplicated task + Task *Task `json:"duplicated_task,omitempty"` + + web.Permissions `json:"-"` + web.CRUDable `json:"-"` +} + +// CanCreate checks if a user has the permission to duplicate a task +func (td *TaskDuplicate) CanCreate(s *xorm.Session, a web.Auth) (canCreate bool, err error) { + // Need read access on the original task + originalTask := &Task{ID: td.TaskID} + canRead, _, err := originalTask.CanRead(s, a) + if err != nil || !canRead { + return canRead, err + } + + // Need write access on the project to create tasks in it + p := &Project{ID: originalTask.ProjectID} + return p.CanUpdate(s, a) +} + +// Create duplicates a task +// @Summary Duplicate a task +// @Description Copies a task with all its properties (labels, assignees, attachments, reminders) into the same project. Creates a "copied from" relation between the new and original task. +// @tags task +// @Accept json +// @Produce json +// @Security JWTKeyAuth +// @Param taskID path int true "The task ID to duplicate" +// @Success 201 {object} models.TaskDuplicate "The duplicated task." +// @Failure 403 {object} web.HTTPError "The user does not have access to the task." +// @Failure 500 {object} models.Message "Internal error" +// @Router /tasks/{taskID}/duplicate [put] +func (td *TaskDuplicate) Create(s *xorm.Session, doer web.Auth) (err error) { + // Get the original task with all details + originalTask := &Task{ID: td.TaskID} + err = originalTask.ReadOne(s, doer) + if err != nil { + return err + } + + log.Debugf("Duplicating task %d", td.TaskID) + + // Create the new task + newTask := &Task{ + Title: originalTask.Title, + Description: originalTask.Description, + Done: false, + DueDate: originalTask.DueDate, + ProjectID: originalTask.ProjectID, + RepeatAfter: originalTask.RepeatAfter, + RepeatMode: originalTask.RepeatMode, + Priority: originalTask.Priority, + StartDate: originalTask.StartDate, + EndDate: originalTask.EndDate, + HexColor: originalTask.HexColor, + PercentDone: originalTask.PercentDone, + Assignees: originalTask.Assignees, + Reminders: originalTask.Reminders, + } + + err = createTask(s, newTask, doer, true, true) + if err != nil { + return err + } + + log.Debugf("Duplicated task %d into new task %d", td.TaskID, newTask.ID) + + // Duplicate labels + labelTasks := []*LabelTask{} + err = s.Where("task_id = ?", td.TaskID).Find(&labelTasks) + if err != nil { + return err + } + for _, lt := range labelTasks { + lt.ID = 0 + lt.TaskID = newTask.ID + if _, err := s.Insert(lt); err != nil { + return err + } + } + + log.Debugf("Duplicated labels from task %d into %d", td.TaskID, newTask.ID) + + // Duplicate attachments (copy underlying files) + attachments, err := getTaskAttachmentsByTaskIDs(s, []int64{td.TaskID}) + if err != nil { + return err + } + for _, attachment := range attachments { + attachment.ID = 0 + attachment.TaskID = newTask.ID + attachment.File = &files.File{ID: attachment.FileID} + if err := attachment.File.LoadFileMetaByID(); err != nil { + if files.IsErrFileDoesNotExist(err) { + log.Debugf("Not duplicating attachment (file %d) because it does not exist", attachment.FileID) + continue + } + return err + } + if err := attachment.File.LoadFileByID(); err != nil { + return err + } + + err := attachment.NewAttachment(s, attachment.File.File, attachment.File.Name, attachment.File.Size, doer) + if err != nil { + return err + } + if attachment.File.File != nil { + _ = attachment.File.File.Close() + } + } + + log.Debugf("Duplicated attachments from task %d into %d", td.TaskID, newTask.ID) + + // Create "copied from/to" relation + rel := &TaskRelation{ + TaskID: newTask.ID, + OtherTaskID: td.TaskID, + RelationKind: RelationKindCopiedFrom, + CreatedByID: doer.GetID(), + } + if _, err := s.Insert(rel); err != nil { + return err + } + reverseRel := &TaskRelation{ + TaskID: td.TaskID, + OtherTaskID: newTask.ID, + RelationKind: RelationKindCopiedTo, + CreatedByID: doer.GetID(), + } + if _, err := s.Insert(reverseRel); err != nil { + return err + } + + log.Debugf("Created copy relations between task %d and %d", td.TaskID, newTask.ID) + + // Re-read the task to populate all fields for the response + td.Task = newTask + return td.Task.ReadOne(s, doer) +} diff --git a/pkg/models/task_duplicate_test.go b/pkg/models/task_duplicate_test.go new file mode 100644 index 000000000..4435af873 --- /dev/null +++ b/pkg/models/task_duplicate_test.go @@ -0,0 +1,89 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present 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 Affero 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 Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package models + +import ( + "testing" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/files" + "code.vikunja.io/api/pkg/user" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTaskDuplicate(t *testing.T) { + t.Run("basic duplicate", func(t *testing.T) { + files.InitTestFileFixtures(t) + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + u := &user.User{ID: 1} + + td := &TaskDuplicate{ + TaskID: 1, + } + can, err := td.CanCreate(s, u) + require.NoError(t, err) + assert.True(t, can) + err = td.Create(s, u) + require.NoError(t, err) + assert.NotEqual(t, int64(0), td.Task.ID) + assert.NotEqual(t, int64(1), td.Task.ID) + assert.Equal(t, "task #1", td.Task.Title) + + // Verify labels were copied + labelCount, err := s.Where("task_id = ?", td.Task.ID).Count(&LabelTask{}) + require.NoError(t, err) + originalLabelCount, err := s.Where("task_id = ?", int64(1)).Count(&LabelTask{}) + require.NoError(t, err) + assert.Equal(t, originalLabelCount, labelCount) + + // Verify assignees were copied + assigneeCount, err := s.Where("task_id = ?", td.Task.ID).Count(&TaskAssginee{}) + require.NoError(t, err) + originalAssigneeCount, err := s.Where("task_id = ?", int64(1)).Count(&TaskAssginee{}) + require.NoError(t, err) + assert.Equal(t, originalAssigneeCount, assigneeCount) + + // Verify a "copiedfrom" relation was created + relationCount, err := s. + Where("task_id = ? AND other_task_id = ? AND relation_kind = ?", + td.Task.ID, int64(1), RelationKindCopiedFrom). + Count(&TaskRelation{}) + require.NoError(t, err) + assert.Equal(t, int64(1), relationCount) + }) + + t.Run("no permission", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + // User 2 does not have write access to task 1's project (project 1) + u := &user.User{ID: 2} + + td := &TaskDuplicate{ + TaskID: 1, + } + can, err := td.CanCreate(s, u) + require.NoError(t, err) + assert.False(t, can) + }) +}