1
0

Add bulk edit for tasks (#42)

This commit is contained in:
konrad
2018-12-28 21:49:46 +00:00
committed by Gitea
parent b050132f4f
commit 3814b8a504
16 changed files with 862 additions and 20 deletions

View 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
}

View 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)
}
})
}
}

View File

@ -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
// =================

View File

@ -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

View File

@ -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)
}*/
}
}
})
}

View File

@ -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
}

View File

@ -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
}
}