Add bulk edit for tasks (#42)
This commit is contained in:
133
pkg/models/bulk_list_task.go
Normal file
133
pkg/models/bulk_list_task.go
Normal file
@ -0,0 +1,133 @@
|
||||
// Vikunja is a todo-list application to facilitate your life.
|
||||
// Copyright 2018 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/log"
|
||||
"code.vikunja.io/web"
|
||||
"github.com/imdario/mergo"
|
||||
)
|
||||
|
||||
// BulkTask is the definition of a bulk update task
|
||||
type BulkTask struct {
|
||||
IDs []int64 `json:"task_ids"`
|
||||
Tasks []*ListTask `json:"-"`
|
||||
ListTask
|
||||
}
|
||||
|
||||
func (bt *BulkTask) checkIfTasksAreOnTheSameList() (err error) {
|
||||
// Get the tasks
|
||||
err = bt.GetTasksByIDs()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(bt.Tasks) == 0 {
|
||||
return ErrBulkTasksNeedAtLeastOne{}
|
||||
}
|
||||
|
||||
// Check if all tasks are in the same list
|
||||
var firstListID = bt.Tasks[0].ListID
|
||||
for _, t := range bt.Tasks {
|
||||
if t.ListID != firstListID {
|
||||
return ErrBulkTasksMustBeInSameList{firstListID, t.ListID}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CanUpdate checks if a user is allowed to update a task
|
||||
func (bt *BulkTask) CanUpdate(a web.Auth) bool {
|
||||
|
||||
err := bt.checkIfTasksAreOnTheSameList()
|
||||
if err != nil {
|
||||
log.Log.Error("Error occurred during CanUpdate for BulkTask: %s", err)
|
||||
return false
|
||||
}
|
||||
|
||||
doer := getUserForRights(a)
|
||||
// A user can update an task if he has write acces to its list
|
||||
l := &List{ID: bt.Tasks[0].ListID}
|
||||
l.ReadOne()
|
||||
return l.CanWrite(doer)
|
||||
}
|
||||
|
||||
// Update updates a bunch of tasks at once
|
||||
// @Summary Update a bunch of tasks at once
|
||||
// @Description Updates a bunch of tasks at once. This includes marking them as done. Note: although you could supply another ID, it will be ignored. Use task_ids instead.
|
||||
// @tags task
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security ApiKeyAuth
|
||||
// @Param task body models.BulkTask true "The task object. Looks like a normal task, the only difference is it uses an array of list_ids to update."
|
||||
// @Success 200 {object} models.ListTask "The updated task object."
|
||||
// @Failure 400 {object} code.vikunja.io/web.HTTPError "Invalid task object provided."
|
||||
// @Failure 403 {object} code.vikunja.io/web.HTTPError "The user does not have access to the task (aka its list)"
|
||||
// @Failure 500 {object} models.Message "Internal error"
|
||||
// @Router /tasks/bulk [post]
|
||||
func (bt *BulkTask) Update() (err error) {
|
||||
|
||||
sess := x.NewSession()
|
||||
defer sess.Close()
|
||||
|
||||
err = sess.Begin()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, oldtask := range bt.Tasks {
|
||||
|
||||
// When a repeating task is marked as done, we update all deadlines and reminders and set it as undone
|
||||
updateDone(oldtask, &bt.ListTask)
|
||||
|
||||
// For whatever reason, xorm dont detect if done is updated, so we need to update this every time by hand
|
||||
// Which is why we merge the actual task struct with the one we got from the
|
||||
// The user struct overrides values in the actual one.
|
||||
if err := mergo.Merge(oldtask, &bt.ListTask, mergo.WithOverride); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// And because a false is considered to be a null value, we need to explicitly check that case here.
|
||||
if bt.ListTask.Done == false {
|
||||
oldtask.Done = false
|
||||
}
|
||||
|
||||
_, err = sess.ID(oldtask.ID).
|
||||
Cols("text",
|
||||
"description",
|
||||
"done",
|
||||
"due_date_unix",
|
||||
"reminders_unix",
|
||||
"repeat_after",
|
||||
"parent_task_id",
|
||||
"priority",
|
||||
"start_date_unix",
|
||||
"end_date_unix").
|
||||
Update(oldtask)
|
||||
if err != nil {
|
||||
return sess.Rollback()
|
||||
}
|
||||
}
|
||||
|
||||
err = sess.Commit()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
69
pkg/models/bulk_list_task_test.go
Normal file
69
pkg/models/bulk_list_task_test.go
Normal file
@ -0,0 +1,69 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBulkTask_Update(t *testing.T) {
|
||||
type fields struct {
|
||||
IDs []int64
|
||||
Tasks []*ListTask
|
||||
ListTask ListTask
|
||||
User *User
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
fields fields
|
||||
wantErr bool
|
||||
wantForbidden bool
|
||||
}{
|
||||
{
|
||||
name: "Test normal update",
|
||||
fields: fields{
|
||||
IDs: []int64{10, 11, 12},
|
||||
ListTask: ListTask{
|
||||
Text: "bulkupdated",
|
||||
},
|
||||
User: &User{ID: 1},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Test with one task on different list",
|
||||
fields: fields{
|
||||
IDs: []int64{10, 11, 12, 13},
|
||||
ListTask: ListTask{
|
||||
Text: "bulkupdated",
|
||||
},
|
||||
User: &User{ID: 1},
|
||||
},
|
||||
wantForbidden: true,
|
||||
},
|
||||
{
|
||||
name: "Test without any tasks",
|
||||
fields: fields{
|
||||
IDs: []int64{},
|
||||
ListTask: ListTask{
|
||||
Text: "bulkupdated",
|
||||
},
|
||||
User: &User{ID: 1},
|
||||
},
|
||||
wantForbidden: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
bt := &BulkTask{
|
||||
IDs: tt.fields.IDs,
|
||||
Tasks: tt.fields.Tasks,
|
||||
ListTask: tt.fields.ListTask,
|
||||
}
|
||||
allowed := bt.CanUpdate(tt.fields.User)
|
||||
if !allowed != tt.wantForbidden {
|
||||
t.Errorf("BulkTask.Update() want forbidden, got %v, want %v", allowed, tt.wantForbidden)
|
||||
}
|
||||
if err := bt.Update(); (err != nil) != tt.wantErr {
|
||||
t.Errorf("BulkTask.Update() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -429,6 +429,51 @@ func (err ErrListTaskDoesNotExist) HTTPError() web.HTTPError {
|
||||
return web.HTTPError{HTTPCode: http.StatusNotFound, Code: ErrCodeListTaskDoesNotExist, Message: "This list task does not exist"}
|
||||
}
|
||||
|
||||
// ErrBulkTasksMustBeInSameList represents a "ErrBulkTasksMustBeInSameList" kind of error.
|
||||
type ErrBulkTasksMustBeInSameList struct {
|
||||
ShouldBeID int64
|
||||
IsID int64
|
||||
}
|
||||
|
||||
// IsErrBulkTasksMustBeInSameList checks if an error is a ErrBulkTasksMustBeInSameList.
|
||||
func IsErrBulkTasksMustBeInSameList(err error) bool {
|
||||
_, ok := err.(ErrBulkTasksMustBeInSameList)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrBulkTasksMustBeInSameList) Error() string {
|
||||
return fmt.Sprintf("All bulk editing tasks must be in the same list. [Should be: %d, is: %d]", err.ShouldBeID, err.IsID)
|
||||
}
|
||||
|
||||
// ErrCodeBulkTasksMustBeInSameList holds the unique world-error code of this error
|
||||
const ErrCodeBulkTasksMustBeInSameList = 4003
|
||||
|
||||
// HTTPError holds the http error description
|
||||
func (err ErrBulkTasksMustBeInSameList) HTTPError() web.HTTPError {
|
||||
return web.HTTPError{HTTPCode: http.StatusBadRequest, Code: ErrCodeBulkTasksMustBeInSameList, Message: "All tasks must be in the same list."}
|
||||
}
|
||||
|
||||
// ErrBulkTasksNeedAtLeastOne represents a "ErrBulkTasksNeedAtLeastOne" kind of error.
|
||||
type ErrBulkTasksNeedAtLeastOne struct{}
|
||||
|
||||
// IsErrBulkTasksNeedAtLeastOne checks if an error is a ErrBulkTasksNeedAtLeastOne.
|
||||
func IsErrBulkTasksNeedAtLeastOne(err error) bool {
|
||||
_, ok := err.(ErrBulkTasksNeedAtLeastOne)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrBulkTasksNeedAtLeastOne) Error() string {
|
||||
return fmt.Sprintf("Need at least one task when bulk editing tasks")
|
||||
}
|
||||
|
||||
// ErrCodeBulkTasksNeedAtLeastOne holds the unique world-error code of this error
|
||||
const ErrCodeBulkTasksNeedAtLeastOne = 4004
|
||||
|
||||
// HTTPError holds the http error description
|
||||
func (err ErrBulkTasksNeedAtLeastOne) HTTPError() web.HTTPError {
|
||||
return web.HTTPError{HTTPCode: http.StatusBadRequest, Code: ErrCodeBulkTasksNeedAtLeastOne, Message: "Need at least one tasks to do bulk editing."}
|
||||
}
|
||||
|
||||
// =================
|
||||
// Namespace errors
|
||||
// =================
|
||||
|
@ -60,4 +60,28 @@
|
||||
created: 1543626724
|
||||
updated: 1543626724
|
||||
start_date_unix: 1544600000
|
||||
end_date_unix: 1544700000
|
||||
end_date_unix: 1544700000
|
||||
- id: 10
|
||||
text: 'task #10 basic'
|
||||
created_by_id: 1
|
||||
list_id: 1
|
||||
created: 1543626724
|
||||
updated: 1543626724
|
||||
- id: 11
|
||||
text: 'task #11 basic'
|
||||
created_by_id: 1
|
||||
list_id: 1
|
||||
created: 1543626724
|
||||
updated: 1543626724
|
||||
- id: 12
|
||||
text: 'task #12 basic'
|
||||
created_by_id: 1
|
||||
list_id: 1
|
||||
created: 1543626724
|
||||
updated: 1543626724
|
||||
- id: 13
|
||||
text: 'task #13 basic other list'
|
||||
created_by_id: 1
|
||||
list_id: 2
|
||||
created: 1543626724
|
||||
updated: 1543626724
|
@ -7,6 +7,8 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"reflect"
|
||||
"sort"
|
||||
"testing"
|
||||
@ -97,6 +99,30 @@ func sortTasksForTesting(by SortBy) (tasks []*ListTask) {
|
||||
StartDateUnix: 1544600000,
|
||||
EndDateUnix: 1544700000,
|
||||
},
|
||||
{
|
||||
ID: 10,
|
||||
Text: "task #10 basic",
|
||||
CreatedByID: 1,
|
||||
ListID: 1,
|
||||
Created: 1543626724,
|
||||
Updated: 1543626724,
|
||||
},
|
||||
{
|
||||
ID: 11,
|
||||
Text: "task #11 basic",
|
||||
CreatedByID: 1,
|
||||
ListID: 1,
|
||||
Created: 1543626724,
|
||||
Updated: 1543626724,
|
||||
},
|
||||
{
|
||||
ID: 12,
|
||||
Text: "task #12 basic",
|
||||
CreatedByID: 1,
|
||||
ListID: 1,
|
||||
Created: 1543626724,
|
||||
Updated: 1543626724,
|
||||
},
|
||||
}
|
||||
|
||||
switch by {
|
||||
@ -122,6 +148,7 @@ func sortTasksForTesting(by SortBy) (tasks []*ListTask) {
|
||||
}
|
||||
|
||||
func TestListTask_ReadAll(t *testing.T) {
|
||||
assert.NoError(t, PrepareTestDatabase())
|
||||
type fields struct {
|
||||
ID int64
|
||||
Text string
|
||||
@ -254,6 +281,30 @@ func TestListTask_ReadAll(t *testing.T) {
|
||||
StartDateUnix: 1544600000,
|
||||
EndDateUnix: 1544700000,
|
||||
},
|
||||
{
|
||||
ID: 10,
|
||||
Text: "task #10 basic",
|
||||
CreatedByID: 1,
|
||||
ListID: 1,
|
||||
Created: 1543626724,
|
||||
Updated: 1543626724,
|
||||
},
|
||||
{
|
||||
ID: 11,
|
||||
Text: "task #11 basic",
|
||||
CreatedByID: 1,
|
||||
ListID: 1,
|
||||
Created: 1543626724,
|
||||
Updated: 1543626724,
|
||||
},
|
||||
{
|
||||
ID: 12,
|
||||
Text: "task #12 basic",
|
||||
CreatedByID: 1,
|
||||
ListID: 1,
|
||||
Created: 1543626724,
|
||||
Updated: 1543626724,
|
||||
},
|
||||
{
|
||||
ID: 4,
|
||||
Text: "task #4 low prio",
|
||||
@ -311,7 +362,113 @@ func TestListTask_ReadAll(t *testing.T) {
|
||||
a: &User{ID: 1},
|
||||
page: 0,
|
||||
},
|
||||
want: sortTasksForTesting(SortTasksByDueDateAsc),
|
||||
want: []*ListTask{
|
||||
{
|
||||
ID: 1,
|
||||
Text: "task #1",
|
||||
CreatedByID: 1,
|
||||
ListID: 1,
|
||||
Created: 1543626724,
|
||||
Updated: 1543626724,
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Text: "task #2 done",
|
||||
Done: true,
|
||||
CreatedByID: 1,
|
||||
ListID: 1,
|
||||
Created: 1543626724,
|
||||
Updated: 1543626724,
|
||||
},
|
||||
{
|
||||
ID: 3,
|
||||
Text: "task #3 high prio",
|
||||
CreatedByID: 1,
|
||||
ListID: 1,
|
||||
Created: 1543626724,
|
||||
Updated: 1543626724,
|
||||
Priority: 100,
|
||||
},
|
||||
{
|
||||
ID: 4,
|
||||
Text: "task #4 low prio",
|
||||
CreatedByID: 1,
|
||||
ListID: 1,
|
||||
Created: 1543626724,
|
||||
Updated: 1543626724,
|
||||
Priority: 1,
|
||||
},
|
||||
{
|
||||
ID: 7,
|
||||
Text: "task #7 with start date",
|
||||
CreatedByID: 1,
|
||||
ListID: 1,
|
||||
Created: 1543626724,
|
||||
Updated: 1543626724,
|
||||
StartDateUnix: 1544600000,
|
||||
},
|
||||
{
|
||||
ID: 8,
|
||||
Text: "task #8 with end date",
|
||||
CreatedByID: 1,
|
||||
ListID: 1,
|
||||
Created: 1543626724,
|
||||
Updated: 1543626724,
|
||||
EndDateUnix: 1544700000,
|
||||
},
|
||||
{
|
||||
ID: 9,
|
||||
Text: "task #9 with start and end date",
|
||||
CreatedByID: 1,
|
||||
ListID: 1,
|
||||
Created: 1543626724,
|
||||
Updated: 1543626724,
|
||||
StartDateUnix: 1544600000,
|
||||
EndDateUnix: 1544700000,
|
||||
},
|
||||
{
|
||||
ID: 10,
|
||||
Text: "task #10 basic",
|
||||
CreatedByID: 1,
|
||||
ListID: 1,
|
||||
Created: 1543626724,
|
||||
Updated: 1543626724,
|
||||
},
|
||||
{
|
||||
ID: 11,
|
||||
Text: "task #11 basic",
|
||||
CreatedByID: 1,
|
||||
ListID: 1,
|
||||
Created: 1543626724,
|
||||
Updated: 1543626724,
|
||||
},
|
||||
{
|
||||
ID: 12,
|
||||
Text: "task #12 basic",
|
||||
CreatedByID: 1,
|
||||
ListID: 1,
|
||||
Created: 1543626724,
|
||||
Updated: 1543626724,
|
||||
},
|
||||
{
|
||||
ID: 6,
|
||||
Text: "task #6 lower due date",
|
||||
CreatedByID: 1,
|
||||
ListID: 1,
|
||||
Created: 1543626724,
|
||||
Updated: 1543626724,
|
||||
DueDateUnix: 1543616724,
|
||||
},
|
||||
{
|
||||
ID: 5,
|
||||
Text: "task #5 higher due date",
|
||||
CreatedByID: 1,
|
||||
ListID: 1,
|
||||
Created: 1543626724,
|
||||
Updated: 1543626724,
|
||||
DueDateUnix: 1543636724,
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
@ -460,20 +617,20 @@ func TestListTask_ReadAll(t *testing.T) {
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("ListTask.ReadAll() = %v, want %v", got, tt.want)
|
||||
/*fmt.Println("Got:")
|
||||
fmt.Println("Got:")
|
||||
gotslice := got.([]*ListTask)
|
||||
for _, g := range gotslice {
|
||||
fmt.Println(g.Priority, g.Text)
|
||||
fmt.Println(g.Text)
|
||||
//fmt.Println(g.StartDateUnix)
|
||||
//fmt.Println(g.EndDateUnix)
|
||||
}
|
||||
fmt.Println("Want:")
|
||||
wantslice := tt.want.([]*ListTask)
|
||||
for _, w := range wantslice {
|
||||
fmt.Println(w.Priority, w.Text)
|
||||
fmt.Println(w.Text)
|
||||
//fmt.Println(w.StartDateUnix)
|
||||
//fmt.Println(w.EndDateUnix)
|
||||
}*/
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -153,3 +153,46 @@ func GetListTaskByID(listTaskID int64) (listTask ListTask, err error) {
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// GetTasksByIDs returns all tasks for a list of ids
|
||||
func (bt *BulkTask) GetTasksByIDs() (err error) {
|
||||
for _, id := range bt.IDs {
|
||||
if id < 1 {
|
||||
return ErrListTaskDoesNotExist{id}
|
||||
}
|
||||
}
|
||||
|
||||
err = x.In("id", bt.IDs).Find(&bt.Tasks)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// We use a map, to avoid looping over two slices at once
|
||||
var usermapids = make(map[int64]bool) // Bool ist just something, doesn't acutually matter
|
||||
for _, list := range bt.Tasks {
|
||||
usermapids[list.CreatedByID] = true
|
||||
}
|
||||
|
||||
// Make a slice from the map
|
||||
var userids []int64
|
||||
for uid := range usermapids {
|
||||
userids = append(userids, uid)
|
||||
}
|
||||
|
||||
// Get all users for the tasks
|
||||
var users []*User
|
||||
err = x.In("id", userids).Find(&users)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for in, task := range bt.Tasks {
|
||||
for _, u := range users {
|
||||
if task.CreatedByID == u.ID {
|
||||
bt.Tasks[in].CreatedBy = *u
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
@ -91,16 +91,8 @@ func (i *ListTask) Update() (err error) {
|
||||
return
|
||||
}
|
||||
|
||||
// When a repeating task is marked, as done, we update all deadlines and reminders and set it as undone
|
||||
if !ot.Done && i.Done && ot.RepeatAfter > 0 {
|
||||
ot.DueDateUnix = ot.DueDateUnix + ot.RepeatAfter
|
||||
|
||||
for in, r := range ot.RemindersUnix {
|
||||
ot.RemindersUnix[in] = r + ot.RepeatAfter
|
||||
}
|
||||
|
||||
i.Done = false
|
||||
}
|
||||
// When a repeating task is marked as done, we update all deadlines and reminders and set it as undone
|
||||
updateDone(&ot, i)
|
||||
|
||||
// For whatever reason, xorm dont detect if done is updated, so we need to update this every time by hand
|
||||
// Which is why we merge the actual task struct with the one we got from the
|
||||
@ -129,3 +121,15 @@ func (i *ListTask) Update() (err error) {
|
||||
*i = ot
|
||||
return
|
||||
}
|
||||
|
||||
func updateDone(oldTask *ListTask, newTask *ListTask) {
|
||||
if !oldTask.Done && newTask.Done && oldTask.RepeatAfter > 0 {
|
||||
oldTask.DueDateUnix = oldTask.DueDateUnix + oldTask.RepeatAfter // assuming we'll save the old task (merged)
|
||||
|
||||
for in, r := range oldTask.RemindersUnix {
|
||||
oldTask.RemindersUnix[in] = r + oldTask.RepeatAfter
|
||||
}
|
||||
|
||||
newTask.Done = false
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user