fix(views): move bucket update to extra endpoint
All checks were successful
continuous-integration/drone/push Build is passing

BREAKING CHANGE: The bucket id of the task model is now only used internally and will not trigger a change in buckets when updating the task.

This resolves a problem where the task update routine needs to know the view context it is in. Because that's not really what it should be used for, the extra endpoint takes all required parameters and handles the complexity of actually updating the bucket.
This fixes a bug where it was impossible to move a task around between buckets of a saved filter view. In that case, the view of the bucket and the project the task was in would be different, hence the update failed.
This commit is contained in:
kolaente 2024-07-02 16:33:46 +02:00
parent e6ce1251f7
commit 359b07dabb
Signed by: konrad
GPG Key ID: F40E70337AB24C9B
16 changed files with 743 additions and 350 deletions

View File

@ -306,6 +306,8 @@ import TaskPositionModel from '@/models/taskPosition'
import {i18n} from '@/i18n'
import ProjectViewService from '@/services/projectViews'
import ProjectViewModel from '@/models/projectView'
import TaskBucketService from '@/services/taskBucket'
import TaskBucketModel from '@/models/taskBucket'
const {
projectId,
@ -333,6 +335,7 @@ const kanbanStore = useKanbanStore()
const taskStore = useTaskStore()
const projectStore = useProjectStore()
const taskPositionService = ref(new TaskPositionService())
const taskBucketService = ref(new TaskBucketService())
const taskContainerRefs = ref<{ [id: IBucket['id']]: HTMLElement }>({})
const bucketLimitInputRef = ref<HTMLInputElement | null>(null)
@ -519,7 +522,12 @@ async function updateTaskPosition(e) {
await taskPositionService.value.update(newPosition)
if(bucketHasChanged) {
await taskStore.update(newTask)
await taskBucketService.value.update(new TaskBucketModel({
taskId: newTask.id,
bucketId: newTask.bucketId,
projectViewId: viewId,
projectId: project.value.id,
}))
}
// Make sure the first and second task don't both get position 0 assigned

View File

@ -0,0 +1,12 @@
import type {IProjectView} from '@/modelTypes/IProjectView'
import type {IAbstract} from '@/modelTypes/IAbstract'
import type {IBucket} from '@/modelTypes/IBucket'
import type {ITask} from '@/modelTypes/ITask'
import type {IProject} from '@/modelTypes/IProject'
export interface ITaskBucket extends IAbstract {
taskId: ITask['id']
bucketId: IBucket['id']
projectViewId: IProjectView['id']
projectId: IProject['id']
}

View File

@ -0,0 +1,14 @@
import AbstractModel from '@/models/abstractModel'
import type {ITaskBucket} from '@/modelTypes/ITaskBucket'
export default class TaskBucketModel extends AbstractModel<ITaskBucket> implements ITaskBucket {
taskId = 0
bucketId = 0
projectViewId = 0
projectId = 0
constructor(data: Partial<ITaskBucket>) {
super()
this.assignData(data)
}
}

View File

@ -0,0 +1,15 @@
import AbstractService from '@/services/abstractService'
import type {ITaskBucket} from '@/modelTypes/ITaskBucket'
import TaskBucketModel from '@/models/taskBucket'
export default class TaskBucketService extends AbstractService<ITaskBucket> {
constructor() {
super({
update: '/projects/{projectId}/views/{projectViewId}/buckets/{bucketId}/tasks',
})
}
modelFactory(data: Partial<ITaskBucket>) {
return new TaskBucketModel(data)
}
}

View File

@ -307,24 +307,6 @@ func TestTask(t *testing.T) {
assertHandlerErrorCode(t, err, models.ErrorCodeGenericForbidden)
})
})
t.Run("Bucket", func(t *testing.T) {
t.Run("Normal", func(t *testing.T) {
rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"projecttask": "1"}, `{"bucket_id":3}`)
require.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"bucket_id":3`)
assert.NotContains(t, rec.Body.String(), `"bucket_id":1`)
})
t.Run("Different Project", func(t *testing.T) {
_, err := testHandler.testUpdateWithUser(nil, map[string]string{"projecttask": "1"}, `{"bucket_id":4}`)
require.Error(t, err)
assertHandlerErrorCode(t, err, models.ErrCodeBucketDoesNotExist)
})
t.Run("Nonexisting Bucket", func(t *testing.T) {
_, err := testHandler.testUpdateWithUser(nil, map[string]string{"projecttask": "1"}, `{"bucket_id":9999}`)
require.Error(t, err)
assertHandlerErrorCode(t, err, models.ErrCodeBucketDoesNotExist)
})
})
})
t.Run("Delete", func(t *testing.T) {
t.Run("Normal", func(t *testing.T) {
@ -490,24 +472,6 @@ func TestTask(t *testing.T) {
assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`)
})
})
t.Run("Bucket", func(t *testing.T) {
t.Run("Normal", func(t *testing.T) {
rec, err := testHandler.testCreateWithUser(nil, map[string]string{"project": "1"}, `{"title":"Lorem Ipsum","bucket_id":3}`)
require.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"bucket_id":3`)
assert.NotContains(t, rec.Body.String(), `"bucket_id":1`)
})
t.Run("Different Project", func(t *testing.T) {
_, err := testHandler.testCreateWithUser(nil, map[string]string{"project": "1"}, `{"title":"Lorem Ipsum","bucket_id":4}`)
require.Error(t, err)
assertHandlerErrorCode(t, err, models.ErrCodeBucketDoesNotBelongToProject)
})
t.Run("Nonexisting Bucket", func(t *testing.T) {
_, err := testHandler.testCreateWithUser(nil, map[string]string{"project": "1"}, `{"title":"Lorem Ipsum","bucket_id":9999}`)
require.Error(t, err)
assertHandlerErrorCode(t, err, models.ErrCodeBucketDoesNotExist)
})
})
t.Run("Link Share", func(t *testing.T) {
rec, err := testHandlerLinkShareWrite.testCreateWithLinkShare(nil, map[string]string{"project": "2"}, `{"title":"Lorem Ipsum"}`)
require.NoError(t, err)

View File

@ -70,16 +70,6 @@ func (b *Bucket) TableName() string {
return "buckets"
}
type TaskBucket struct {
BucketID int64 `xorm:"bigint not null index"`
TaskID int64 `xorm:"bigint not null index"`
ProjectViewID int64 `xorm:"bigint not null index"`
}
func (b *TaskBucket) TableName() string {
return "task_buckets"
}
func getBucketByID(s *xorm.Session, id int64) (b *Bucket, err error) {
b = &Bucket{}
exists, err := s.Where("id = ?", id).Get(b)

View File

@ -53,6 +53,8 @@ func (b *Bucket) canDoBucket(s *xorm.Session, a web.Auth) (bool, error) {
return false, err
}
// TODO saved filter check
p := &Project{ID: pv.ProjectID}
return p.CanUpdate(s, a)
}

View File

@ -0,0 +1,179 @@
// 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 Licensee 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 Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package models
import (
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/web"
"xorm.io/xorm"
)
type TaskBucket struct {
BucketID int64 `xorm:"bigint not null index" json:"bucket_id" param:"bucket"`
TaskID int64 `xorm:"bigint not null index" json:"task_id"`
ProjectViewID int64 `xorm:"bigint not null index" json:"project_view_id" param:"view"`
ProjectID int64 `xorm:"-" json:"-" param:"project"`
TaskDone bool `xorm:"-" json:"task_done,omitempty"`
web.Rights `xorm:"-" json:"-"`
web.CRUDable `xorm:"-" json:"-"`
}
func (b *TaskBucket) TableName() string {
return "task_buckets"
}
func (b *TaskBucket) CanUpdate(s *xorm.Session, a web.Auth) (bool, error) {
bucket := Bucket{
ID: b.BucketID,
ProjectID: b.ProjectID,
ProjectViewID: b.ProjectViewID,
}
return bucket.canDoBucket(s, a)
}
// Update is the handler to update a task bucket
// @Summary Update a task bucket
// @Description Updates a task in a bucket
// @tags task
// @Accept json
// @Produce json
// @Security JWTKeyAuth
// @Param project path int true "Project ID"
// @Param view path int true "Project View ID"
// @Param bucket path int true "Bucket ID"
// @Param taskBucket body models.TaskBucket true "The id of the task you want to move into the bucket."
// @Success 200 {object} models.TaskBucket "The updated task bucket."
// @Failure 400 {object} web.HTTPError "Invalid task bucket object provided."
// @Failure 500 {object} models.Message "Internal error"
// @Router /projects/{project}/views/{view}/buckets/{bucket}/tasks [post]
func (b *TaskBucket) Update(s *xorm.Session, a web.Auth) (err error) {
oldTaskBucket := &TaskBucket{}
_, err = s.
Where("task_id = ? AND project_view_id = ?", b.TaskID, b.ProjectViewID).
Get(oldTaskBucket)
if err != nil {
return
}
if oldTaskBucket.BucketID == b.BucketID {
// no need to do anything
return
}
view, err := GetProjectViewByIDAndProject(s, b.ProjectViewID, b.ProjectID)
if err != nil {
return err
}
bucket, err := getBucketByID(s, b.BucketID)
if err != nil {
return err
}
// If there is a bucket set, make sure they belong to the same project as the task
if view.ID != bucket.ProjectViewID {
return ErrBucketDoesNotBelongToProjectView{
ProjectViewID: view.ID,
BucketID: bucket.ID,
}
}
task, err := GetTaskByIDSimple(s, b.TaskID)
if err != nil {
return err
}
// Check the bucket limit
// Only check the bucket limit if the task is being moved between buckets, allow reordering the task within a bucket
if b.BucketID != 0 && b.BucketID != oldTaskBucket.BucketID {
err = checkBucketLimit(s, &task, bucket)
if err != nil {
return err
}
}
var updateBucket = true
// mark task done if moved into the done bucket
var doneChanged bool
if view.DoneBucketID == b.BucketID {
doneChanged = true
task.Done = true
if task.RepeatAfter > 0 {
oldTask := task
task.Done = false
updateDone(&oldTask, &task)
updateBucket = false
}
}
if oldTaskBucket.BucketID == view.DoneBucketID {
doneChanged = true
task.Done = false
}
if doneChanged {
_, err = s.Where("id = ?", task.ID).
Cols(
"done",
"due_date",
"start_date",
"end_date",
).
Update(task)
if err != nil {
return
}
err = task.updateReminders(s, &task)
if err != nil {
return err
}
doer, _ := user.GetFromAuth(a)
err = events.Dispatch(&TaskUpdatedEvent{
Task: &task,
Doer: doer,
})
if err != nil {
return err
}
}
if updateBucket {
count, err := s.Where("task_id = ? AND project_view_id = ?", b.TaskID, b.ProjectViewID).
Cols("bucket_id").
Update(b)
if err != nil {
return err
}
if count == 0 {
_, err = s.Insert(b)
if err != nil {
return err
}
}
}
b.TaskDone = task.Done
return
}

View File

@ -0,0 +1,159 @@
// 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 Licensee 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 Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package models
import (
"testing"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/user"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTaskBucket_Update(t *testing.T) {
u := &user.User{ID: 1}
t.Run("full bucket", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
tb := &TaskBucket{
TaskID: 1,
BucketID: 2, // Bucket 2 already has 3 tasks and a limit of 3
ProjectViewID: 4,
ProjectID: 1, // In actual web requests set via the url
}
err := tb.Update(s, u)
require.Error(t, err)
assert.True(t, IsErrBucketLimitExceeded(err))
})
t.Run("full bucket but not changing the bucket", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
tb := &TaskBucket{
TaskID: 4,
BucketID: 2, // Bucket 2 already has 3 tasks and a limit of 3
ProjectViewID: 4,
ProjectID: 1, // In actual web requests set via the url
}
err := tb.Update(s, u)
require.NoError(t, err)
})
t.Run("bucket on other project view", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
tb := &TaskBucket{
TaskID: 1,
BucketID: 4, // Bucket 4 belongs to project 2
ProjectViewID: 4,
ProjectID: 1, // In actual web requests set via the url
}
err := tb.Update(s, u)
require.Error(t, err)
assert.True(t, IsErrBucketDoesNotBelongToProject(err))
})
t.Run("moving a task to the done bucket", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
tb := &TaskBucket{
TaskID: 1,
BucketID: 3, // Bucket 3 is the done bucket
ProjectViewID: 4,
ProjectID: 1, // In actual web requests set via the url
}
err := tb.Update(s, u)
require.NoError(t, err)
err = s.Commit()
require.NoError(t, err)
assert.True(t, tb.TaskDone)
db.AssertExists(t, "tasks", map[string]interface{}{
"id": 1,
"done": true,
}, false)
db.AssertExists(t, "task_buckets", map[string]interface{}{
"task_id": 1,
"bucket_id": 3,
}, false)
})
t.Run("move done task out of done bucket", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
tb := &TaskBucket{
TaskID: 2,
BucketID: 1, // Bucket 1 is the default bucket
ProjectViewID: 4,
ProjectID: 1, // In actual web requests set via the url
}
err := tb.Update(s, u)
require.NoError(t, err)
err = s.Commit()
require.NoError(t, err)
assert.False(t, tb.TaskDone)
db.AssertExists(t, "tasks", map[string]interface{}{
"id": tb.TaskID,
"done": false,
}, false)
db.AssertExists(t, "task_buckets", map[string]interface{}{
"task_id": tb.TaskID,
"bucket_id": 1,
}, false)
db.AssertMissing(t, "task_buckets", map[string]interface{}{
"task_id": tb.TaskID,
"bucket_id": 3,
})
})
t.Run("moving a repeating task to the done bucket", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
tb := &TaskBucket{
TaskID: 28,
BucketID: 3, // Bucket 3 is the done bucket
ProjectViewID: 4,
ProjectID: 1, // In actual web requests set via the url
}
err := tb.Update(s, u)
require.NoError(t, err)
err = s.Commit()
require.NoError(t, err)
assert.False(t, tb.TaskDone)
assert.Equal(t, int64(3), tb.BucketID) // This should be the actual bucket
db.AssertExists(t, "tasks", map[string]interface{}{
"id": 1,
"done": false,
}, false)
db.AssertExists(t, "task_buckets", map[string]interface{}{
"task_id": 1,
"bucket_id": 1,
}, false)
})
}

View File

@ -655,107 +655,6 @@ func checkBucketLimit(s *xorm.Session, t *Task, bucket *Bucket) (err error) {
return nil
}
// Contains all the task logic to figure out what bucket to use for this task.
func setTaskBucket(s *xorm.Session, task *Task, originalTask *Task, view *ProjectView, targetBucketID int64) (err error) {
if view.BucketConfigurationMode != BucketConfigurationModeManual {
return
}
var shouldChangeBucket = true
targetBucket := &TaskBucket{
BucketID: targetBucketID,
TaskID: task.ID,
ProjectViewID: view.ID,
}
oldTaskBucket := &TaskBucket{}
_, err = s.
Where("task_id = ? AND project_view_id = ?", task.ID, view.ID).
Get(oldTaskBucket)
if err != nil {
return
}
// If the task was marked as done and the view has a done bucket, move the task to the done bucket
if task.Done && originalTask != nil &&
(!originalTask.Done || task.ProjectID != originalTask.ProjectID) {
targetBucket.BucketID = view.DoneBucketID
// …and also reset the position so that it shows up at the top
// Note: this might result in an "off-looking" position when there is already a task with position 0.
// This is done by design, because recalculating all positions is really costly and will happen
// later anyway.
_, err = s.
Where("task_id = ? AND project_view_id = ?", task.ID, view.ID).
Cols("position").
Update(&TaskPosition{Position: 0})
if err != nil {
return
}
}
if targetBucket.BucketID == 0 && oldTaskBucket.BucketID != 0 {
shouldChangeBucket = false
}
// Either no bucket was provided or the task was moved between projects
// But if the task was moved between projects, don't update the done bucket
// because then we have it already updated to the done bucket.
if targetBucket.BucketID == 0 ||
(originalTask != nil && task.ProjectID != 0 && originalTask.ProjectID != task.ProjectID && !task.Done) {
targetBucket.BucketID, err = getDefaultBucketID(s, view)
if err != nil {
return
}
}
bucket, err := getBucketByID(s, targetBucket.BucketID)
if err != nil {
return err
}
// If there is a bucket set, make sure they belong to the same project as the task
if view.ID != bucket.ProjectViewID {
return ErrBucketDoesNotBelongToProjectView{
ProjectViewID: view.ID,
BucketID: bucket.ID,
}
}
// Check the bucket limit
// Only check the bucket limit if the task is being moved between buckets, allow reordering the task within a bucket
if targetBucket.BucketID != 0 && targetBucket.BucketID != oldTaskBucket.BucketID {
err = checkBucketLimit(s, task, bucket)
if err != nil {
return err
}
}
if bucket.ID == view.DoneBucketID && originalTask != nil && !originalTask.Done {
task.Done = true
}
// If the task was moved into the done bucket and the task has a repeating cycle we should not update
// the bucket.
if bucket.ID == view.DoneBucketID && task.RepeatAfter > 0 {
task.Done = true // This will trigger the correct re-scheduling of the task (happening in updateDone later)
shouldChangeBucket = false
}
if shouldChangeBucket {
_, err = s.
Where("task_id = ? AND project_view_id = ?", task.ID, view.ID).
Delete(&TaskBucket{})
if err != nil {
return
}
targetBucket.BucketID = bucket.ID
_, err = s.Insert(targetBucket)
}
return
}
func calculateDefaultPosition(entityID int64, position float64) float64 {
if position == 0 {
return float64(entityID) * math.Pow(2, 16)
@ -795,7 +694,7 @@ func (t *Task) Create(s *xorm.Session, a web.Auth) (err error) {
return createTask(s, t, a, true, true)
}
func createTask(s *xorm.Session, t *Task, a web.Auth, updateAssignees bool, updateBucket bool) (err error) {
func createTask(s *xorm.Session, t *Task, a web.Auth, updateAssignees bool, setBucket bool) (err error) {
t.ID = 0
@ -840,15 +739,26 @@ func createTask(s *xorm.Session, t *Task, a web.Auth, updateAssignees bool, upda
}
positions := []*TaskPosition{}
taskBuckets := []*TaskBucket{}
for _, view := range views {
if setBucket &&
view.ViewKind == ProjectViewKindKanban &&
view.BucketConfigurationMode == BucketConfigurationModeManual {
if updateBucket {
// Get the default bucket and move the task there
err = setTaskBucket(s, t, nil, view, t.BucketID)
if err != nil {
return
bucketID := view.DoneBucketID
if !t.Done || view.DoneBucketID == 0 {
bucketID, err = getDefaultBucketID(s, view)
if err != nil {
return err
}
}
taskBuckets = append(taskBuckets, &TaskBucket{
BucketID: bucketID,
TaskID: t.ID,
ProjectViewID: view.ID,
})
}
positions = append(positions, &TaskPosition{
@ -858,13 +768,20 @@ func createTask(s *xorm.Session, t *Task, a web.Auth, updateAssignees bool, upda
})
}
if updateBucket {
if len(positions) > 0 {
_, err = s.Insert(&positions)
if err != nil {
return
}
}
if len(taskBuckets) > 0 {
_, err = s.Insert(&taskBuckets)
if err != nil {
return
}
}
t.CreatedBy = createdBy
// Update the assignees
@ -941,11 +858,6 @@ func (t *Task) Update(s *xorm.Session, a web.Auth) (err error) {
return err
}
// Update the reminders
if err := ot.updateReminders(s, t); err != nil {
return err
}
// All columns to update in a separate variable to be able to add to them
colsToUpdate := []string{
"title",
@ -975,44 +887,62 @@ func (t *Task) Update(s *xorm.Session, a web.Auth) (err error) {
colsToUpdate = append(colsToUpdate, "index")
}
views, err := getViewsForProject(s, t.ProjectID)
if err != nil {
return err
}
buckets := make(map[int64]*Bucket)
err = s.In("project_view_id",
builder.Select("id").
From("project_views").
Where(builder.Eq{"project_id": t.ProjectID}),
).
Find(&buckets)
if err != nil {
return err
}
for _, view := range views {
// Only update the bucket when the current view
var targetBucketID int64
if t.BucketID != 0 {
bucket, has := buckets[t.BucketID]
if !has {
return ErrBucketDoesNotExist{BucketID: t.BucketID}
}
if has && bucket.ProjectViewID == view.ID {
targetBucketID = t.BucketID
}
}
err = setTaskBucket(s, t, &ot, view, targetBucketID)
// When a task was marked done or moved between projects, make sure it is in the correct bucket
if t.Done != ot.Done || t.ProjectID != ot.ProjectID {
views, err := getViewsForProject(s, t.ProjectID)
if err != nil {
return err
}
for _, view := range views {
if view.ViewKind != ProjectViewKindKanban && view.BucketConfigurationMode != BucketConfigurationModeManual {
continue
}
var bucketID = view.DoneBucketID
if bucketID == 0 || t.ProjectID != ot.ProjectID {
bucketID, err = getDefaultBucketID(s, view)
if err != nil {
return err
}
}
// Task is done and was moved between projects, should go into the done bucket of the new project
if t.Done && t.ProjectID != ot.ProjectID {
bucketID = view.DoneBucketID
}
tb := &TaskBucket{
BucketID: bucketID,
TaskID: t.ID,
ProjectViewID: view.ID,
ProjectID: t.ProjectID,
}
err = tb.Update(s, a)
if err != nil {
return err
}
tp := TaskPosition{
TaskID: t.ID,
ProjectViewID: view.ID,
Position: calculateDefaultPosition(t.Index, t.Position),
}
err = tp.Update(s, a)
if err != nil {
return err
}
}
}
// When a repeating task is marked as done, we update all deadlines and reminders and set it as undone
updateDone(&ot, t)
// Update the reminders
if err := ot.updateReminders(s, t); err != nil {
return err
}
// If a task attachment is being set as cover image, check if the attachment actually belongs to the task
if t.CoverImageAttachmentID != 0 {
is, err := s.Exist(&TaskAttachment{

View File

@ -157,21 +157,6 @@ func TestTask_Create(t *testing.T) {
require.Error(t, err)
assert.True(t, user.IsErrUserDoesNotExist(err))
})
t.Run("full bucket", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
task := &Task{
Title: "Lorem",
Description: "Lorem Ipsum Dolor",
ProjectID: 1,
BucketID: 2, // Bucket 2 already has 3 tasks and a limit of 3
}
err := task.Create(s, usr)
require.Error(t, err)
assert.True(t, IsErrBucketLimitExceeded(err))
})
t.Run("default bucket different", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
@ -232,137 +217,6 @@ func TestTask_Update(t *testing.T) {
require.Error(t, err)
assert.True(t, IsErrTaskDoesNotExist(err))
})
t.Run("full bucket", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
task := &Task{
ID: 1,
Title: "test10000",
Description: "Lorem Ipsum Dolor",
ProjectID: 1,
BucketID: 2, // Bucket 2 already has 3 tasks and a limit of 3
}
err := task.Update(s, u)
require.Error(t, err)
assert.True(t, IsErrBucketLimitExceeded(err))
})
t.Run("full bucket but not changing the bucket", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
task := &Task{
ID: 4,
Title: "test10000",
Description: "Lorem Ipsum Dolor",
ProjectID: 1,
BucketID: 2, // Bucket 2 already has 3 tasks and a limit of 3
}
err := task.Update(s, u)
require.NoError(t, err)
})
t.Run("bucket on other project", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
task := &Task{
ID: 1,
Title: "test10000",
Description: "Lorem Ipsum Dolor",
ProjectID: 1,
BucketID: 4, // Bucket 4 belongs to project 2
}
err := task.Update(s, u)
require.Error(t, err)
assert.True(t, IsErrBucketDoesNotExist(err))
})
t.Run("moving a task to the done bucket", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
task := &Task{
ID: 1,
Title: "test",
ProjectID: 1,
BucketID: 3, // Bucket 3 is the done bucket
}
err := task.Update(s, u)
require.NoError(t, err)
err = s.Commit()
require.NoError(t, err)
assert.True(t, task.Done)
db.AssertExists(t, "tasks", map[string]interface{}{
"id": 1,
"done": true,
}, false)
db.AssertExists(t, "task_buckets", map[string]interface{}{
"task_id": 1,
"bucket_id": 3,
}, false)
})
t.Run("move done task out of done bucket", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
task := &Task{
ID: 2,
Title: "test",
ProjectID: 1,
BucketID: 1, // Bucket 1 is the default bucket
}
err := task.Update(s, u)
require.NoError(t, err)
err = s.Commit()
require.NoError(t, err)
assert.False(t, task.Done)
db.AssertExists(t, "tasks", map[string]interface{}{
"id": task.ID,
"done": false,
}, false)
db.AssertExists(t, "task_buckets", map[string]interface{}{
"task_id": task.ID,
"bucket_id": 1,
}, false)
db.AssertMissing(t, "task_buckets", map[string]interface{}{
"task_id": task.ID,
"bucket_id": 3,
})
})
t.Run("moving a repeating task to the done bucket", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
task := &Task{
ID: 28,
Title: "test updated",
ProjectID: 1,
BucketID: 3, // Bucket 3 is the done bucket
RepeatAfter: 3600,
}
err := task.Update(s, u)
require.NoError(t, err)
err = s.Commit()
require.NoError(t, err)
assert.False(t, task.Done)
assert.Equal(t, int64(3), task.BucketID)
db.AssertExists(t, "tasks", map[string]interface{}{
"id": 1,
"done": false,
}, false)
db.AssertExists(t, "task_buckets", map[string]interface{}{
"task_id": 1,
"bucket_id": 1,
}, false)
})
t.Run("default bucket when moving a task between projects", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
@ -407,7 +261,7 @@ func TestTask_Update(t *testing.T) {
"bucket_id": 3,
}, false)
})
t.Run("move task to another project", func(t *testing.T) {
t.Run("move task to another project should use the default bucket", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()

View File

@ -189,6 +189,7 @@ func createProjectWithEverything(s *xorm.Session, project *models.ProjectWithTas
}
// Create all views, create default views if we don't have any
var kanbanView *models.ProjectView
viewsByOldIDs := make(map[int64]*models.ProjectView, len(oldViews))
if len(oldViews) > 0 {
for _, view := range oldViews {
@ -216,12 +217,16 @@ func createProjectWithEverything(s *xorm.Session, project *models.ProjectWithTas
return
}
viewsByOldIDs[oldID] = view
if view.ViewKind == models.ProjectViewKindKanban {
kanbanView = view
}
}
} else {
// Only using the default views
// Add all buckets to the default kanban view
for _, view := range project.Views {
if view.ViewKind == models.ProjectViewKindKanban {
kanbanView = view
for _, b := range bucketsByOldID {
b.ProjectViewID = view.ID
err = b.Update(s, user)
@ -236,25 +241,36 @@ func createProjectWithEverything(s *xorm.Session, project *models.ProjectWithTas
log.Debugf("[creating structure] Creating %d tasks", len(tasks))
setBucketOrDefault := func(task *models.Task) {
bucket, exists := bucketsByOldID[task.BucketID]
setBucketOrDefault := func(task *models.Task) (err error) {
var bucketID = task.BucketID
bucket, exists := bucketsByOldID[bucketID]
if exists {
task.BucketID = bucket.ID
} else if task.BucketID > 0 {
bucketID = bucket.ID
tb := &models.TaskBucket{
TaskID: task.ID,
BucketID: bucketID,
ProjectID: task.ProjectID,
ProjectViewID: kanbanView.ID,
}
err = tb.Update(s, user)
if err != nil {
return
}
} else if bucketID > 0 {
log.Debugf("[creating structure] No bucket created for original bucket id %d", task.BucketID)
task.BucketID = 0
bucketID = 0
}
if !exists || task.BucketID == 0 {
if !exists || bucketID == 0 {
needsDefaultBucket = true
}
return
}
tasksByOldID := make(map[int64]*models.TaskWithComments, len(tasks))
newTaskIDs := []int64{}
// Create all tasks
for i, t := range tasks {
setBucketOrDefault(&tasks[i].Task)
oldid := t.ID
t.ProjectID = project.ID
err = t.Create(s, user)
@ -262,6 +278,11 @@ func createProjectWithEverything(s *xorm.Session, project *models.ProjectWithTas
continue
}
err = setBucketOrDefault(&tasks[i].Task)
if err != nil {
return
}
newTaskIDs = append(newTaskIDs, t.ID)
if err != nil {
@ -285,7 +306,10 @@ func createProjectWithEverything(s *xorm.Session, project *models.ProjectWithTas
// First create the related tasks if they do not exist
if _, exists := tasksByOldID[rt.ID]; !exists || rt.ID == 0 {
oldid := rt.ID
setBucketOrDefault(rt)
err = setBucketOrDefault(rt)
if err != nil {
return
}
rt.ProjectID = t.ProjectID
err = rt.Create(s, user)
if err != nil {

View File

@ -622,12 +622,19 @@ func registerAPIRoutes(a *echo.Group) {
return &models.ProjectView{}
},
}
a.GET("/projects/:project/views", projectViewProvider.ReadAllWeb)
a.GET("/projects/:project/views/:view", projectViewProvider.ReadOneWeb)
a.PUT("/projects/:project/views", projectViewProvider.CreateWeb)
a.DELETE("/projects/:project/views/:view", projectViewProvider.DeleteWeb)
a.POST("/projects/:project/views/:view", projectViewProvider.UpdateWeb)
// Kanban Task Bucket Relation
taskBucketProvider := &handler.WebHandler{
EmptyStruct: func() handler.CObject {
return &models.TaskBucket{}
},
}
a.POST("/projects/:project/views/:view/buckets/:bucket/tasks", taskBucketProvider.UpdateWeb)
}
func registerMigrations(m *echo.Group) {

View File

@ -1,4 +1,5 @@
// Package swagger Code generated by swaggo/swag. DO NOT EDIT
// Code generated by swaggo/swag. DO NOT EDIT.
package swagger
import "github.com/swaggo/swag"
@ -3736,6 +3737,78 @@ const docTemplate = `{
}
}
},
"/projects/{project}/views/{view}/buckets/{bucket}/tasks": {
"post": {
"security": [
{
"JWTKeyAuth": []
}
],
"description": "Updates a task in a bucket",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"task"
],
"summary": "Update a task bucket",
"parameters": [
{
"type": "integer",
"description": "Project ID",
"name": "project",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "Project View ID",
"name": "view",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "Bucket ID",
"name": "bucket",
"in": "path",
"required": true
},
{
"description": "The id of the task you want to move into the bucket.",
"name": "taskBucket",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.TaskBucket"
}
}
],
"responses": {
"200": {
"description": "The updated task bucket.",
"schema": {
"$ref": "#/definitions/models.TaskBucket"
}
},
"400": {
"description": "Invalid task bucket object provided.",
"schema": {
"$ref": "#/definitions/web.HTTPError"
}
},
"500": {
"description": "Internal error",
"schema": {
"$ref": "#/definitions/models.Message"
}
}
}
}
},
"/register": {
"post": {
"description": "Creates a new user account.",
@ -8751,6 +8824,23 @@ const docTemplate = `{
}
}
},
"models.TaskBucket": {
"type": "object",
"properties": {
"bucket_id": {
"type": "integer"
},
"project_view_id": {
"type": "integer"
},
"task_done": {
"type": "boolean"
},
"task_id": {
"type": "integer"
}
}
},
"models.TaskCollection": {
"type": "object",
"properties": {
@ -9641,8 +9731,6 @@ var SwaggerInfo = &swag.Spec{
Description: "# Pagination\nEvery endpoint capable of pagination will return two headers:\n* `x-pagination-total-pages`: The total number of available pages for this request\n* `x-pagination-result-count`: The number of items returned for this request.\n# Rights\nAll endpoints which return a single item (project, task, etc.) - no array - will also return a `x-max-right` header with the max right the user has on this item as an int where `0` is `Read Only`, `1` is `Read & Write` and `2` is `Admin`.\nThis can be used to show or hide ui elements based on the rights the user has.\n# Errors\nAll errors have an error code and a human-readable error message in addition to the http status code. You should always check for the status code in the response, not only the http status code.\nDue to limitations in the swagger library we're using for this document, only one error per http status code is documented here. Make sure to check the [error docs](https://vikunja.io/docs/errors/) in Vikunja's documentation for a full list of available error codes.\n# Authorization\n**JWT-Auth:** Main authorization method, used for most of the requests. Needs `Authorization: Bearer <jwt-token>`-header to authenticate successfully.\n\n**API Token:** You can create scoped API tokens for your user and use the token to make authenticated requests in the context of that user. The token must be provided via an `Authorization: Bearer <token>` header, similar to jwt auth. See the documentation for the `api` group to manage token creation and revocation.\n\n**BasicAuth:** Only used when requesting tasks via CalDAV.\n<!-- ReDoc-Inject: <security-definitions> -->",
InfoInstanceName: "swagger",
SwaggerTemplate: docTemplate,
LeftDelim: "{{",
RightDelim: "}}",
}
func init() {

View File

@ -3728,6 +3728,78 @@
}
}
},
"/projects/{project}/views/{view}/buckets/{bucket}/tasks": {
"post": {
"security": [
{
"JWTKeyAuth": []
}
],
"description": "Updates a task in a bucket",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"task"
],
"summary": "Update a task bucket",
"parameters": [
{
"type": "integer",
"description": "Project ID",
"name": "project",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "Project View ID",
"name": "view",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "Bucket ID",
"name": "bucket",
"in": "path",
"required": true
},
{
"description": "The id of the task you want to move into the bucket.",
"name": "taskBucket",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.TaskBucket"
}
}
],
"responses": {
"200": {
"description": "The updated task bucket.",
"schema": {
"$ref": "#/definitions/models.TaskBucket"
}
},
"400": {
"description": "Invalid task bucket object provided.",
"schema": {
"$ref": "#/definitions/web.HTTPError"
}
},
"500": {
"description": "Internal error",
"schema": {
"$ref": "#/definitions/models.Message"
}
}
}
}
},
"/register": {
"post": {
"description": "Creates a new user account.",
@ -8743,6 +8815,23 @@
}
}
},
"models.TaskBucket": {
"type": "object",
"properties": {
"bucket_id": {
"type": "integer"
},
"project_view_id": {
"type": "integer"
},
"task_done": {
"type": "boolean"
},
"task_id": {
"type": "integer"
}
}
},
"models.TaskCollection": {
"type": "object",
"properties": {

View File

@ -870,6 +870,17 @@ definitions:
task_id:
type: integer
type: object
models.TaskBucket:
properties:
bucket_id:
type: integer
project_view_id:
type: integer
task_done:
type: boolean
task_id:
type: integer
type: object
models.TaskCollection:
properties:
filter:
@ -3855,6 +3866,53 @@ paths:
summary: Updates a project view
tags:
- project
/projects/{project}/views/{view}/buckets/{bucket}/tasks:
post:
consumes:
- application/json
description: Updates a task in a bucket
parameters:
- description: Project ID
in: path
name: project
required: true
type: integer
- description: Project View ID
in: path
name: view
required: true
type: integer
- description: Bucket ID
in: path
name: bucket
required: true
type: integer
- description: The id of the task you want to move into the bucket.
in: body
name: taskBucket
required: true
schema:
$ref: '#/definitions/models.TaskBucket'
produces:
- application/json
responses:
"200":
description: The updated task bucket.
schema:
$ref: '#/definitions/models.TaskBucket'
"400":
description: Invalid task bucket object provided.
schema:
$ref: '#/definitions/web.HTTPError'
"500":
description: Internal error
schema:
$ref: '#/definitions/models.Message'
security:
- JWTKeyAuth: []
summary: Update a task bucket
tags:
- task
/projects/{projectID}/duplicate:
put:
consumes: