diff --git a/pkg/db/fixtures/buckets.yml b/pkg/db/fixtures/buckets.yml index 93dc7c36445..f5ca83bdd7b 100644 --- a/pkg/db/fixtures/buckets.yml +++ b/pkg/db/fixtures/buckets.yml @@ -18,7 +18,6 @@ title: testbucket3 project_id: 1 created_by_id: 1 - is_done_bucket: 1 position: 3 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 @@ -26,7 +25,6 @@ title: testbucket4 - other project project_id: 2 created_by_id: 1 - is_done_bucket: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 # The following are not or only partly owned by user 1 @@ -40,6 +38,7 @@ title: testbucket6 project_id: 6 created_by_id: 1 + position: 1 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 7 @@ -137,6 +136,7 @@ title: testbucket22 project_id: 6 created_by_id: 1 + position: 2 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 - id: 23 diff --git a/pkg/db/fixtures/projects.yml b/pkg/db/fixtures/projects.yml index 6603554e160..8dee3cae1f5 100644 --- a/pkg/db/fixtures/projects.yml +++ b/pkg/db/fixtures/projects.yml @@ -5,6 +5,7 @@ identifier: test1 owner_id: 1 position: 3 + done_bucket_id: 3 updated: 2018-12-02 15:13:12 created: 2018-12-01 15:13:12 - @@ -14,6 +15,7 @@ identifier: test2 owner_id: 3 position: 2 + done_bucket_id: 4 updated: 2018-12-02 15:13:12 created: 2018-12-01 15:13:12 - @@ -50,6 +52,7 @@ identifier: test6 owner_id: 6 position: 6 + default_bucket_id: 22 updated: 2018-12-02 15:13:12 created: 2018-12-01 15:13:12 - diff --git a/pkg/migration/20230903143017.go b/pkg/migration/20230903143017.go new file mode 100644 index 00000000000..5040c53de66 --- /dev/null +++ b/pkg/migration/20230903143017.go @@ -0,0 +1,118 @@ +// 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 . + +package migration + +import ( + "src.techknowlogick.com/xormigrate" + "xorm.io/xorm" + "xorm.io/xorm/schemas" +) + +type projects20230903143017 struct { + ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"project"` + DefaultBucketID int64 `xorm:"bigint INDEX null" json:"default_bucket_id"` + DoneBucketID int64 `xorm:"bigint INDEX null" json:"done_bucket_id"` +} + +func (projects20230903143017) TableName() string { + return "projects" +} + +type bucket20230903143017 struct { + ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"bucket"` + IsDoneBucket bool `xorm:"BOOL" json:"is_done_bucket"` + ProjectID int64 `xorm:"bigint not null" json:"project_id" param:"project"` +} + +func (bucket20230903143017) TableName() string { + return "buckets" +} + +const dropIsDoneBucketColSqlite20230903143017 = ` +create table buckets_dg_tmp +( + id INTEGER not null + primary key autoincrement, + title TEXT not null, + project_id INTEGER not null, + "limit" INTEGER default 0, + position REAL, + created DATETIME not null, + updated DATETIME not null, + created_by_id INTEGER not null +); + +insert into buckets_dg_tmp(id, title, project_id, "limit", position, created, updated, created_by_id) +select id, + title, + project_id, + "limit", + position, + created, + updated, + created_by_id +from buckets; + +drop table buckets; + +alter table buckets_dg_tmp + rename to buckets; + +create unique index UQE_buckets_id + on buckets (id); +` + +func init() { + migrations = append(migrations, &xormigrate.Migration{ + ID: "20230903143017", + Description: "Move done bucket state to project + add default bucket setting", + Migrate: func(tx *xorm.Engine) (err error) { + err = tx.Sync2(projects20230903143017{}) + if err != nil { + return + } + + doneBuckets := []*bucket20230903143017{} + err = tx.Where("is_done_bucket = true"). + Find(&doneBuckets) + if err != nil { + return + } + + for _, bucket := range doneBuckets { + _, err = tx.Where("id = ?", bucket.ProjectID). + Cols("done_bucket_id"). + Update(&projects20230903143017{ + DoneBucketID: bucket.ID, + }) + if err != nil { + return + } + } + + if tx.Dialect().URI().DBType == schemas.SQLITE { + _, err = tx.Exec(dropIsDoneBucketColSqlite20230903143017) + return err + } + + return dropTableColum(tx, "buckets", "is_done_bucket") + }, + Rollback: func(tx *xorm.Engine) error { + return nil + }, + }) +} diff --git a/pkg/models/kanban.go b/pkg/models/kanban.go index beac99cb45b..82ced4d6dab 100644 --- a/pkg/models/kanban.go +++ b/pkg/models/kanban.go @@ -38,8 +38,6 @@ type Bucket struct { // How many tasks can be at the same time on this board max Limit int64 `xorm:"default 0" json:"limit" minimum:"0" valid:"range(0|9223372036854775807)"` - // If this bucket is the "done bucket". All tasks moved into this bucket will automatically marked as done. All tasks marked as done from elsewhere will be moved into this bucket. - IsDoneBucket bool `xorm:"BOOL" json:"is_done_bucket"` // The number of tasks currently in this bucket Count int64 `xorm:"-" json:"count"` @@ -80,28 +78,21 @@ func getBucketByID(s *xorm.Session, id int64) (b *Bucket, err error) { return } -func getDefaultBucket(s *xorm.Session, projectID int64) (bucket *Bucket, err error) { - bucket = &Bucket{} +func getDefaultBucketID(s *xorm.Session, project *Project) (bucketID int64, err error) { + if project.DefaultBucketID != 0 { + return project.DefaultBucketID, nil + } + + bucket := &Bucket{} _, err = s. - Where("project_id = ?", projectID). + Where("project_id = ?", project.ID). OrderBy("position asc"). Get(bucket) - return -} - -func getDoneBucketForProject(s *xorm.Session, projectID int64) (bucket *Bucket, err error) { - bucket = &Bucket{} - exists, err := s. - Where("project_id = ? and is_done_bucket = ?", projectID, true). - Get(bucket) if err != nil { - return nil, err - } - if !exists { - bucket = nil + return 0, err } - return + return bucket.ID, nil } // ReadAll returns all buckets with their tasks for a certain project @@ -287,29 +278,11 @@ func (b *Bucket) Create(s *xorm.Session, a web.Auth) (err error) { // @Failure 500 {object} models.Message "Internal error" // @Router /projects/{projectID}/buckets/{bucketID} [post] func (b *Bucket) Update(s *xorm.Session, _ web.Auth) (err error) { - doneBucket, err := getDoneBucketForProject(s, b.ProjectID) - if err != nil { - return err - } - - if doneBucket != nil && doneBucket.IsDoneBucket && b.IsDoneBucket && doneBucket.ID != b.ID { - // When the current bucket will be the new done bucket, the old one should not be the done bucket anymore - doneBucket.IsDoneBucket = false - _, err = s. - Where("id = ?", doneBucket.ID). - Cols("is_done_bucket"). - Update(doneBucket) - if err != nil { - return - } - } - _, err = s. Where("id = ?", b.ID). Cols( "title", "limit", - "is_done_bucket", "position", ). Update(b) @@ -350,15 +323,19 @@ func (b *Bucket) Delete(s *xorm.Session, _ web.Auth) (err error) { } // Get the default bucket - defaultBucket, err := getDefaultBucket(s, b.ProjectID) + p, err := GetProjectSimpleByID(s, b.ProjectID) if err != nil { return } + defaultBucketID, err := getDefaultBucketID(s, p) + if err != nil { + return err + } // Remove all associations of tasks to that bucket _, err = s. Where("bucket_id = ?", b.ID). Cols("bucket_id"). - Update(&Task{BucketID: defaultBucket.ID}) + Update(&Task{BucketID: defaultBucketID}) return } diff --git a/pkg/models/kanban_test.go b/pkg/models/kanban_test.go index 31d6400cfe8..93eebce0588 100644 --- a/pkg/models/kanban_test.go +++ b/pkg/models/kanban_test.go @@ -217,28 +217,4 @@ func TestBucket_Update(t *testing.T) { testAndAssertBucketUpdate(t, b, s) }) - t.Run("old done bucket should be unset", func(t *testing.T) { - db.LoadAndAssertFixtures(t) - s := db.NewSession() - defer s.Close() - - b := &Bucket{ - ID: 1, - ProjectID: 1, - IsDoneBucket: true, - } - - err := b.Update(s, &user.User{ID: 1}) - assert.NoError(t, err) - db.AssertExists(t, "buckets", map[string]interface{}{ - "id": 1, - "project_id": 1, - "is_done_bucket": true, - }, false) - db.AssertExists(t, "buckets", map[string]interface{}{ - "id": 3, - "project_id": 1, - "is_done_bucket": false, - }, false) - }) } diff --git a/pkg/models/project.go b/pkg/models/project.go index d730de4a324..b13f7f72d73 100644 --- a/pkg/models/project.go +++ b/pkg/models/project.go @@ -51,6 +51,11 @@ type Project struct { ParentProjectID int64 `xorm:"bigint INDEX null" json:"parent_project_id"` ParentProject *Project `xorm:"-" json:"-"` + // The ID of the bucket where new tasks without a bucket are added to. By default, this is the leftmost bucket in a project. + DefaultBucketID int64 `xorm:"bigint INDEX null" json:"default_bucket_id"` + // If tasks are moved to the done bucket, they are marked as done. If they are marked as done individually, they are moved into the done bucket. + DoneBucketID int64 `xorm:"bigint INDEX null" json:"done_bucket_id"` + // The user who created this project. Owner *user.User `xorm:"-" json:"owner" valid:"-"` @@ -778,6 +783,8 @@ func UpdateProject(s *xorm.Session, project *Project, auth web.Auth, updateProje "hex_color", "parent_project_id", "position", + "done_bucket_id", + "default_bucket_id", } if project.Description != "" { colsToUpdate = append(colsToUpdate, "description") diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index 785e4f26439..def726121ec 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -629,17 +629,18 @@ func checkBucketLimit(s *xorm.Session, t *Task, bucket *Bucket) (err error) { } // Contains all the task logic to figure out what bucket to use for this task. -func setTaskBucket(s *xorm.Session, task *Task, originalTask *Task, doCheckBucketLimit bool) (targetBucket *Bucket, err error) { - // Make sure we have a bucket - var bucket *Bucket - if task.Done && originalTask != nil && !originalTask.Done { - bucket, err := getDoneBucketForProject(s, task.ProjectID) +func setTaskBucket(s *xorm.Session, task *Task, originalTask *Task, doCheckBucketLimit bool, project *Project) (targetBucket *Bucket, err error) { + + if project == nil { + project, err = GetProjectSimpleByID(s, task.ProjectID) if err != nil { return nil, err } - if bucket != nil { - task.BucketID = bucket.ID - } + } + + var bucket *Bucket + if task.Done && originalTask != nil && !originalTask.Done { + task.BucketID = project.DoneBucketID } if task.BucketID == 0 && originalTask != nil && originalTask.BucketID != 0 { @@ -648,11 +649,10 @@ func setTaskBucket(s *xorm.Session, task *Task, originalTask *Task, doCheckBucke // Either no bucket was provided or the task was moved between projects if task.BucketID == 0 || (originalTask != nil && task.ProjectID != 0 && originalTask.ProjectID != task.ProjectID) { - bucket, err = getDefaultBucket(s, task.ProjectID) + task.BucketID, err = getDefaultBucketID(s, project) if err != nil { return } - task.BucketID = bucket.ID } if bucket == nil { @@ -676,7 +676,7 @@ func setTaskBucket(s *xorm.Session, task *Task, originalTask *Task, doCheckBucke } } - if bucket.IsDoneBucket && originalTask != nil && !originalTask.Done { + if bucket.ID == project.DoneBucketID && originalTask != nil && !originalTask.Done { task.Done = true } @@ -732,7 +732,7 @@ func createTask(s *xorm.Session, t *Task, a web.Auth, updateAssignees bool) (err } // Check if the project exists - l, err := GetProjectSimpleByID(s, t.ProjectID) + p, err := GetProjectSimpleByID(s, t.ProjectID) if err != nil { return err } @@ -749,7 +749,7 @@ func createTask(s *xorm.Session, t *Task, a web.Auth, updateAssignees bool) (err } // Get the default bucket and move the task there - _, err = setTaskBucket(s, t, nil, true) + _, err = setTaskBucket(s, t, nil, true, nil) if err != nil { return } @@ -781,7 +781,7 @@ func createTask(s *xorm.Session, t *Task, a web.Auth, updateAssignees bool) (err return err } - t.setIdentifier(l) + t.setIdentifier(p) if t.IsFavorite { if err := addToFavorites(s, t.ID, createdBy, FavoriteKindTask); err != nil { @@ -838,14 +838,18 @@ func (t *Task) Update(s *xorm.Session, a web.Auth) (err error) { // Old task has the stored reminders ot.Reminders = reminders - targetBucket, err := setTaskBucket(s, t, &ot, t.BucketID != 0 && t.BucketID != ot.BucketID) + targetBucket, err := setTaskBucket(s, t, &ot, t.BucketID != 0 && t.BucketID != ot.BucketID, nil) if err != nil { return err } // If the task was moved into the done bucket and the task has a repeating cycle we should not update // the bucket. - if targetBucket.IsDoneBucket && t.RepeatAfter > 0 { + project, err := GetProjectSimpleByID(s, t.ProjectID) + if err != nil { + return err + } + if targetBucket.ID == project.DoneBucketID && t.RepeatAfter > 0 { t.Done = true // This will trigger the correct re-scheduling of the task (happening in updateDone later) t.BucketID = ot.BucketID } diff --git a/pkg/models/tasks_test.go b/pkg/models/tasks_test.go index 8201b027584..a50d506419d 100644 --- a/pkg/models/tasks_test.go +++ b/pkg/models/tasks_test.go @@ -170,6 +170,23 @@ func TestTask_Create(t *testing.T) { assert.Error(t, err) assert.True(t, IsErrBucketLimitExceeded(err)) }) + t.Run("default bucket different", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + task := &Task{ + Title: "Lorem", + Description: "Lorem Ipsum Dolor", + ProjectID: 6, + } + err := task.Create(s, usr) + assert.NoError(t, err) + db.AssertExists(t, "tasks", map[string]interface{}{ + "id": task.ID, + "bucket_id": 22, // default bucket of project 6 but with a position of 2 + }, false) + }) } func TestTask_Update(t *testing.T) {