1
0

Task Relations (#103)

This commit is contained in:
konrad
2019-09-25 18:44:41 +00:00
committed by Gitea
parent 1272255975
commit 8fe33fd616
20 changed files with 1751 additions and 333 deletions

View File

@ -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").

View File

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

View File

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

View File

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

View File

@ -49,6 +49,7 @@ func GetTables() []interface{} {
&LabelTask{},
&TaskReminder{},
&LinkSharing{},
&TaskRelation{},
}
}

View File

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

216
pkg/models/task_relation.go Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
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
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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)
})
}

View File

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