diff --git a/Featurecreep.md b/Featurecreep.md index 8981304ef69..4aed82215b1 100644 --- a/Featurecreep.md +++ b/Featurecreep.md @@ -214,7 +214,8 @@ Sorry for some of them being in German, I'll tranlate them at some point. * [x] Percent done - For now just a float, may later depend on how many sub tasks are done or so * [ ] Attachments * [ ] Related tasks -> settable with a "kind" of relation like blocked, subtask, or just related or so - * [ ] Should be possible to relate tasks which are not in the same list + * [x] Should be possible to relate tasks which are not in the same list + * [ ] Replace the old subtask implementation * New Struct for the relation * Endpoint to get all full related tasks for a task * When using task.ReadOne() or ReadAll() only get the relation kind + title etc, not everything diff --git a/Makefile b/Makefile index 42318d251c8..53b81a2d3a0 100644 --- a/Makefile +++ b/Makefile @@ -214,7 +214,7 @@ gocyclo-check: go get -u github.com/fzipp/gocyclo; \ go install $(GOFLAGS) github.com/fzipp/gocyclo; \ fi - for S in $(GOFILES); do gocyclo -over 17 $$S || exit 1; done; + for S in $(GOFILES); do gocyclo -over 19 $$S || exit 1; done; .PHONY: static-check static-check: diff --git a/docs/content/doc/usage/errors.md b/docs/content/doc/usage/errors.md index 54a61b34358..498fe5022f7 100644 --- a/docs/content/doc/usage/errors.md +++ b/docs/content/doc/usage/errors.md @@ -38,6 +38,11 @@ This document describes the different errors Vikunja can return. | 4003 | 403 | All bulk editing tasks must belong to the same list. | | 4004 | 403 | Need at least one task when bulk editing tasks. | | 4005 | 403 | The user does not have the right to see the task. | +| 4006 | 403 | The user tried to set a parent task as the task itself. | +| 4007 | 400 | The user tried to create a task relation with an invalid kind of relation. | +| 4008 | 409 | The user tried to create a task relation which already exists. | +| 4009 | 404 | The task relation does not exist. | +| 4010 | 400 | Cannot relate a task with itself. | | 5001 | 404 | The namspace does not exist. | | 5003 | 403 | The user does not have access to the specified namespace. | | 5006 | 400 | The namespace name cannot be empty. | diff --git a/docs/content/doc/usage/relation_kinds.md b/docs/content/doc/usage/relation_kinds.md new file mode 100644 index 00000000000..3aba536b058 --- /dev/null +++ b/docs/content/doc/usage/relation_kinds.md @@ -0,0 +1,25 @@ +--- +date: "2019-09-25:00:00+02:00" +title: "Task Relation kinds" +draft: false +type: "doc" +menu: + sidebar: + parent: "usage" +--- + +# Available task relation kinds + +| Code | Description | +|------|-------------| +| subtask | Task is a subtask of the other task. This is the opposite of `parenttask`. | +| parenttask | Task is a parent task of the other task. This is the opposite of `subtask`. | +| related | Both tasks are related to each other. How is not more specified. | +| duplicateof | Task is a duplicate of the other task. This is the opposite of `duplicates`. | +| duplicates | Task duplicates the other task. This is the opposite of `duplicateof`. | +| blocking | Task is blocking the other task. This is the opposite of `blocked`. | +| blocked | Task is blocked by the other task. This is the opposite of `blocking`. | +| precedes | Task precedes the other task. This is the opposite of `follows`. | +| follows | Task follows the other task. This is the opposite of `precedes`. | +| copiedfrom | Task is copied from the other task. This is the opposite of `copiedto`. | +| copiedto | Task is copied to the other task. This is the opposite of `copiedfrom`. | diff --git a/pkg/integrations/task_test.go b/pkg/integrations/task_test.go index b034b8c376e..4481e3787f1 100644 --- a/pkg/integrations/task_test.go +++ b/pkg/integrations/task_test.go @@ -80,42 +80,42 @@ func TestTask(t *testing.T) { t.Run("by priority", func(t *testing.T) { rec, err := testHandler.testReadAllWithUser(url.Values{"sort": []string{"priority"}}, nil) assert.NoError(t, err) - assert.Contains(t, rec.Body.String(), `[{"id":3,"text":"task #3 high prio","description":"","done":false,"doneAt":0,"dueDate":0,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":100,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","percentDone":0,"subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":1,"username":"user1","avatarUrl":"111d68d06e2d317b5a59c2c6c5bad808","created":0,"updated":0}},{"id":4,"text":"task #4 low prio","description":"","done":false,"doneAt":0,"dueDate":0,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":1`) + assert.Contains(t, rec.Body.String(), `[{"id":3,"text":"task #3 high prio","description":"","done":false,"doneAt":0,"dueDate":0,"reminderDates":null,"listID":1,"repeatAfter":0,"priority":100,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","percentDone":0,"related_tasks":{},"created":1543626724,"updated":1543626724,"createdBy":{"id":1,"username":"user1","avatarUrl":"111d68d06e2d317b5a59c2c6c5bad808","created":0,"updated":0}},{"id":4,"text":"task #4 low prio","description":"","done":false,"doneAt":0,"dueDate":0,"reminderDates":null,"listID":1,"repeatAfter":0,"priority":1`) }) t.Run("by priority desc", func(t *testing.T) { rec, err := testHandler.testReadAllWithUser(url.Values{"sort": []string{"prioritydesc"}}, nil) assert.NoError(t, err) - assert.Contains(t, rec.Body.String(), `[{"id":3,"text":"task #3 high prio","description":"","done":false,"doneAt":0,"dueDate":0,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":100,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","percentDone":0,"subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":1,"username":"user1","avatarUrl":"111d68d06e2d317b5a59c2c6c5bad808","created":0,"updated":0}},{"id":4,"text":"task #4 low prio","description":"","done":false,"doneAt":0,"dueDate":0,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":1`) + assert.Contains(t, rec.Body.String(), `[{"id":3,"text":"task #3 high prio","description":"","done":false,"doneAt":0,"dueDate":0,"reminderDates":null,"listID":1,"repeatAfter":0,"priority":100,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","percentDone":0,"related_tasks":{},"created":1543626724,"updated":1543626724,"createdBy":{"id":1,"username":"user1","avatarUrl":"111d68d06e2d317b5a59c2c6c5bad808","created":0,"updated":0}},{"id":4,"text":"task #4 low prio","description":"","done":false,"doneAt":0,"dueDate":0,"reminderDates":null,"listID":1,"repeatAfter":0,"priority":1`) }) t.Run("by priority asc", func(t *testing.T) { rec, err := testHandler.testReadAllWithUser(url.Values{"sort": []string{"priorityasc"}}, nil) assert.NoError(t, err) - assert.Contains(t, rec.Body.String(), `{"id":33,"text":"task #33 with percent done","description":"","done":false,"doneAt":0,"dueDate":0,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":0,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","percentDone":0.5,"subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":1,"username":"user1","avatarUrl":"111d68d06e2d317b5a59c2c6c5bad808","created":0,"updated":0}},{"id":4,"text":"task #4 low prio","description":"","done":false,"doneAt":0,"dueDate":0,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":1,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","percentDone":0,"subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":1,"username":"user1","avatarUrl":"111d68d06e2d317b5a59c2c6c5bad808","created":0,"updated":0}},{"id":3,"text":"task #3 high prio","description":"","done":false,"doneAt":0,"dueDate":0,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":100,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","percentDone":0,"subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":1,"username":"user1","avatarUrl":"111d68d06e2d317b5a59c2c6c5bad808","created":0,"updated":0}}]`) + assert.Contains(t, rec.Body.String(), `{"id":33,"text":"task #33 with percent done","description":"","done":false,"doneAt":0,"dueDate":0,"reminderDates":null,"listID":1,"repeatAfter":0,"priority":0,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","percentDone":0.5,"related_tasks":{},"created":1543626724,"updated":1543626724,"createdBy":{"id":1,"username":"user1","avatarUrl":"111d68d06e2d317b5a59c2c6c5bad808","created":0,"updated":0}},{"id":4,"text":"task #4 low prio","description":"","done":false,"doneAt":0,"dueDate":0,"reminderDates":null,"listID":1,"repeatAfter":0,"priority":1,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","percentDone":0,"related_tasks":{},"created":1543626724,"updated":1543626724,"createdBy":{"id":1,"username":"user1","avatarUrl":"111d68d06e2d317b5a59c2c6c5bad808","created":0,"updated":0}},{"id":3,"text":"task #3 high prio","description":"","done":false,"doneAt":0,"dueDate":0,"reminderDates":null,"listID":1,"repeatAfter":0,"priority":100,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","percentDone":0,"related_tasks":{},"created":1543626724,"updated":1543626724,"createdBy":{"id":1,"username":"user1","avatarUrl":"111d68d06e2d317b5a59c2c6c5bad808","created":0,"updated":0}}]`) }) // should equal duedate desc t.Run("by duedate", func(t *testing.T) { rec, err := testHandler.testReadAllWithUser(url.Values{"sort": []string{"duedate"}}, nil) assert.NoError(t, err) - assert.Contains(t, rec.Body.String(), `[{"id":5,"text":"task #5 higher due date","description":"","done":false,"doneAt":0,"dueDate":1543636724,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":0,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","percentDone":0,"subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":1,"username":"user1","avatarUrl":"111d68d06e2d317b5a59c2c6c5bad808","created":0,"updated":0}},{"id":6,"text":"task #6 lower due date"`) + assert.Contains(t, rec.Body.String(), `[{"id":5,"text":"task #5 higher due date","description":"","done":false,"doneAt":0,"dueDate":1543636724,"reminderDates":null,"listID":1,"repeatAfter":0,"priority":0,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","percentDone":0,"related_tasks":{},"created":1543626724,"updated":1543626724,"createdBy":{"id":1,"username":"user1","avatarUrl":"111d68d06e2d317b5a59c2c6c5bad808","created":0,"updated":0}},{"id":6,"text":"task #6 lower due date"`) }) t.Run("by duedate desc", func(t *testing.T) { rec, err := testHandler.testReadAllWithUser(url.Values{"sort": []string{"duedatedesc"}}, nil) assert.NoError(t, err) - assert.Contains(t, rec.Body.String(), `[{"id":5,"text":"task #5 higher due date","description":"","done":false,"doneAt":0,"dueDate":1543636724,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":0,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","percentDone":0,"subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":1,"username":"user1","avatarUrl":"111d68d06e2d317b5a59c2c6c5bad808","created":0,"updated":0}},{"id":6,"text":"task #6 lower due date"`) + assert.Contains(t, rec.Body.String(), `[{"id":5,"text":"task #5 higher due date","description":"","done":false,"doneAt":0,"dueDate":1543636724,"reminderDates":null,"listID":1,"repeatAfter":0,"priority":0,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","percentDone":0,"related_tasks":{},"created":1543626724,"updated":1543626724,"createdBy":{"id":1,"username":"user1","avatarUrl":"111d68d06e2d317b5a59c2c6c5bad808","created":0,"updated":0}},{"id":6,"text":"task #6 lower due date"`) }) t.Run("by duedate asc", func(t *testing.T) { rec, err := testHandler.testReadAllWithUser(url.Values{"sort": []string{"duedateasc"}}, nil) assert.NoError(t, err) - assert.Contains(t, rec.Body.String(), `{"id":6,"text":"task #6 lower due date","description":"","done":false,"doneAt":0,"dueDate":1543616724,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":0,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","percentDone":0,"subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":1,"username":"user1","avatarUrl":"111d68d06e2d317b5a59c2c6c5bad808","created":0,"updated":0}},{"id":5,"text":"task #5 higher due date","description":"","done":false,"doneAt":0,"dueDate":1543636724,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":0,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","percentDone":0,"subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":1,"username":"user1","avatarUrl":"111d68d06e2d317b5a59c2c6c5bad808","created":0,"updated":0}}]`) + assert.Contains(t, rec.Body.String(), `{"id":6,"text":"task #6 lower due date","description":"","done":false,"doneAt":0,"dueDate":1543616724,"reminderDates":null,"listID":1,"repeatAfter":0,"priority":0,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","percentDone":0,"related_tasks":{},"created":1543626724,"updated":1543626724,"createdBy":{"id":1,"username":"user1","avatarUrl":"111d68d06e2d317b5a59c2c6c5bad808","created":0,"updated":0}},{"id":5,"text":"task #5 higher due date","description":"","done":false,"doneAt":0,"dueDate":1543636724,"reminderDates":null,"listID":1,"repeatAfter":0,"priority":0,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","percentDone":0,"related_tasks":{},"created":1543626724,"updated":1543626724,"createdBy":{"id":1,"username":"user1","avatarUrl":"111d68d06e2d317b5a59c2c6c5bad808","created":0,"updated":0}}]`) }) t.Run("invalid parameter", func(t *testing.T) { // Invalid parameter should not sort at all rec, err := testHandler.testReadAllWithUser(url.Values{"sort": []string{"loremipsum"}}, nil) assert.NoError(t, err) - assert.NotContains(t, rec.Body.String(), `[{"id":3,"text":"task #3 high prio","description":"","done":false,"dueDate":0,"reminderDates":null,"repeatAfter":0,"parentTaskID":0,"priority":100,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","email":"","created":0,"updated":0}},{"id":4,"text":"task #4 low prio","description":"","done":false,"dueDate":0,"reminderDates":null,"repeatAfter":0,"parentTaskID":0,"priority":1`) - assert.NotContains(t, rec.Body.String(), `{"id":4,"text":"task #4 low prio","description":"","done":false,"dueDate":0,"reminderDates":null,"repeatAfter":0,"parentTaskID":0,"priority":1,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","email":"","created":0,"updated":0}},{"id":3,"text":"task #3 high prio","description":"","done":false,"dueDate":0,"reminderDates":null,"repeatAfter":0,"parentTaskID":0,"priority":100,"startDate":0,"endDate":0,"assignees":null,"labels":null,"subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","email":"","created":0,"updated":0}}]`) - assert.NotContains(t, rec.Body.String(), `[{"id":5,"text":"task #5 higher due date","description":"","done":false,"dueDate":1543636724,"reminderDates":null,"repeatAfter":0,"parentTaskID":0,"priority":0,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","email":"","created":0,"updated":0}},{"id":6,"text":"task #6 lower due date"`) - assert.NotContains(t, rec.Body.String(), `{"id":6,"text":"task #6 lower due date","description":"","done":false,"dueDate":1543616724,"reminderDates":null,"repeatAfter":0,"parentTaskID":0,"priority":0,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","email":"","created":0,"updated":0}},{"id":5,"text":"task #5 higher due date","description":"","done":false,"dueDate":1543636724,"reminderDates":null,"repeatAfter":0,"parentTaskID":0,"priority":0,"startDate":0,"endDate":0,"assignees":null,"labels":null,"subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","email":"","created":0,"updated":0}}]`) + assert.NotContains(t, rec.Body.String(), `[{"id":3,"text":"task #3 high prio","description":"","done":false,"dueDate":0,"reminderDates":null,"repeatAfter":0,"priority":100,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","email":"","created":0,"updated":0}},{"id":4,"text":"task #4 low prio","description":"","done":false,"dueDate":0,"reminderDates":null,"repeatAfter":0,"priority":1`) + assert.NotContains(t, rec.Body.String(), `{"id":4,"text":"task #4 low prio","description":"","done":false,"dueDate":0,"reminderDates":null,"repeatAfter":0,"priority":1,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","email":"","created":0,"updated":0}},{"id":3,"text":"task #3 high prio","description":"","done":false,"dueDate":0,"reminderDates":null,"repeatAfter":0,"priority":100,"startDate":0,"endDate":0,"assignees":null,"labels":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","email":"","created":0,"updated":0}}]`) + assert.NotContains(t, rec.Body.String(), `[{"id":5,"text":"task #5 higher due date","description":"","done":false,"dueDate":1543636724,"reminderDates":null,"repeatAfter":0,"priority":0,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","email":"","created":0,"updated":0}},{"id":6,"text":"task #6 lower due date"`) + assert.NotContains(t, rec.Body.String(), `{"id":6,"text":"task #6 lower due date","description":"","done":false,"dueDate":1543616724,"reminderDates":null,"repeatAfter":0,"priority":0,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","email":"","created":0,"updated":0}},{"id":5,"text":"task #5 higher due date","description":"","done":false,"dueDate":1543636724,"reminderDates":null,"repeatAfter":0,"priority":0,"startDate":0,"endDate":0,"assignees":null,"labels":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","email":"","created":0,"updated":0}}]`) }) }) t.Run("Date range", func(t *testing.T) { @@ -245,23 +245,6 @@ func TestTask(t *testing.T) { assert.Contains(t, rec.Body.String(), `"done":false`) assert.NotContains(t, rec.Body.String(), `"done":true`) }) - t.Run("Parent task", func(t *testing.T) { - rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"listtask": "1"}, `{"parentTaskID":2}`) - assert.NoError(t, err) - assert.Contains(t, rec.Body.String(), `"parentTaskID":2`) - assert.NotContains(t, rec.Body.String(), `"parentTaskID":0`) - }) - t.Run("Parent task same task", func(t *testing.T) { - _, err := testHandler.testUpdateWithUser(nil, map[string]string{"listtask": "1"}, `{"parentTaskID":1}`) - assert.Error(t, err) - assertHandlerErrorCode(t, err, models.ErrCodeParentTaskCannotBeTheSame) - }) - t.Run("Parent task unset", func(t *testing.T) { - rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"listtask": "29"}, `{"parentTaskID":0}`) - assert.NoError(t, err) - assert.Contains(t, rec.Body.String(), `"parentTaskID":0`) - assert.NotContains(t, rec.Body.String(), `"parentTaskID":1`) - }) t.Run("Assignees", func(t *testing.T) { rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"listtask": "1"}, `{"assignees":[{"id":1}]}`) assert.NoError(t, err) diff --git a/pkg/migration/20190922205826.go b/pkg/migration/20190922205826.go new file mode 100644 index 00000000000..0d423117fba --- /dev/null +++ b/pkg/migration/20190922205826.go @@ -0,0 +1,96 @@ +// Copyright 2019 Vikunja and contriubtors. All rights reserved. +// +// This file is part of Vikunja. +// +// Vikunja 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. +// +// Vikunja 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 Vikunja. If not, see . + +package migration + +import ( + "code.vikunja.io/api/pkg/models" + "github.com/go-xorm/xorm" + "src.techknowlogick.com/xormigrate" +) + +// TaskRelation represents a kind of relation between two tasks +type taskRelation20190922205826 struct { + ID int64 `xorm:"int(11) autoincr not null unique pk"` + TaskID int64 `xorm:"int(11) not null"` + OtherTaskID int64 `xorm:"int(11) not null"` + RelationKind models.RelationKind `xorm:"varchar(50) not null"` + CreatedByID int64 `xorm:"int(11) not null"` + Created int64 `xorm:"created not null"` +} + +// TableName holds the table name for the task relation table +func (taskRelation20190922205826) TableName() string { + return "task_relations" +} + +type task20190922205826 struct { + ID int64 `xorm:"int(11) autoincr not null unique pk"` + CreatedByID int64 `xorm:"int(11) not null"` + ParentTaskID int64 `xorm:"int(11) INDEX null"` +} + +func (task20190922205826) TableName() string { + return "tasks" +} + +func init() { + migrations = append(migrations, &xormigrate.Migration{ + ID: "20190922205826", + Description: "Add task relations", + Migrate: func(tx *xorm.Engine) error { + err := tx.Sync2(taskRelation20190922205826{}) + if err != nil { + return err + } + + // Get all current subtasks and put them in a new table + tasks := []*task20190922205826{} + err = tx.Where("parent_task_id = not null OR parent_task_id != 0").Find(&tasks) + if err != nil { + return err + } + + var migratedRelations = make([]*taskRelation20190922205826, 0, len(tasks)*2) + for _, t := range tasks { + migratedRelations = append(migratedRelations, + &taskRelation20190922205826{ + TaskID: t.ID, + OtherTaskID: t.ParentTaskID, + RelationKind: models.RelationKindParenttask, + CreatedByID: t.CreatedByID, + }, + &taskRelation20190922205826{ + TaskID: t.ParentTaskID, + OtherTaskID: t.ID, + RelationKind: models.RelationKindSubtask, + CreatedByID: t.CreatedByID, + }) + } + + _, err = tx.Insert(migratedRelations) + if err != nil { + return err + } + + return dropTableColum(tx, "tasks", "parent_task_id") + }, + Rollback: func(tx *xorm.Engine) error { + return tx.DropTables(taskRelation20190922205826{}) + }, + }) +} diff --git a/pkg/models/bulk_task.go b/pkg/models/bulk_task.go index df1b0db3d28..721e151f73f 100644 --- a/pkg/models/bulk_task.go +++ b/pkg/models/bulk_task.go @@ -116,7 +116,6 @@ func (bt *BulkTask) Update() (err error) { "due_date_unix", "reminders_unix", "repeat_after", - "parent_task_id", "priority", "start_date_unix", "end_date_unix"). diff --git a/pkg/models/error.go b/pkg/models/error.go index 43e304620ac..895c599f9a2 100644 --- a/pkg/models/error.go +++ b/pkg/models/error.go @@ -618,6 +618,119 @@ func (err ErrParentTaskCannotBeTheSame) HTTPError() web.HTTPError { } } +// ErrInvalidRelationKind represents an error where the user tries to use an invalid relation kind +type ErrInvalidRelationKind struct { + Kind RelationKind +} + +// IsErrInvalidRelationKind checks if an error is ErrInvalidRelationKind. +func IsErrInvalidRelationKind(err error) bool { + _, ok := err.(ErrInvalidRelationKind) + return ok +} + +func (err ErrInvalidRelationKind) Error() string { + return fmt.Sprintf("Invalid task relation kind [Kind: %v]", err.Kind) +} + +// ErrCodeInvalidRelationKind holds the unique world-error code of this error +const ErrCodeInvalidRelationKind = 4007 + +// HTTPError holds the http error description +func (err ErrInvalidRelationKind) HTTPError() web.HTTPError { + return web.HTTPError{ + HTTPCode: http.StatusBadRequest, + Code: ErrCodeInvalidRelationKind, + Message: "The task relation is invalid.", + } +} + +// ErrRelationAlreadyExists represents an error where the user tries to create an already existing relation +type ErrRelationAlreadyExists struct { + Kind RelationKind + TaskID int64 + OtherTaskID int64 +} + +// IsErrRelationAlreadyExists checks if an error is ErrRelationAlreadyExists. +func IsErrRelationAlreadyExists(err error) bool { + _, ok := err.(ErrRelationAlreadyExists) + return ok +} + +func (err ErrRelationAlreadyExists) Error() string { + return fmt.Sprintf("Task relation already exists [TaskID: %v, OtherTaskID: %v, Kind: %v]", err.TaskID, err.OtherTaskID, err.Kind) +} + +// ErrCodeRelationAlreadyExists holds the unique world-error code of this error +const ErrCodeRelationAlreadyExists = 4008 + +// HTTPError holds the http error description +func (err ErrRelationAlreadyExists) HTTPError() web.HTTPError { + return web.HTTPError{ + HTTPCode: http.StatusConflict, + Code: ErrCodeRelationAlreadyExists, + Message: "The task relation already exists.", + } +} + +// ErrRelationDoesNotExist represents an error where a task relation does not exist. +type ErrRelationDoesNotExist struct { + Kind RelationKind + TaskID int64 + OtherTaskID int64 +} + +// IsErrRelationDoesNotExist checks if an error is ErrRelationDoesNotExist. +func IsErrRelationDoesNotExist(err error) bool { + _, ok := err.(ErrRelationDoesNotExist) + return ok +} + +func (err ErrRelationDoesNotExist) Error() string { + return fmt.Sprintf("Task relation does not exist [TaskID: %v, OtherTaskID: %v, Kind: %v]", err.TaskID, err.OtherTaskID, err.Kind) +} + +// ErrCodeRelationDoesNotExist holds the unique world-error code of this error +const ErrCodeRelationDoesNotExist = 4009 + +// HTTPError holds the http error description +func (err ErrRelationDoesNotExist) HTTPError() web.HTTPError { + return web.HTTPError{ + HTTPCode: http.StatusNotFound, + Code: ErrCodeRelationDoesNotExist, + Message: "The task relation does not exist.", + } +} + +// ErrRelationTasksCannotBeTheSame represents an error where the user tries to relate a task with itself +type ErrRelationTasksCannotBeTheSame struct { + TaskID int64 + OtherTaskID int64 +} + +// IsErrRelationTasksCannotBeTheSame checks if an error is ErrRelationTasksCannotBeTheSame. +func IsErrRelationTasksCannotBeTheSame(err error) bool { + _, ok := err.(ErrRelationTasksCannotBeTheSame) + return ok +} + +func (err ErrRelationTasksCannotBeTheSame) Error() string { + return fmt.Sprintf("Tried to relate a task with itself [TaskID: %v, OtherTaskID: %v]", err.TaskID, err.OtherTaskID) +} + +// ErrCodeRelationTasksCannotBeTheSame holds the unique world-error code of this error +const ErrCodeRelationTasksCannotBeTheSame = 4010 + +// HTTPError holds the http error description +func (err ErrRelationTasksCannotBeTheSame) HTTPError() web.HTTPError { + return web.HTTPError{ + HTTPCode: http.StatusBadRequest, + Code: ErrCodeRelationTasksCannotBeTheSame, + Message: "You cannot relate a task with itself", + } +} + // ================= // Namespace errors // ================= diff --git a/pkg/models/fixtures/task_relations.yml b/pkg/models/fixtures/task_relations.yml new file mode 100644 index 00000000000..6abf882a174 --- /dev/null +++ b/pkg/models/fixtures/task_relations.yml @@ -0,0 +1,12 @@ +- id: 1 + task_id: 1 + other_task_id: 29 + relation_kind: 'subtask' + created_by_id: 1 + created: 0 +- id: 2 + task_id: 29 + other_task_id: 1 + relation_kind: 'parenttask' + created_by_id: 1 + created: 0 diff --git a/pkg/models/fixtures/tasks.yml b/pkg/models/fixtures/tasks.yml index 73007088dc3..246ba47810e 100644 --- a/pkg/models/fixtures/tasks.yml +++ b/pkg/models/fixtures/tasks.yml @@ -181,7 +181,6 @@ - id: 29 text: 'task #29 with parent task (1)' created_by_id: 1 - parent_task_id: 1 list_id: 1 created: 1543626724 updated: 1543626724 diff --git a/pkg/models/models.go b/pkg/models/models.go index 5ce084505b9..37088c08180 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -49,6 +49,7 @@ func GetTables() []interface{} { &LabelTask{}, &TaskReminder{}, &LinkSharing{}, + &TaskRelation{}, } } diff --git a/pkg/models/task_readall_test.go b/pkg/models/task_readall_test.go index de8651721e8..51f57d0eb81 100644 --- a/pkg/models/task_readall_test.go +++ b/pkg/models/task_readall_test.go @@ -55,6 +55,18 @@ func sortTasksForTesting(by SortBy) (tasks []*Task) { Created: 0, }, }, + RelatedTasks: map[RelationKind][]*Task{ + RelationKindSubtask: { + { + ID: 29, + Text: "task #29 with parent task (1)", + CreatedByID: 1, + ListID: 1, + Created: 1543626724, + Updated: 1543626724, + }, + }, + }, Created: 1543626724, Updated: 1543626724, }, @@ -75,48 +87,53 @@ func sortTasksForTesting(by SortBy) (tasks []*Task) { Created: 0, }, }, - Created: 1543626724, - Updated: 1543626724, + RelatedTasks: map[RelationKind][]*Task{}, + Created: 1543626724, + Updated: 1543626724, }, { - ID: 3, - Text: "task #3 high prio", - CreatedByID: 1, - CreatedBy: user1, - ListID: 1, - Created: 1543626724, - Updated: 1543626724, - Priority: 100, + ID: 3, + Text: "task #3 high prio", + CreatedByID: 1, + CreatedBy: user1, + ListID: 1, + RelatedTasks: map[RelationKind][]*Task{}, + Created: 1543626724, + Updated: 1543626724, + Priority: 100, }, { - ID: 4, - Text: "task #4 low prio", - CreatedByID: 1, - CreatedBy: user1, - ListID: 1, - Created: 1543626724, - Updated: 1543626724, - Priority: 1, + ID: 4, + Text: "task #4 low prio", + CreatedByID: 1, + CreatedBy: user1, + ListID: 1, + RelatedTasks: map[RelationKind][]*Task{}, + Created: 1543626724, + Updated: 1543626724, + Priority: 1, }, { - ID: 5, - Text: "task #5 higher due date", - CreatedByID: 1, - CreatedBy: user1, - ListID: 1, - Created: 1543626724, - Updated: 1543626724, - DueDateUnix: 1543636724, + ID: 5, + Text: "task #5 higher due date", + CreatedByID: 1, + CreatedBy: user1, + ListID: 1, + RelatedTasks: map[RelationKind][]*Task{}, + Created: 1543626724, + Updated: 1543626724, + DueDateUnix: 1543636724, }, { - ID: 6, - Text: "task #6 lower due date", - CreatedByID: 1, - CreatedBy: user1, - ListID: 1, - Created: 1543626724, - Updated: 1543626724, - DueDateUnix: 1543616724, + ID: 6, + Text: "task #6 lower due date", + CreatedByID: 1, + CreatedBy: user1, + ListID: 1, + RelatedTasks: map[RelationKind][]*Task{}, + Created: 1543626724, + Updated: 1543626724, + DueDateUnix: 1543616724, }, { ID: 7, @@ -124,19 +141,21 @@ func sortTasksForTesting(by SortBy) (tasks []*Task) { CreatedByID: 1, CreatedBy: user1, ListID: 1, + RelatedTasks: map[RelationKind][]*Task{}, Created: 1543626724, Updated: 1543626724, StartDateUnix: 1544600000, }, { - ID: 8, - Text: "task #8 with end date", - CreatedByID: 1, - CreatedBy: user1, - ListID: 1, - Created: 1543626724, - Updated: 1543626724, - EndDateUnix: 1544700000, + ID: 8, + Text: "task #8 with end date", + CreatedByID: 1, + CreatedBy: user1, + ListID: 1, + RelatedTasks: map[RelationKind][]*Task{}, + Created: 1543626724, + Updated: 1543626724, + EndDateUnix: 1544700000, }, { ID: 9, @@ -144,145 +163,161 @@ func sortTasksForTesting(by SortBy) (tasks []*Task) { CreatedByID: 1, CreatedBy: user1, ListID: 1, + RelatedTasks: map[RelationKind][]*Task{}, Created: 1543626724, Updated: 1543626724, StartDateUnix: 1544600000, EndDateUnix: 1544700000, }, { - ID: 10, - Text: "task #10 basic", - CreatedByID: 1, - CreatedBy: user1, - ListID: 1, - Created: 1543626724, - Updated: 1543626724, + ID: 10, + Text: "task #10 basic", + CreatedByID: 1, + CreatedBy: user1, + ListID: 1, + RelatedTasks: map[RelationKind][]*Task{}, + Created: 1543626724, + Updated: 1543626724, }, { - ID: 11, - Text: "task #11 basic", - CreatedByID: 1, - CreatedBy: user1, - ListID: 1, - Created: 1543626724, - Updated: 1543626724, + ID: 11, + Text: "task #11 basic", + CreatedByID: 1, + CreatedBy: user1, + ListID: 1, + RelatedTasks: map[RelationKind][]*Task{}, + Created: 1543626724, + Updated: 1543626724, }, { - ID: 12, - Text: "task #12 basic", - CreatedByID: 1, - CreatedBy: user1, - ListID: 1, - Created: 1543626724, - Updated: 1543626724, + ID: 12, + Text: "task #12 basic", + CreatedByID: 1, + CreatedBy: user1, + ListID: 1, + RelatedTasks: map[RelationKind][]*Task{}, + Created: 1543626724, + Updated: 1543626724, }, { - ID: 15, - Text: "task #15", - CreatedByID: 6, - CreatedBy: user6, - ListID: 6, - Created: 1543626724, - Updated: 1543626724, + ID: 15, + Text: "task #15", + CreatedByID: 6, + CreatedBy: user6, + ListID: 6, + RelatedTasks: map[RelationKind][]*Task{}, + Created: 1543626724, + Updated: 1543626724, }, { - ID: 16, - Text: "task #16", - CreatedByID: 6, - CreatedBy: user6, - ListID: 7, - Created: 1543626724, - Updated: 1543626724, + ID: 16, + Text: "task #16", + CreatedByID: 6, + CreatedBy: user6, + ListID: 7, + RelatedTasks: map[RelationKind][]*Task{}, + Created: 1543626724, + Updated: 1543626724, }, { - ID: 17, - Text: "task #17", - CreatedByID: 6, - CreatedBy: user6, - ListID: 8, - Created: 1543626724, - Updated: 1543626724, + ID: 17, + Text: "task #17", + CreatedByID: 6, + CreatedBy: user6, + ListID: 8, + RelatedTasks: map[RelationKind][]*Task{}, + Created: 1543626724, + Updated: 1543626724, }, { - ID: 18, - Text: "task #18", - CreatedByID: 6, - CreatedBy: user6, - ListID: 9, - Created: 1543626724, - Updated: 1543626724, + ID: 18, + Text: "task #18", + CreatedByID: 6, + CreatedBy: user6, + ListID: 9, + RelatedTasks: map[RelationKind][]*Task{}, + Created: 1543626724, + Updated: 1543626724, }, { - ID: 19, - Text: "task #19", - CreatedByID: 6, - CreatedBy: user6, - ListID: 10, - Created: 1543626724, - Updated: 1543626724, + ID: 19, + Text: "task #19", + CreatedByID: 6, + CreatedBy: user6, + ListID: 10, + RelatedTasks: map[RelationKind][]*Task{}, + Created: 1543626724, + Updated: 1543626724, }, { - ID: 20, - Text: "task #20", - CreatedByID: 6, - CreatedBy: user6, - ListID: 11, - Created: 1543626724, - Updated: 1543626724, + ID: 20, + Text: "task #20", + CreatedByID: 6, + CreatedBy: user6, + ListID: 11, + RelatedTasks: map[RelationKind][]*Task{}, + Created: 1543626724, + Updated: 1543626724, }, { - ID: 21, - Text: "task #21", - CreatedByID: 6, - CreatedBy: user6, - ListID: 12, - Created: 1543626724, - Updated: 1543626724, + ID: 21, + Text: "task #21", + CreatedByID: 6, + CreatedBy: user6, + ListID: 12, + RelatedTasks: map[RelationKind][]*Task{}, + Created: 1543626724, + Updated: 1543626724, }, { - ID: 22, - Text: "task #22", - CreatedByID: 6, - CreatedBy: user6, - ListID: 13, - Created: 1543626724, - Updated: 1543626724, + ID: 22, + Text: "task #22", + CreatedByID: 6, + CreatedBy: user6, + ListID: 13, + RelatedTasks: map[RelationKind][]*Task{}, + Created: 1543626724, + Updated: 1543626724, }, { - ID: 23, - Text: "task #23", - CreatedByID: 6, - CreatedBy: user6, - ListID: 14, - Created: 1543626724, - Updated: 1543626724, + ID: 23, + Text: "task #23", + CreatedByID: 6, + CreatedBy: user6, + ListID: 14, + RelatedTasks: map[RelationKind][]*Task{}, + Created: 1543626724, + Updated: 1543626724, }, { - ID: 24, - Text: "task #24", - CreatedByID: 6, - CreatedBy: user6, - ListID: 15, - Created: 1543626724, - Updated: 1543626724, + ID: 24, + Text: "task #24", + CreatedByID: 6, + CreatedBy: user6, + ListID: 15, + RelatedTasks: map[RelationKind][]*Task{}, + Created: 1543626724, + Updated: 1543626724, }, { - ID: 25, - Text: "task #25", - CreatedByID: 6, - CreatedBy: user6, - ListID: 16, - Created: 1543626724, - Updated: 1543626724, + ID: 25, + Text: "task #25", + CreatedByID: 6, + CreatedBy: user6, + ListID: 16, + RelatedTasks: map[RelationKind][]*Task{}, + Created: 1543626724, + Updated: 1543626724, }, { - ID: 26, - Text: "task #26", - CreatedByID: 6, - CreatedBy: user6, - ListID: 17, - Created: 1543626724, - Updated: 1543626724, + ID: 26, + Text: "task #26", + CreatedByID: 6, + CreatedBy: user6, + ListID: 17, + RelatedTasks: map[RelationKind][]*Task{}, + Created: 1543626724, + Updated: 1543626724, }, { ID: 27, @@ -291,18 +326,42 @@ func sortTasksForTesting(by SortBy) (tasks []*Task) { CreatedBy: user1, RemindersUnix: []int64{1543626724, 1543626824}, ListID: 1, + RelatedTasks: map[RelationKind][]*Task{}, Created: 1543626724, Updated: 1543626724, }, { - ID: 28, - Text: "task #28 with repeat after", + ID: 28, + Text: "task #28 with repeat after", + CreatedByID: 1, + CreatedBy: user1, + ListID: 1, + RelatedTasks: map[RelationKind][]*Task{}, + RepeatAfter: 3600, + Created: 1543626724, + Updated: 1543626724, + }, + { + ID: 29, + Text: "task #29 with parent task (1)", CreatedByID: 1, CreatedBy: user1, ListID: 1, - RepeatAfter: 3600, - Created: 1543626724, - Updated: 1543626724, + RelatedTasks: map[RelationKind][]*Task{ + RelationKindParenttask: { + { + ID: 1, + Text: "task #1", + Description: "Lorem Ipsum", + CreatedByID: 1, + ListID: 1, + Created: 1543626724, + Updated: 1543626724, + }, + }, + }, + Created: 1543626724, + Updated: 1543626724, }, { ID: 30, @@ -314,37 +373,41 @@ func sortTasksForTesting(by SortBy) (tasks []*Task) { user1, user2, }, - Created: 1543626724, - Updated: 1543626724, + RelatedTasks: map[RelationKind][]*Task{}, + Created: 1543626724, + Updated: 1543626724, }, { - ID: 31, - Text: "task #31 with color", - HexColor: "f0f0f0", - CreatedByID: 1, - CreatedBy: user1, - ListID: 1, - Created: 1543626724, - Updated: 1543626724, + ID: 31, + Text: "task #31 with color", + HexColor: "f0f0f0", + CreatedByID: 1, + CreatedBy: user1, + ListID: 1, + RelatedTasks: map[RelationKind][]*Task{}, + Created: 1543626724, + Updated: 1543626724, }, { - ID: 32, - Text: "task #32", - CreatedByID: 1, - CreatedBy: user1, - ListID: 3, - Created: 1543626724, - Updated: 1543626724, + ID: 32, + Text: "task #32", + CreatedByID: 1, + CreatedBy: user1, + ListID: 3, + RelatedTasks: map[RelationKind][]*Task{}, + Created: 1543626724, + Updated: 1543626724, }, { - ID: 33, - Text: "task #33 with percent done", - CreatedByID: 1, - CreatedBy: user1, - ListID: 1, - PercentDone: 0.5, - Created: 1543626724, - Updated: 1543626724, + ID: 33, + Text: "task #33 with percent done", + CreatedByID: 1, + CreatedBy: user1, + ListID: 1, + PercentDone: 0.5, + RelatedTasks: map[RelationKind][]*Task{}, + Created: 1543626724, + Updated: 1543626724, }, } @@ -392,12 +455,10 @@ func TestTask_ReadAll(t *testing.T) { CreatedByID int64 ListID int64 RepeatAfter int64 - ParentTaskID int64 Priority int64 Sorting string StartDateSortUnix int64 EndDateSortUnix int64 - Subtasks []*Task Created int64 Updated int64 CreatedBy *User @@ -524,6 +585,7 @@ func TestTask_ReadAll(t *testing.T) { CreatedByID: 1, CreatedBy: user1, ListID: 1, + RelatedTasks: map[RelationKind][]*Task{}, Created: 1543626724, Updated: 1543626724, StartDateUnix: 1544600000, @@ -534,6 +596,7 @@ func TestTask_ReadAll(t *testing.T) { CreatedByID: 1, CreatedBy: user1, ListID: 1, + RelatedTasks: map[RelationKind][]*Task{}, Created: 1543626724, Updated: 1543626724, StartDateUnix: 1544600000, @@ -555,14 +618,15 @@ func TestTask_ReadAll(t *testing.T) { }, want: []*Task{ { - ID: 8, - Text: "task #8 with end date", - CreatedByID: 1, - CreatedBy: user1, - ListID: 1, - Created: 1543626724, - Updated: 1543626724, - EndDateUnix: 1544700000, + ID: 8, + Text: "task #8 with end date", + CreatedByID: 1, + CreatedBy: user1, + ListID: 1, + RelatedTasks: map[RelationKind][]*Task{}, + Created: 1543626724, + Updated: 1543626724, + EndDateUnix: 1544700000, }, { ID: 9, @@ -570,6 +634,7 @@ func TestTask_ReadAll(t *testing.T) { CreatedByID: 1, CreatedBy: user1, ListID: 1, + RelatedTasks: map[RelationKind][]*Task{}, Created: 1543626724, Updated: 1543626724, StartDateUnix: 1544600000, @@ -590,14 +655,15 @@ func TestTask_ReadAll(t *testing.T) { }, want: []*Task{ { - ID: 8, - Text: "task #8 with end date", - CreatedByID: 1, - CreatedBy: user1, - ListID: 1, - Created: 1543626724, - Updated: 1543626724, - EndDateUnix: 1544700000, + ID: 8, + Text: "task #8 with end date", + CreatedByID: 1, + CreatedBy: user1, + ListID: 1, + RelatedTasks: map[RelationKind][]*Task{}, + Created: 1543626724, + Updated: 1543626724, + EndDateUnix: 1544700000, }, { ID: 9, @@ -605,6 +671,7 @@ func TestTask_ReadAll(t *testing.T) { CreatedByID: 1, CreatedBy: user1, ListID: 1, + RelatedTasks: map[RelationKind][]*Task{}, Created: 1543626724, Updated: 1543626724, StartDateUnix: 1544600000, @@ -626,12 +693,10 @@ func TestTask_ReadAll(t *testing.T) { CreatedByID: tt.fields.CreatedByID, ListID: tt.fields.ListID, RepeatAfter: tt.fields.RepeatAfter, - ParentTaskID: tt.fields.ParentTaskID, Priority: tt.fields.Priority, Sorting: tt.fields.Sorting, StartDateSortUnix: tt.fields.StartDateSortUnix, EndDateSortUnix: tt.fields.EndDateSortUnix, - Subtasks: tt.fields.Subtasks, Created: tt.fields.Created, Updated: tt.fields.Updated, CreatedBy: tt.fields.CreatedBy, diff --git a/pkg/models/task_relation.go b/pkg/models/task_relation.go new file mode 100644 index 00000000000..ebad999c15c --- /dev/null +++ b/pkg/models/task_relation.go @@ -0,0 +1,216 @@ +// Copyright 2019 Vikunja and contriubtors. All rights reserved. +// +// This file is part of Vikunja. +// +// Vikunja 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. +// +// Vikunja 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 Vikunja. If not, see . + +package models + +import ( + "code.vikunja.io/web" +) + +// RelationKind represents a kind of relation between to tasks +type RelationKind string + +// All valid relation kinds +const ( + RelationKindUnknown RelationKind = `unknown` + RelationKindSubtask RelationKind = `subtask` + RelationKindParenttask RelationKind = `parenttask` + RelationKindRelated RelationKind = `related` + RelationKindDuplicateOf RelationKind = `duplicateof` + RelationKindDuplicates RelationKind = `duplicates` + RelationKindBlocking RelationKind = `blocking` + RelationKindBlocked RelationKind = `blocked` + RelationKindPreceeds RelationKind = `precedes` + RelationKindFollows RelationKind = `follows` + RelationKindCopiedFrom RelationKind = `copiedfrom` + RelationKindCopiedTo RelationKind = `copiedto` +) + +/* + * The direction of the relation goes _from_ task_id -> other_task_id. + * The relation kind only tells us something about the relation in that direction, and NOT + * the other way around. This means each relation exists two times in the db, one for each + * relevant direction. + * This design allows to easily do things like "Give me every relation for this task" whithout having + * to deal with each possible case of relation. Instead, it would just give me every relation record + * which has task_id set to the task ID I care about. + * + * For example, when I create a relation where I define task 2 as a subtask of task 1, it would actually + * create two relations. One from Task 2 -> Task 1 with relation kind subtask and one from Task 1 -> Task 2 + * with relation kind parent task. + * When I now want to have all relations task 1 is a part of, I just ask "Give me all relations where + * task_id = 1". + */ + +func (rk RelationKind) isValid() bool { + return rk == RelationKindSubtask || + rk == RelationKindParenttask || + rk == RelationKindRelated || + rk == RelationKindDuplicateOf || + rk == RelationKindDuplicates || + rk == RelationKindBlocked || + rk == RelationKindBlocking || + rk == RelationKindPreceeds || + rk == RelationKindFollows || + rk == RelationKindCopiedFrom || + rk == RelationKindCopiedTo +} + +// TaskRelation represents a kind of relation between two tasks +type TaskRelation struct { + // The unique, numeric id of this relation. + ID int64 `xorm:"int(11) autoincr not null unique pk" json:"-"` + // The ID of the "base" task, the task which has a relation to another. + TaskID int64 `xorm:"int(11) not null" json:"task_id" param:"task"` + // The ID of the other task, the task which is being related. + OtherTaskID int64 `xorm:"int(11) not null" json:"other_task_id"` + // The kind of the relation. + RelationKind RelationKind `xorm:"varchar(50) not null" json:"relation_kind"` + + CreatedByID int64 `xorm:"int(11) not null" json:"-"` + // The user who created this relation + CreatedBy *User `xorm:"-" json:"created_by"` + + // A unix timestamp when this label was created. You cannot change this value. + Created int64 `xorm:"created not null" json:"created"` + + web.CRUDable `xorm:"-" json:"-"` + web.Rights `xorm:"-" json:"-"` +} + +// TableName holds the table name for the task relation table +func (TaskRelation) TableName() string { + return "task_relations" +} + +// RelatedTaskMap holds all relations of a single task, grouped by relation kind. +// This avoids the need for an extra type TaskWithRelation (or similar). +type RelatedTaskMap map[RelationKind][]*Task + +// Create creates a new task relation +// @Summary Create a new relation between two tasks +// @Description Creates a new relation between two tasks. The user needs to have update rights on the base task and at least read rights on the other task. Both tasks do not need to be on the same list. Take a look at the docs for available task relation kinds. +// @tags task +// @Accept json +// @Produce json +// @Security JWTKeyAuth +// @Param relation body models.TaskRelation true "The relation object" +// @Param taskID path int true "Task ID" +// @Success 200 {object} models.TaskRelation "The created task relation object." +// @Failure 400 {object} code.vikunja.io/web.HTTPError "Invalid task relation object provided." +// @Failure 500 {object} models.Message "Internal error" +// @Router /tasks/{taskID}/relations [put] +func (rel *TaskRelation) Create(a web.Auth) error { + + // Check if both tasks are the same + if rel.TaskID == rel.OtherTaskID { + return ErrRelationTasksCannotBeTheSame{ + TaskID: rel.TaskID, + OtherTaskID: rel.OtherTaskID, + } + } + + // Check if the relation already exists, in one form or the other. + exists, err := x. + Where("(task_id = ? AND other_task_id = ? AND relation_kind = ?) OR (task_id = ? AND other_task_id = ? AND relation_kind = ?)", + rel.TaskID, rel.OtherTaskID, rel.RelationKind, rel.TaskID, rel.OtherTaskID, rel.RelationKind). + Exist(rel) + if err != nil { + return err + } + if exists { + return ErrRelationAlreadyExists{ + TaskID: rel.TaskID, + OtherTaskID: rel.OtherTaskID, + Kind: rel.RelationKind, + } + } + + rel.CreatedByID = a.GetID() + + // Build up the other relation (see the comment above for explanation) + otherRelation := &TaskRelation{ + TaskID: rel.OtherTaskID, + OtherTaskID: rel.TaskID, + CreatedByID: a.GetID(), + } + + switch rel.RelationKind { + case RelationKindSubtask: + otherRelation.RelationKind = RelationKindParenttask + case RelationKindParenttask: + otherRelation.RelationKind = RelationKindSubtask + case RelationKindRelated: + otherRelation.RelationKind = RelationKindRelated + case RelationKindDuplicateOf: + otherRelation.RelationKind = RelationKindDuplicates + case RelationKindDuplicates: + otherRelation.RelationKind = RelationKindDuplicateOf + case RelationKindBlocking: + otherRelation.RelationKind = RelationKindBlocked + case RelationKindBlocked: + otherRelation.RelationKind = RelationKindBlocking + case RelationKindPreceeds: + otherRelation.RelationKind = RelationKindFollows + case RelationKindFollows: + otherRelation.RelationKind = RelationKindPreceeds + case RelationKindCopiedFrom: + otherRelation.RelationKind = RelationKindCopiedTo + case RelationKindCopiedTo: + otherRelation.RelationKind = RelationKindCopiedFrom + } + + // Finally insert everything + _, err = x.Insert(&[]*TaskRelation{ + rel, + otherRelation, + }) + return err +} + +// Delete removes a task relation +// @Summary Remove a task relation +// @tags task +// @Accept json +// @Produce json +// @Security JWTKeyAuth +// @Param relation body models.TaskRelation true "The relation object" +// @Param taskID path int true "Task ID" +// @Success 200 {object} models.Message "The task relation was successfully deleted." +// @Failure 400 {object} code.vikunja.io/web.HTTPError "Invalid task relation object provided." +// @Failure 404 {object} code.vikunja.io/web.HTTPError "The task relation was not found." +// @Failure 500 {object} models.Message "Internal error" +// @Router /tasks/{taskID}/relations [delete] +func (rel *TaskRelation) Delete() error { + // Check if the relation exists + exists, err := x. + Cols("task_id", "other_task_id", "relation_kind"). + Get(rel) + if err != nil { + return err + } + if !exists { + return ErrRelationDoesNotExist{ + TaskID: rel.TaskID, + OtherTaskID: rel.OtherTaskID, + Kind: rel.RelationKind, + } + } + + _, err = x.Delete(rel) + return err +} diff --git a/pkg/models/task_relation_rights.go b/pkg/models/task_relation_rights.go new file mode 100644 index 00000000000..989df77eec8 --- /dev/null +++ b/pkg/models/task_relation_rights.go @@ -0,0 +1,50 @@ +// Copyright 2019 Vikunja and contriubtors. All rights reserved. +// +// This file is part of Vikunja. +// +// Vikunja 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. +// +// Vikunja 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 Vikunja. If not, see . + +package models + +import "code.vikunja.io/web" + +// CanDelete checks if a user can delete a task relation +func (rel *TaskRelation) CanDelete(a web.Auth) (bool, error) { + // A user can delete a relation if it can update the base task + baseTask := &Task{ID: rel.TaskID} + return baseTask.CanUpdate(a) +} + +// CanCreate checks if a user can create a new relation between two relations +func (rel *TaskRelation) CanCreate(a web.Auth) (bool, error) { + // Check if the relation kind is valid + if !rel.RelationKind.isValid() { + return false, ErrInvalidRelationKind{Kind: rel.RelationKind} + } + + // Needs have write access to the base task and at least read access to the other task + baseTask := &Task{ID: rel.TaskID} + has, err := baseTask.CanUpdate(a) + if err != nil || !has { + return false, err + } + + // We explicitly don't check if the two tasks are on the same list. + otherTask := &Task{ID: rel.OtherTaskID} + has, err = otherTask.CanRead(a) + if err != nil { + return false, err + } + return has, nil +} diff --git a/pkg/models/task_relation_test.go b/pkg/models/task_relation_test.go new file mode 100644 index 00000000000..44a676e0efa --- /dev/null +++ b/pkg/models/task_relation_test.go @@ -0,0 +1,160 @@ +// Copyright 2019 Vikunja and contriubtors. All rights reserved. +// +// This file is part of Vikunja. +// +// Vikunja 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. +// +// Vikunja 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 Vikunja. If not, see . + +package models + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestTaskRelation_Create(t *testing.T) { + t.Run("Normal", func(t *testing.T) { + rel := TaskRelation{ + TaskID: 1, + OtherTaskID: 2, + RelationKind: RelationKindSubtask, + } + err := rel.Create(&User{ID: 1}) + assert.NoError(t, err) + }) + t.Run("Two Tasks In Different Lists", func(t *testing.T) { + rel := TaskRelation{ + TaskID: 1, + OtherTaskID: 13, + RelationKind: RelationKindSubtask, + } + err := rel.Create(&User{ID: 1}) + assert.NoError(t, err) + }) + t.Run("Already Existing", func(t *testing.T) { + rel := TaskRelation{ + TaskID: 1, + OtherTaskID: 29, + RelationKind: RelationKindSubtask, + } + err := rel.Create(&User{ID: 1}) + assert.Error(t, err) + assert.True(t, IsErrRelationAlreadyExists(err)) + }) + t.Run("Same Task", func(t *testing.T) { + rel := TaskRelation{ + TaskID: 1, + OtherTaskID: 1, + } + err := rel.Create(&User{ID: 1}) + assert.Error(t, err) + assert.True(t, IsErrRelationTasksCannotBeTheSame(err)) + }) +} + +func TestTaskRelation_Delete(t *testing.T) { + t.Run("Normal", func(t *testing.T) { + rel := TaskRelation{ + TaskID: 1, + OtherTaskID: 29, + RelationKind: RelationKindSubtask, + } + err := rel.Delete() + assert.NoError(t, err) + }) + t.Run("Not existing", func(t *testing.T) { + rel := TaskRelation{ + TaskID: 9999, + OtherTaskID: 3, + RelationKind: RelationKindSubtask, + } + err := rel.Delete() + assert.Error(t, err) + assert.True(t, IsErrRelationDoesNotExist(err)) + }) +} + +func TestTaskRelation_CanCreate(t *testing.T) { + t.Run("Normal", func(t *testing.T) { + rel := TaskRelation{ + TaskID: 1, + OtherTaskID: 2, + RelationKind: RelationKindSubtask, + } + can, err := rel.CanCreate(&User{ID: 1}) + assert.NoError(t, err) + assert.True(t, can) + }) + t.Run("Two tasks on different lists", func(t *testing.T) { + rel := TaskRelation{ + TaskID: 1, + OtherTaskID: 13, + RelationKind: RelationKindSubtask, + } + can, err := rel.CanCreate(&User{ID: 1}) + assert.NoError(t, err) + assert.True(t, can) + }) + t.Run("No update rights on base task", func(t *testing.T) { + rel := TaskRelation{ + TaskID: 14, + OtherTaskID: 1, + RelationKind: RelationKindSubtask, + } + can, err := rel.CanCreate(&User{ID: 1}) + assert.NoError(t, err) + assert.False(t, can) + }) + t.Run("No update rights on base task, but read rights", func(t *testing.T) { + rel := TaskRelation{ + TaskID: 15, + OtherTaskID: 1, + RelationKind: RelationKindSubtask, + } + can, err := rel.CanCreate(&User{ID: 1}) + assert.NoError(t, err) + assert.False(t, can) + }) + t.Run("No read rights on other task", func(t *testing.T) { + rel := TaskRelation{ + TaskID: 1, + OtherTaskID: 14, + RelationKind: RelationKindSubtask, + } + can, err := rel.CanCreate(&User{ID: 1}) + assert.NoError(t, err) + assert.False(t, can) + }) + t.Run("Nonexisting base task", func(t *testing.T) { + rel := TaskRelation{ + TaskID: 999999, + OtherTaskID: 1, + RelationKind: RelationKindSubtask, + } + can, err := rel.CanCreate(&User{ID: 1}) + assert.Error(t, err) + assert.True(t, IsErrTaskDoesNotExist(err)) + assert.False(t, can) + }) + t.Run("Nonexisting other task", func(t *testing.T) { + rel := TaskRelation{ + TaskID: 1, + OtherTaskID: 999999, + RelationKind: RelationKindSubtask, + } + can, err := rel.CanCreate(&User{ID: 1}) + assert.Error(t, err) + assert.True(t, IsErrTaskDoesNotExist(err)) + assert.False(t, can) + }) +} diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index ade9767937e..491363ebb54 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -46,8 +46,6 @@ type Task struct { ListID int64 `xorm:"int(11) INDEX not null" json:"listID" param:"list"` // An amount in seconds this task repeats itself. If this is set, when marking the task as done, it will mark itself as "undone" and then increase all remindes and the due date by its amount. RepeatAfter int64 `xorm:"int(11) INDEX null" json:"repeatAfter"` - // If the task is a subtask, this is the id of its parent. - ParentTaskID int64 `xorm:"int(11) INDEX null" json:"parentTaskID"` // The task priority. Can be anything you want, it is possible to sort by this later. Priority int64 `xorm:"int(11) null" json:"priority"` // When this task starts. @@ -70,8 +68,8 @@ type Task struct { StartDateSortUnix int64 `xorm:"-" json:"-" query:"startdate"` EndDateSortUnix int64 `xorm:"-" json:"-" query:"enddate"` - // An array of subtasks. - Subtasks []*Task `xorm:"-" json:"subtasks"` + // All related tasks, grouped by their relation kind + RelatedTasks RelatedTaskMap `xorm:"-" json:"related_tasks"` // A unix timestamp when this task was created. You cannot change this value. Created int64 `xorm:"created not null" json:"created"` @@ -221,7 +219,6 @@ func getTasksForLists(lists []*List, opts *taskOptions) (tasks []*Task, err erro And("((due_date_unix BETWEEN ? AND ?) OR "+ "(start_date_unix BETWEEN ? and ?) OR "+ "(end_date_unix BETWEEN ? and ?))", startDateUnix, endDateUnix, startDateUnix, endDateUnix, startDateUnix, endDateUnix). - And("(parent_task_id = 0 OR parent_task_id IS NULL)"). OrderBy(orderby). Find(&taskMap); err != nil { return nil, err @@ -229,7 +226,6 @@ func getTasksForLists(lists []*List, opts *taskOptions) (tasks []*Task, err erro } else { if err := x.In("list_id", listIDs). Where("text LIKE ?", "%"+opts.search+"%"). - And("(parent_task_id = 0 OR parent_task_id IS NULL)"). OrderBy(orderby). Find(&taskMap); err != nil { return nil, err @@ -442,16 +438,38 @@ func addMoreInfoToTasks(taskMap map[int64]*Task) (tasks []*Task, err error) { for _, task := range taskMap { // Make created by user objects - taskMap[task.ID].CreatedBy = users[task.CreatedByID] + task.CreatedBy = users[task.CreatedByID] // Add the reminders - taskMap[task.ID].RemindersUnix = taskRemindersUnix[task.ID] + task.RemindersUnix = taskRemindersUnix[task.ID] - // Reorder all subtasks - if task.ParentTaskID != 0 { - taskMap[task.ParentTaskID].Subtasks = append(taskMap[task.ParentTaskID].Subtasks, task) - delete(taskMap, task.ID) - } + // Prepare the subtasks + task.RelatedTasks = make(RelatedTaskMap) + } + + // Get all related tasks + relatedTasks := []*TaskRelation{} + err = x.In("task_id", taskIDs).Find(&relatedTasks) + if err != nil { + return + } + + // Collect all related task IDs, so we can get all related task headers in one go + var relatedTaskIDs []int64 + for _, rt := range relatedTasks { + relatedTaskIDs = append(relatedTaskIDs, rt.OtherTaskID) + } + fullRelatedTasks := make(map[int64]*Task) + err = x.In("id", relatedTaskIDs).Find(&fullRelatedTasks) + if err != nil { + return + } + + // NOTE: while it certainly be possible to run this function on fullRelatedTasks again, we don't do this for performance reasons. + + // Go through all task relations and put them into the task objects + for _, rt := range relatedTasks { + taskMap[rt.TaskID].RelatedTasks[rt.RelationKind] = append(taskMap[rt.TaskID].RelatedTasks[rt.RelationKind], fullRelatedTasks[rt.OtherTaskID]) } // make a complete slice from the map @@ -552,11 +570,6 @@ func (t *Task) Update() (err error) { return } - // Parent task cannot be the same as the current task - if t.ID == t.ParentTaskID { - return ErrParentTaskCannotBeTheSame{TaskID: t.ID} - } - // When a repeating task is marked as done, we update all deadlines and reminders and set it as undone updateDone(&ot, t) @@ -618,10 +631,6 @@ func (t *Task) Update() (err error) { if t.RepeatAfter == 0 { ot.RepeatAfter = 0 } - // Parent task - if t.ParentTaskID == 0 { - ot.ParentTaskID = 0 - } // Start date if t.StartDateUnix == 0 { ot.StartDateUnix = 0 @@ -645,7 +654,6 @@ func (t *Task) Update() (err error) { "done", "due_date_unix", "repeat_after", - "parent_task_id", "priority", "start_date_unix", "end_date_unix", diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index a37912dbde1..30f40f22415 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -258,6 +258,14 @@ func registerAPIRoutes(a *echo.Group) { } a.POST("/tasks/:listtask/labels/bulk", bulkLabelTaskHandler.CreateWeb) + taskRelationHandler := &handler.WebHandler{ + EmptyStruct: func() handler.CObject { + return &models.TaskRelation{} + }, + } + a.PUT("/tasks/:task/relations", taskRelationHandler.CreateWeb) + a.DELETE("/tasks/:task/relations", taskRelationHandler.DeleteWeb) + labelHandler := &handler.WebHandler{ EmptyStruct: func() handler.CObject { return &models.Label{} diff --git a/pkg/swagger/docs.go b/pkg/swagger/docs.go index 11c553cb4c5..9da8dd62a89 100644 --- a/pkg/swagger/docs.go +++ b/pkg/swagger/docs.go @@ -1,6 +1,6 @@ // GENERATED BY THE COMMAND ABOVE; DO NOT EDIT // This file was generated by swaggo/swag at -// 2019-09-20 19:04:01.162535903 +0200 CEST m=+0.097033344 +// 2019-09-25 20:06:34.002904022 +0200 CEST m=+0.196783505 package swagger @@ -412,7 +412,7 @@ var doc = `{ "JWTKeyAuth": [] } ], - "description": "Returns a team by its ID.", + "description": "Returns a list by its ID.", "consumes": [ "application/json" ], @@ -420,13 +420,13 @@ var doc = `{ "application/json" ], "tags": [ - "team" + "list" ], - "summary": "Gets one team", + "summary": "Gets one list", "parameters": [ { "type": "integer", - "description": "Team ID", + "description": "List ID", "name": "id", "in": "path", "required": true @@ -434,14 +434,14 @@ var doc = `{ ], "responses": { "200": { - "description": "The team", + "description": "The list", "schema": { "type": "object", - "$ref": "#/definitions/models.Team" + "$ref": "#/definitions/models.List" } }, "403": { - "description": "The user does not have access to the team", + "description": "The user does not have access to the list", "schema": { "type": "object", "$ref": "#/definitions/code.vikunja.io.web.HTTPError" @@ -3204,6 +3204,134 @@ var doc = `{ } } }, + "/tasks/{taskID}/relations": { + "put": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Creates a new relation between two tasks. The user needs to have update rights on the base task and at least read rights on the other task. Both tasks do not need to be on the same list.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "task" + ], + "summary": "Create a new relation between two tasks", + "parameters": [ + { + "description": "The relation object", + "name": "relation", + "in": "body", + "required": true, + "schema": { + "type": "object", + "$ref": "#/definitions/models.TaskRelation" + } + }, + { + "type": "integer", + "description": "Task ID", + "name": "taskID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "The created task relation object.", + "schema": { + "type": "object", + "$ref": "#/definitions/models.TaskRelation" + } + }, + "400": { + "description": "Invalid task relation object provided.", + "schema": { + "type": "object", + "$ref": "#/definitions/code.vikunja.io.web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "type": "object", + "$ref": "#/definitions/models.Message" + } + } + } + }, + "delete": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "task" + ], + "summary": "Remove a task relation", + "parameters": [ + { + "description": "The relation object", + "name": "relation", + "in": "body", + "required": true, + "schema": { + "type": "object", + "$ref": "#/definitions/models.TaskRelation" + } + }, + { + "type": "integer", + "description": "Task ID", + "name": "taskID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "The task relation was successfully deleted.", + "schema": { + "type": "object", + "$ref": "#/definitions/models.Message" + } + }, + "400": { + "description": "Invalid task relation object provided.", + "schema": { + "type": "object", + "$ref": "#/definitions/code.vikunja.io.web.HTTPError" + } + }, + "404": { + "description": "The task relation was not found.", + "schema": { + "type": "object", + "$ref": "#/definitions/code.vikunja.io.web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "type": "object", + "$ref": "#/definitions/models.Message" + } + } + } + } + }, "/tasks/{task}/labels": { "get": { "security": [ @@ -4143,10 +4271,6 @@ var doc = `{ "description": "The list this task belongs to.", "type": "integer" }, - "parentTaskID": { - "description": "If the task is a subtask, this is the id of its parent.", - "type": "integer" - }, "percentDone": { "description": "Determines how far a task is left from being done", "type": "number" @@ -4155,6 +4279,11 @@ var doc = `{ "description": "The task priority. Can be anything you want, it is possible to sort by this later.", "type": "integer" }, + "related_tasks": { + "description": "All related tasks, grouped by their relation kind", + "type": "object", + "$ref": "#/definitions/models.RelatedTaskMap" + }, "reminderDates": { "description": "An array of unix timestamps when the user wants to be reminded of the task.", "type": "array", @@ -4170,13 +4299,6 @@ var doc = `{ "description": "When this task starts.", "type": "integer" }, - "subtasks": { - "description": "An array of subtasks.", - "type": "array", - "items": { - "$ref": "#/definitions/models.Task" - } - }, "task_ids": { "description": "A list of task ids to update", "type": "array", @@ -4499,6 +4621,111 @@ var doc = `{ } } }, + "models.RelatedTaskMap": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "assignees": { + "description": "An array of users who are assigned to this task", + "type": "array", + "items": { + "$ref": "#/definitions/models.User" + } + }, + "created": { + "description": "A unix timestamp when this task was created. You cannot change this value.", + "type": "integer" + }, + "createdBy": { + "description": "The user who initially created the task.", + "type": "object", + "$ref": "#/definitions/models.User" + }, + "description": { + "description": "The task description.", + "type": "string" + }, + "done": { + "description": "Whether a task is done or not.", + "type": "boolean" + }, + "doneAt": { + "description": "The unix timestamp when a task was marked as done.", + "type": "integer" + }, + "dueDate": { + "description": "A unix timestamp when the task is due.", + "type": "integer" + }, + "endDate": { + "description": "When this task ends.", + "type": "integer" + }, + "hexColor": { + "description": "The task color in hex", + "type": "string", + "maxLength": 6 + }, + "id": { + "description": "The unique, numeric id of this task.", + "type": "integer" + }, + "labels": { + "description": "An array of labels which are associated with this task.", + "type": "array", + "items": { + "$ref": "#/definitions/models.Label" + } + }, + "listID": { + "description": "The list this task belongs to.", + "type": "integer" + }, + "percentDone": { + "description": "Determines how far a task is left from being done", + "type": "number" + }, + "priority": { + "description": "The task priority. Can be anything you want, it is possible to sort by this later.", + "type": "integer" + }, + "related_tasks": { + "description": "All related tasks, grouped by their relation kind", + "type": "object", + "$ref": "#/definitions/models.RelatedTaskMap" + }, + "reminderDates": { + "description": "An array of unix timestamps when the user wants to be reminded of the task.", + "type": "array", + "items": { + "type": "integer" + } + }, + "repeatAfter": { + "description": "An amount in seconds this task repeats itself. If this is set, when marking the task as done, it will mark itself as \"undone\" and then increase all remindes and the due date by its amount.", + "type": "integer" + }, + "startDate": { + "description": "When this task starts.", + "type": "integer" + }, + "text": { + "description": "The task text. This is what you'll see in the list.", + "type": "string", + "maxLength": 250, + "minLength": 3 + }, + "updated": { + "description": "A unix timestamp when this task was last updated. You cannot change this value.", + "type": "integer" + } + } + } + } + }, "models.Task": { "type": "object", "properties": { @@ -4558,10 +4785,6 @@ var doc = `{ "description": "The list this task belongs to.", "type": "integer" }, - "parentTaskID": { - "description": "If the task is a subtask, this is the id of its parent.", - "type": "integer" - }, "percentDone": { "description": "Determines how far a task is left from being done", "type": "number" @@ -4570,6 +4793,11 @@ var doc = `{ "description": "The task priority. Can be anything you want, it is possible to sort by this later.", "type": "integer" }, + "related_tasks": { + "description": "All related tasks, grouped by their relation kind", + "type": "object", + "$ref": "#/definitions/models.RelatedTaskMap" + }, "reminderDates": { "description": "An array of unix timestamps when the user wants to be reminded of the task.", "type": "array", @@ -4585,13 +4813,6 @@ var doc = `{ "description": "When this task starts.", "type": "integer" }, - "subtasks": { - "description": "An array of subtasks.", - "type": "array", - "items": { - "$ref": "#/definitions/models.Task" - } - }, "text": { "description": "The task text. This is what you'll see in the list.", "type": "string", @@ -4615,6 +4836,32 @@ var doc = `{ } } }, + "models.TaskRelation": { + "type": "object", + "properties": { + "created": { + "description": "A unix timestamp when this label was created. You cannot change this value.", + "type": "integer" + }, + "created_by": { + "description": "The user who created this relation", + "type": "object", + "$ref": "#/definitions/models.User" + }, + "other_task_id": { + "description": "The ID of the other task, the task which is being related.", + "type": "integer" + }, + "relation_kind": { + "description": "The kind of the relation.", + "type": "string" + }, + "task_id": { + "description": "The ID of the \"base\" task, the task which has a relation to another.", + "type": "integer" + } + } + }, "models.Team": { "type": "object", "properties": { diff --git a/pkg/swagger/swagger.json b/pkg/swagger/swagger.json index ab8495da1dc..c361f115c32 100644 --- a/pkg/swagger/swagger.json +++ b/pkg/swagger/swagger.json @@ -399,7 +399,7 @@ "JWTKeyAuth": [] } ], - "description": "Returns a team by its ID.", + "description": "Returns a list by its ID.", "consumes": [ "application/json" ], @@ -407,13 +407,13 @@ "application/json" ], "tags": [ - "team" + "list" ], - "summary": "Gets one team", + "summary": "Gets one list", "parameters": [ { "type": "integer", - "description": "Team ID", + "description": "List ID", "name": "id", "in": "path", "required": true @@ -421,14 +421,14 @@ ], "responses": { "200": { - "description": "The team", + "description": "The list", "schema": { "type": "object", - "$ref": "#/definitions/models.Team" + "$ref": "#/definitions/models.List" } }, "403": { - "description": "The user does not have access to the team", + "description": "The user does not have access to the list", "schema": { "type": "object", "$ref": "#/definitions/code.vikunja.io/web.HTTPError" @@ -3191,6 +3191,134 @@ } } }, + "/tasks/{taskID}/relations": { + "put": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Creates a new relation between two tasks. The user needs to have update rights on the base task and at least read rights on the other task. Both tasks do not need to be on the same list.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "task" + ], + "summary": "Create a new relation between two tasks", + "parameters": [ + { + "description": "The relation object", + "name": "relation", + "in": "body", + "required": true, + "schema": { + "type": "object", + "$ref": "#/definitions/models.TaskRelation" + } + }, + { + "type": "integer", + "description": "Task ID", + "name": "taskID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "The created task relation object.", + "schema": { + "type": "object", + "$ref": "#/definitions/models.TaskRelation" + } + }, + "400": { + "description": "Invalid task relation object provided.", + "schema": { + "type": "object", + "$ref": "#/definitions/code.vikunja.io/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "type": "object", + "$ref": "#/definitions/models.Message" + } + } + } + }, + "delete": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "task" + ], + "summary": "Remove a task relation", + "parameters": [ + { + "description": "The relation object", + "name": "relation", + "in": "body", + "required": true, + "schema": { + "type": "object", + "$ref": "#/definitions/models.TaskRelation" + } + }, + { + "type": "integer", + "description": "Task ID", + "name": "taskID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "The task relation was successfully deleted.", + "schema": { + "type": "object", + "$ref": "#/definitions/models.Message" + } + }, + "400": { + "description": "Invalid task relation object provided.", + "schema": { + "type": "object", + "$ref": "#/definitions/code.vikunja.io/web.HTTPError" + } + }, + "404": { + "description": "The task relation was not found.", + "schema": { + "type": "object", + "$ref": "#/definitions/code.vikunja.io/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "type": "object", + "$ref": "#/definitions/models.Message" + } + } + } + } + }, "/tasks/{task}/labels": { "get": { "security": [ @@ -4129,10 +4257,6 @@ "description": "The list this task belongs to.", "type": "integer" }, - "parentTaskID": { - "description": "If the task is a subtask, this is the id of its parent.", - "type": "integer" - }, "percentDone": { "description": "Determines how far a task is left from being done", "type": "number" @@ -4141,6 +4265,11 @@ "description": "The task priority. Can be anything you want, it is possible to sort by this later.", "type": "integer" }, + "related_tasks": { + "description": "All related tasks, grouped by their relation kind", + "type": "object", + "$ref": "#/definitions/models.RelatedTaskMap" + }, "reminderDates": { "description": "An array of unix timestamps when the user wants to be reminded of the task.", "type": "array", @@ -4156,13 +4285,6 @@ "description": "When this task starts.", "type": "integer" }, - "subtasks": { - "description": "An array of subtasks.", - "type": "array", - "items": { - "$ref": "#/definitions/models.Task" - } - }, "task_ids": { "description": "A list of task ids to update", "type": "array", @@ -4485,6 +4607,111 @@ } } }, + "models.RelatedTaskMap": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "assignees": { + "description": "An array of users who are assigned to this task", + "type": "array", + "items": { + "$ref": "#/definitions/models.User" + } + }, + "created": { + "description": "A unix timestamp when this task was created. You cannot change this value.", + "type": "integer" + }, + "createdBy": { + "description": "The user who initially created the task.", + "type": "object", + "$ref": "#/definitions/models.User" + }, + "description": { + "description": "The task description.", + "type": "string" + }, + "done": { + "description": "Whether a task is done or not.", + "type": "boolean" + }, + "doneAt": { + "description": "The unix timestamp when a task was marked as done.", + "type": "integer" + }, + "dueDate": { + "description": "A unix timestamp when the task is due.", + "type": "integer" + }, + "endDate": { + "description": "When this task ends.", + "type": "integer" + }, + "hexColor": { + "description": "The task color in hex", + "type": "string", + "maxLength": 6 + }, + "id": { + "description": "The unique, numeric id of this task.", + "type": "integer" + }, + "labels": { + "description": "An array of labels which are associated with this task.", + "type": "array", + "items": { + "$ref": "#/definitions/models.Label" + } + }, + "listID": { + "description": "The list this task belongs to.", + "type": "integer" + }, + "percentDone": { + "description": "Determines how far a task is left from being done", + "type": "number" + }, + "priority": { + "description": "The task priority. Can be anything you want, it is possible to sort by this later.", + "type": "integer" + }, + "related_tasks": { + "description": "All related tasks, grouped by their relation kind", + "type": "object", + "$ref": "#/definitions/models.RelatedTaskMap" + }, + "reminderDates": { + "description": "An array of unix timestamps when the user wants to be reminded of the task.", + "type": "array", + "items": { + "type": "integer" + } + }, + "repeatAfter": { + "description": "An amount in seconds this task repeats itself. If this is set, when marking the task as done, it will mark itself as \"undone\" and then increase all remindes and the due date by its amount.", + "type": "integer" + }, + "startDate": { + "description": "When this task starts.", + "type": "integer" + }, + "text": { + "description": "The task text. This is what you'll see in the list.", + "type": "string", + "maxLength": 250, + "minLength": 3 + }, + "updated": { + "description": "A unix timestamp when this task was last updated. You cannot change this value.", + "type": "integer" + } + } + } + } + }, "models.Task": { "type": "object", "properties": { @@ -4544,10 +4771,6 @@ "description": "The list this task belongs to.", "type": "integer" }, - "parentTaskID": { - "description": "If the task is a subtask, this is the id of its parent.", - "type": "integer" - }, "percentDone": { "description": "Determines how far a task is left from being done", "type": "number" @@ -4556,6 +4779,11 @@ "description": "The task priority. Can be anything you want, it is possible to sort by this later.", "type": "integer" }, + "related_tasks": { + "description": "All related tasks, grouped by their relation kind", + "type": "object", + "$ref": "#/definitions/models.RelatedTaskMap" + }, "reminderDates": { "description": "An array of unix timestamps when the user wants to be reminded of the task.", "type": "array", @@ -4571,13 +4799,6 @@ "description": "When this task starts.", "type": "integer" }, - "subtasks": { - "description": "An array of subtasks.", - "type": "array", - "items": { - "$ref": "#/definitions/models.Task" - } - }, "text": { "description": "The task text. This is what you'll see in the list.", "type": "string", @@ -4601,6 +4822,32 @@ } } }, + "models.TaskRelation": { + "type": "object", + "properties": { + "created": { + "description": "A unix timestamp when this label was created. You cannot change this value.", + "type": "integer" + }, + "created_by": { + "description": "The user who created this relation", + "type": "object", + "$ref": "#/definitions/models.User" + }, + "other_task_id": { + "description": "The ID of the other task, the task which is being related.", + "type": "integer" + }, + "relation_kind": { + "description": "The kind of the relation.", + "type": "string" + }, + "task_id": { + "description": "The ID of the \"base\" task, the task which has a relation to another.", + "type": "integer" + } + } + }, "models.Team": { "type": "object", "properties": { diff --git a/pkg/swagger/swagger.yaml b/pkg/swagger/swagger.yaml index 25fa9235d92..109d88c4a52 100644 --- a/pkg/swagger/swagger.yaml +++ b/pkg/swagger/swagger.yaml @@ -74,9 +74,6 @@ definitions: listID: description: The list this task belongs to. type: integer - parentTaskID: - description: If the task is a subtask, this is the id of its parent. - type: integer percentDone: description: Determines how far a task is left from being done type: number @@ -84,6 +81,10 @@ definitions: description: The task priority. Can be anything you want, it is possible to sort by this later. type: integer + related_tasks: + $ref: '#/definitions/models.RelatedTaskMap' + description: All related tasks, grouped by their relation kind + type: object reminderDates: description: An array of unix timestamps when the user wants to be reminded of the task. @@ -98,11 +99,6 @@ definitions: startDate: description: When this task starts. type: integer - subtasks: - description: An array of subtasks. - items: - $ref: '#/definitions/models.Task' - type: array task_ids: description: A list of task ids to update items: @@ -360,6 +356,90 @@ definitions: maxLength: 250 type: string type: object + models.RelatedTaskMap: + additionalProperties: + items: + properties: + assignees: + description: An array of users who are assigned to this task + items: + $ref: '#/definitions/models.User' + type: array + created: + description: A unix timestamp when this task was created. You cannot change + this value. + type: integer + createdBy: + $ref: '#/definitions/models.User' + description: The user who initially created the task. + type: object + description: + description: The task description. + type: string + done: + description: Whether a task is done or not. + type: boolean + doneAt: + description: The unix timestamp when a task was marked as done. + type: integer + dueDate: + description: A unix timestamp when the task is due. + type: integer + endDate: + description: When this task ends. + type: integer + hexColor: + description: The task color in hex + maxLength: 6 + type: string + id: + description: The unique, numeric id of this task. + type: integer + labels: + description: An array of labels which are associated with this task. + items: + $ref: '#/definitions/models.Label' + type: array + listID: + description: The list this task belongs to. + type: integer + percentDone: + description: Determines how far a task is left from being done + type: number + priority: + description: The task priority. Can be anything you want, it is possible + to sort by this later. + type: integer + related_tasks: + $ref: '#/definitions/models.RelatedTaskMap' + description: All related tasks, grouped by their relation kind + type: object + reminderDates: + description: An array of unix timestamps when the user wants to be reminded + of the task. + items: + type: integer + type: array + repeatAfter: + description: An amount in seconds this task repeats itself. If this is + set, when marking the task as done, it will mark itself as "undone" + and then increase all remindes and the due date by its amount. + type: integer + startDate: + description: When this task starts. + type: integer + text: + description: The task text. This is what you'll see in the list. + maxLength: 250 + minLength: 3 + type: string + updated: + description: A unix timestamp when this task was last updated. You cannot + change this value. + type: integer + type: object + type: array + type: object models.Task: properties: assignees: @@ -405,9 +485,6 @@ definitions: listID: description: The list this task belongs to. type: integer - parentTaskID: - description: If the task is a subtask, this is the id of its parent. - type: integer percentDone: description: Determines how far a task is left from being done type: number @@ -415,6 +492,10 @@ definitions: description: The task priority. Can be anything you want, it is possible to sort by this later. type: integer + related_tasks: + $ref: '#/definitions/models.RelatedTaskMap' + description: All related tasks, grouped by their relation kind + type: object reminderDates: description: An array of unix timestamps when the user wants to be reminded of the task. @@ -429,11 +510,6 @@ definitions: startDate: description: When this task starts. type: integer - subtasks: - description: An array of subtasks. - items: - $ref: '#/definitions/models.Task' - type: array text: description: The task text. This is what you'll see in the list. maxLength: 250 @@ -451,6 +527,26 @@ definitions: user_id: type: integer type: object + models.TaskRelation: + properties: + created: + description: A unix timestamp when this label was created. You cannot change + this value. + type: integer + created_by: + $ref: '#/definitions/models.User' + description: The user who created this relation + type: object + other_task_id: + description: The ID of the other task, the task which is being related. + type: integer + relation_kind: + description: The kind of the relation. + type: string + task_id: + description: The ID of the "base" task, the task which has a relation to another. + type: integer + type: object models.Team: properties: created: @@ -1010,9 +1106,9 @@ paths: get: consumes: - application/json - description: Returns a team by its ID. + description: Returns a list by its ID. parameters: - - description: Team ID + - description: List ID in: path name: id required: true @@ -1021,12 +1117,12 @@ paths: - application/json responses: "200": - description: The team + description: The list schema: - $ref: '#/definitions/models.Team' + $ref: '#/definitions/models.List' type: object "403": - description: The user does not have access to the team + description: The user does not have access to the list schema: $ref: '#/definitions/code.vikunja.io/web.HTTPError' type: object @@ -1037,9 +1133,9 @@ paths: type: object security: - JWTKeyAuth: [] - summary: Gets one team + summary: Gets one list tags: - - team + - list post: consumes: - application/json @@ -2911,6 +3007,93 @@ paths: summary: Update all labels on a task. tags: - labels + /tasks/{taskID}/relations: + delete: + consumes: + - application/json + parameters: + - description: The relation object + in: body + name: relation + required: true + schema: + $ref: '#/definitions/models.TaskRelation' + type: object + - description: Task ID + in: path + name: taskID + required: true + type: integer + produces: + - application/json + responses: + "200": + description: The task relation was successfully deleted. + schema: + $ref: '#/definitions/models.Message' + type: object + "400": + description: Invalid task relation object provided. + schema: + $ref: '#/definitions/code.vikunja.io/web.HTTPError' + type: object + "404": + description: The task relation was not found. + schema: + $ref: '#/definitions/code.vikunja.io/web.HTTPError' + type: object + "500": + description: Internal error + schema: + $ref: '#/definitions/models.Message' + type: object + security: + - JWTKeyAuth: [] + summary: Remove a task relation + tags: + - task + put: + consumes: + - application/json + description: Creates a new relation between two tasks. The user needs to have + update rights on the base task and at least read rights on the other task. + Both tasks do not need to be on the same list. + parameters: + - description: The relation object + in: body + name: relation + required: true + schema: + $ref: '#/definitions/models.TaskRelation' + type: object + - description: Task ID + in: path + name: taskID + required: true + type: integer + produces: + - application/json + responses: + "200": + description: The created task relation object. + schema: + $ref: '#/definitions/models.TaskRelation' + type: object + "400": + description: Invalid task relation object provided. + schema: + $ref: '#/definitions/code.vikunja.io/web.HTTPError' + type: object + "500": + description: Internal error + schema: + $ref: '#/definitions/models.Message' + type: object + security: + - JWTKeyAuth: [] + summary: Create a new relation between two tasks + tags: + - task /tasks/all: get: consumes: