Add support for archiving lists and namespaces #152
|
@ -34,6 +34,7 @@ This document describes the different errors Vikunja can return.
|
|||
| 3005 | 400 | The list title cannot be empty. |
|
||||
| 3006 | 404 | The list share does not exist. |
|
||||
| 3007 | 400 | A list with this identifier already exists. |
|
||||
| 3008 | 412 | The list is archived and can therefore only be accessed read only. This is also true for all tasks associated with this list. |
|
||||
| 4001 | 400 | The list task text cannot be empty. |
|
||||
| 4002 | 404 | The list task does not exist. |
|
||||
| 4003 | 403 | All bulk editing tasks must belong to the same list. |
|
||||
|
@ -55,6 +56,7 @@ This document describes the different errors Vikunja can return.
|
|||
| 5009 | 403 | The user needs to have namespace read access to perform that action. |
|
||||
| 5010 | 403 | This team does not have access to that namespace. |
|
||||
| 5011 | 409 | This user has already access to that namespace. |
|
||||
| 5012 | 412 | The namespace is archived and can therefore only be accessed read only. |
|
||||
| 6001 | 400 | The team name cannot be emtpy. |
|
||||
| 6002 | 404 | The team does not exist. |
|
||||
| 6004 | 409 | The team already has access to that namespace or list. |
|
||||
|
|
|
@ -6,3 +6,11 @@
|
|||
task_id: 2
|
||||
label_id: 4
|
||||
created: 0
|
||||
- id: 3
|
||||
task_id: 35
|
||||
label_id: 4
|
||||
created: 0
|
||||
- id: 4
|
||||
task_id: 36
|
||||
label_id: 4
|
||||
created: 0
|
||||
|
|
|
@ -180,3 +180,22 @@
|
|||
namespace_id: 15
|
||||
updated: 0
|
||||
created: 0
|
||||
-
|
||||
id: 21
|
||||
title: Test21 archived through namespace
|
||||
description: Lorem Ipsum
|
||||
identifier: test21
|
||||
owner_id: 1
|
||||
namespace_id: 16
|
||||
updated: 0
|
||||
created: 0
|
||||
-
|
||||
id: 22
|
||||
title: Test22 archived individually
|
||||
description: Lorem Ipsum
|
||||
identifier: test22
|
||||
owner_id: 1
|
||||
namespace_id: 1
|
||||
is_archived: 1
|
||||
updated: 0
|
||||
created: 0
|
||||
|
|
|
@ -76,3 +76,9 @@
|
|||
owner_id: 13
|
||||
updated: 0
|
||||
created: 0
|
||||
- id: 16
|
||||
name: Archived testnamespace16
|
||||
owner_id: 1
|
||||
is_archived: 1
|
||||
updated: 0
|
||||
created: 0
|
||||
|
|
|
@ -5,4 +5,12 @@
|
|||
- id: 2
|
||||
task_id: 30
|
||||
user_id: 2
|
||||
created: 0
|
||||
created: 0
|
||||
- id: 3
|
||||
task_id: 35
|
||||
user_id: 2
|
||||
created: 0
|
||||
- id: 4
|
||||
task_id: 36
|
||||
user_id: 2
|
||||
created: 0
|
||||
|
|
|
@ -82,3 +82,15 @@
|
|||
task_id: 26
|
||||
created: 1582135626
|
||||
updated: 1582135626
|
||||
- id: 15
|
||||
comment: comment 15
|
||||
author_id: 1
|
||||
task_id: 35
|
||||
created: 1582135626
|
||||
updated: 1582135626
|
||||
- id: 16
|
||||
comment: comment 16
|
||||
author_id: 1
|
||||
task_id: 36
|
||||
created: 1582135626
|
||||
updated: 1582135626
|
||||
|
|
|
@ -10,3 +10,27 @@
|
|||
relation_kind: 'parenttask'
|
||||
created_by_id: 1
|
||||
created: 0
|
||||
- id: 3
|
||||
task_id: 35
|
||||
other_task_id: 1
|
||||
relation_kind: 'related'
|
||||
created_by_id: 1
|
||||
created: 0
|
||||
- id: 4
|
||||
task_id: 35
|
||||
other_task_id: 1
|
||||
relation_kind: 'related'
|
||||
created_by_id: 1
|
||||
created: 0
|
||||
- id: 5
|
||||
task_id: 36
|
||||
other_task_id: 1
|
||||
relation_kind: 'related'
|
||||
created_by_id: 1
|
||||
created: 0
|
||||
- id: 6
|
||||
task_id: 36
|
||||
other_task_id: 1
|
||||
relation_kind: 'related'
|
||||
created_by_id: 1
|
||||
created: 0
|
||||
|
|
|
@ -251,5 +251,19 @@
|
|||
index: 20
|
||||
created: 1543626724
|
||||
updated: 1543626724
|
||||
- id: 35
|
||||
text: 'task #35'
|
||||
created_by_id: 1
|
||||
list_id: 21
|
||||
index: 1
|
||||
created: 1543626724
|
||||
updated: 1543626724
|
||||
- id: 36
|
||||
text: 'task #36'
|
||||
created_by_id: 1
|
||||
list_id: 22
|
||||
index: 1
|
||||
created: 1543626724
|
||||
updated: 1543626724
|
||||
|
||||
|
||||
|
|
|
@ -0,0 +1,238 @@
|
|||
// Copyright 2020 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 integrations
|
||||
|
||||
import (
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/web/handler"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// This tests the following behaviour:
|
||||
// 1. A namespace should not be editable if it is archived.
|
||||
// 1. With the exception being to un-archive it.
|
||||
// 2. A list which belongs to an archived namespace cannot be edited.
|
||||
// 3. An archived list should not be editable.
|
||||
// 1. Except for un-archiving it.
|
||||
// 4. It is not possible to un-archive a list individually if its namespace is archived.
|
||||
// 5. Creating new lists on an archived namespace should not work.
|
||||
// 6. Creating new tasks on an archived list should not work.
|
||||
// 7. Creating new tasks on a list who's namespace is archived should not work.
|
||||
// 8. Editing tasks on an archived list should not work.
|
||||
// 9. Editing tasks on a list who's namespace is archived should not work.
|
||||
// 10. Archived namespaces should not appear in the list with all namespaces.
|
||||
// 11. Archived lists should not appear in the list with all lists.
|
||||
// 12. Lists who's namespace is archived should not appear in the list with all lists.
|
||||
//
|
||||
// All of this is tested through integration tests because it's not yet clear if this will be implemented directly
|
||||
// or with some kind of middleware.
|
||||
//
|
||||
// Maybe the inheritance of lists from namespaces could be solved with some kind of is_archived_inherited flag -
|
||||
// that way I'd only need to implement the checking on a list level and update the flag for all lists once the
|
||||
// namespace is archived. The archived flag would then be used to not accedentially unarchive lists which were
|
||||
// already individually archived when the namespace was archived.
|
||||
// Should still test it all though.
|
||||
//
|
||||
// Namespace 16 is archived
|
||||
// List 21 belongs to namespace 16
|
||||
// List 22 is archived individually
|
||||
|
||||
func TestArchived(t *testing.T) {
|
||||
testListHandler := webHandlerTest{
|
||||
user: &testuser1,
|
||||
strFunc: func() handler.CObject {
|
||||
return &models.List{}
|
||||
},
|
||||
t: t,
|
||||
}
|
||||
testNamespaceHandler := webHandlerTest{
|
||||
user: &testuser1,
|
||||
strFunc: func() handler.CObject {
|
||||
return &models.Namespace{}
|
||||
},
|
||||
t: t,
|
||||
}
|
||||
testTaskHandler := webHandlerTest{
|
||||
user: &testuser1,
|
||||
strFunc: func() handler.CObject {
|
||||
return &models.Task{}
|
||||
},
|
||||
t: t,
|
||||
}
|
||||
testLabelHandler := webHandlerTest{
|
||||
user: &testuser1,
|
||||
strFunc: func() handler.CObject {
|
||||
return &models.LabelTask{}
|
||||
},
|
||||
t: t,
|
||||
}
|
||||
testAssigneeHandler := webHandlerTest{
|
||||
user: &testuser1,
|
||||
strFunc: func() handler.CObject {
|
||||
return &models.TaskAssginee{}
|
||||
},
|
||||
t: t,
|
||||
}
|
||||
testRelationHandler := webHandlerTest{
|
||||
user: &testuser1,
|
||||
strFunc: func() handler.CObject {
|
||||
return &models.TaskRelation{}
|
||||
},
|
||||
t: t,
|
||||
}
|
||||
testCommentHandler := webHandlerTest{
|
||||
user: &testuser1,
|
||||
strFunc: func() handler.CObject {
|
||||
return &models.TaskComment{}
|
||||
},
|
||||
t: t,
|
||||
}
|
||||
|
||||
t.Run("namespace", func(t *testing.T) {
|
||||
t.Run("not editable", func(t *testing.T) {
|
||||
_, err := testNamespaceHandler.testUpdateWithUser(nil, map[string]string{"namespace": "16"}, `{"name":"TestIpsum","is_archived":true}`)
|
||||
assert.Error(t, err)
|
||||
assertHandlerErrorCode(t, err, models.ErrCodeNamespaceIsArchived)
|
||||
})
|
||||
t.Run("unarchivable", func(t *testing.T) {
|
||||
rec, err := testNamespaceHandler.testUpdateWithUser(nil, map[string]string{"namespace": "16"}, `{"name":"TestIpsum","is_archived":false}`)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, rec.Body.String(), `"is_archived":false`)
|
||||
})
|
||||
t.Run("no new lists", func(t *testing.T) {
|
||||
_, err := testListHandler.testCreateWithUser(nil, map[string]string{"namespace": "16"}, `{"title":"Lorem"}`)
|
||||
assert.Error(t, err)
|
||||
assertHandlerErrorCode(t, err, models.ErrCodeNamespaceIsArchived)
|
||||
})
|
||||
t.Run("should not appear in the list", func(t *testing.T) {
|
||||
rec, err := testNamespaceHandler.testReadAllWithUser(nil, nil)
|
||||
assert.NoError(t, err)
|
||||
assert.NotContains(t, rec.Body.String(), `"name":"Archived testnamespace16"`)
|
||||
})
|
||||
t.Run("should appear in the list if explicitly requested", func(t *testing.T) {
|
||||
rec, err := testNamespaceHandler.testReadAllWithUser(url.Values{"is_archived": []string{"true"}}, nil)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, rec.Body.String(), `"name":"Archived testnamespace16"`)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("list", func(t *testing.T) {
|
||||
|
||||
taskTests := func(taskID string, errCode int, t *testing.T) {
|
||||
t.Run("task", func(t *testing.T) {
|
||||
t.Run("edit task", func(t *testing.T) {
|
||||
_, err := testTaskHandler.testUpdateWithUser(nil, map[string]string{"listtask": taskID}, `{"text":"TestIpsum"}`)
|
||||
assert.Error(t, err)
|
||||
assertHandlerErrorCode(t, err, errCode)
|
||||
})
|
||||
t.Run("delete", func(t *testing.T) {
|
||||
_, err := testTaskHandler.testDeleteWithUser(nil, map[string]string{"listtask": taskID})
|
||||
assert.Error(t, err)
|
||||
assertHandlerErrorCode(t, err, errCode)
|
||||
})
|
||||
t.Run("add new labels", func(t *testing.T) {
|
||||
_, err := testLabelHandler.testCreateWithUser(nil, map[string]string{"listtask": taskID}, `{"label_id":1}`)
|
||||
assert.Error(t, err)
|
||||
assertHandlerErrorCode(t, err, errCode)
|
||||
})
|
||||
t.Run("remove lables", func(t *testing.T) {
|
||||
_, err := testLabelHandler.testDeleteWithUser(nil, map[string]string{"listtask": taskID, "label": "4"})
|
||||
assert.Error(t, err)
|
||||
assertHandlerErrorCode(t, err, errCode)
|
||||
})
|
||||
t.Run("add assignees", func(t *testing.T) {
|
||||
_, err := testAssigneeHandler.testCreateWithUser(nil, map[string]string{"listtask": taskID}, `{"user_id":3}`)
|
||||
assert.Error(t, err)
|
||||
assertHandlerErrorCode(t, err, errCode)
|
||||
})
|
||||
t.Run("remove assignees", func(t *testing.T) {
|
||||
_, err := testAssigneeHandler.testDeleteWithUser(nil, map[string]string{"listtask": taskID, "user": "2"})
|
||||
assert.Error(t, err)
|
||||
assertHandlerErrorCode(t, err, errCode)
|
||||
})
|
||||
t.Run("add relation", func(t *testing.T) {
|
||||
_, err := testRelationHandler.testCreateWithUser(nil, map[string]string{"task": taskID}, `{"other_task_id":1,"relation_kind":"related"}`)
|
||||
assert.Error(t, err)
|
||||
assertHandlerErrorCode(t, err, errCode)
|
||||
})
|
||||
t.Run("remove relation", func(t *testing.T) {
|
||||
_, err := testRelationHandler.testDeleteWithUser(nil, map[string]string{"task": taskID}, `{"other_task_id":2,"relation_kind":"related"}`)
|
||||
assert.Error(t, err)
|
||||
assertHandlerErrorCode(t, err, errCode)
|
||||
})
|
||||
t.Run("add comment", func(t *testing.T) {
|
||||
_, err := testCommentHandler.testCreateWithUser(nil, map[string]string{"task": taskID}, `{"comment":"Lorem"}`)
|
||||
assert.Error(t, err)
|
||||
assertHandlerErrorCode(t, err, errCode)
|
||||
})
|
||||
t.Run("remove comment", func(t *testing.T) {
|
||||
var commentID = "15"
|
||||
if taskID == "36" {
|
||||
commentID = "16"
|
||||
}
|
||||
_, err := testCommentHandler.testDeleteWithUser(nil, map[string]string{"task": taskID, "commentid": commentID})
|
||||
assert.Error(t, err)
|
||||
assertHandlerErrorCode(t, err, errCode)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// The list belongs to an archived namespace
|
||||
t.Run("archived namespace", func(t *testing.T) {
|
||||
t.Run("not editable", func(t *testing.T) {
|
||||
_, err := testListHandler.testUpdateWithUser(nil, map[string]string{"list": "21"}, `{"title":"TestIpsum","is_archived":true}`)
|
||||
assert.Error(t, err)
|
||||
assertHandlerErrorCode(t, err, models.ErrCodeNamespaceIsArchived)
|
||||
})
|
||||
t.Run("no new tasks", func(t *testing.T) {
|
||||
_, err := testTaskHandler.testCreateWithUser(nil, map[string]string{"list": "21"}, `{"text":"Lorem"}`)
|
||||
assert.Error(t, err)
|
||||
assertHandlerErrorCode(t, err, models.ErrCodeNamespaceIsArchived)
|
||||
})
|
||||
t.Run("not unarchivable", func(t *testing.T) {
|
||||
_, err := testListHandler.testUpdateWithUser(nil, map[string]string{"list": "21"}, `{"title":"LoremIpsum","is_archived":false}`)
|
||||
assert.Error(t, err)
|
||||
assertHandlerErrorCode(t, err, models.ErrCodeNamespaceIsArchived)
|
||||
})
|
||||
|
||||
taskTests("35", models.ErrCodeNamespaceIsArchived, t)
|
||||
})
|
||||
// The list itself is archived
|
||||
t.Run("archived individually", func(t *testing.T) {
|
||||
t.Run("not editable", func(t *testing.T) {
|
||||
_, err := testListHandler.testUpdateWithUser(nil, map[string]string{"list": "22"}, `{"title":"TestIpsum","is_archived":true}`)
|
||||
assert.Error(t, err)
|
||||
assertHandlerErrorCode(t, err, models.ErrCodeListIsArchived)
|
||||
})
|
||||
t.Run("no new tasks", func(t *testing.T) {
|
||||
_, err := testTaskHandler.testCreateWithUser(nil, map[string]string{"list": "22"}, `{"text":"Lorem"}`)
|
||||
assert.Error(t, err)
|
||||
assertHandlerErrorCode(t, err, models.ErrCodeListIsArchived)
|
||||
})
|
||||
t.Run("unarchivable", func(t *testing.T) {
|
||||
rec, err := testListHandler.testUpdateWithUser(nil, map[string]string{"list": "22"}, `{"title":"LoremIpsum","is_archived":false}`)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, rec.Body.String(), `"is_archived":false`)
|
||||
})
|
||||
|
||||
taskTests("36", models.ErrCodeListIsArchived, t)
|
||||
})
|
||||
})
|
||||
}
|
|
@ -221,9 +221,13 @@ func (h *webHandlerTest) testUpdateWithUser(queryParams url.Values, urlParams ma
|
|||
return newTestRequestWithUser(h.t, http.MethodPost, hndl.UpdateWeb, h.user, payload, queryParams, urlParams)
|
||||
}
|
||||
|
||||
func (h *webHandlerTest) testDeleteWithUser(queryParams url.Values, urlParams map[string]string) (rec *httptest.ResponseRecorder, err error) {
|
||||
func (h *webHandlerTest) testDeleteWithUser(queryParams url.Values, urlParams map[string]string, payload ...string) (rec *httptest.ResponseRecorder, err error) {
|
||||
pl := ""
|
||||
if len(payload) > 0 {
|
||||
pl = payload[0]
|
||||
}
|
||||
hndl := h.getHandler()
|
||||
return newTestRequestWithUser(h.t, http.MethodDelete, hndl.DeleteWeb, h.user, "", queryParams, urlParams)
|
||||
return newTestRequestWithUser(h.t, http.MethodDelete, hndl.DeleteWeb, h.user, pl, queryParams, urlParams)
|
||||
}
|
||||
|
||||
func (h *webHandlerTest) testReadAllWithLinkShare(queryParams url.Values, urlParams map[string]string) (rec *httptest.ResponseRecorder, err error) {
|
||||
|
|
|
@ -38,10 +38,12 @@ func TestList(t *testing.T) {
|
|||
rec, err := testHandler.testReadAllWithUser(nil, nil)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, rec.Body.String(), `Test1`)
|
||||
assert.NotContains(t, rec.Body.String(), `Test2`)
|
||||
assert.NotContains(t, rec.Body.String(), `Test2"`)
|
||||
assert.Contains(t, rec.Body.String(), `Test3`) // Shared directly via users_list
|
||||
assert.Contains(t, rec.Body.String(), `Test4`) // Shared via namespace
|
||||
assert.NotContains(t, rec.Body.String(), `Test5`)
|
||||
assert.NotContains(t, rec.Body.String(), `Test21`) // Archived through namespace
|
||||
assert.NotContains(t, rec.Body.String(), `Test22`) // Archived directly
|
||||
})
|
||||
t.Run("Search", func(t *testing.T) {
|
||||
rec, err := testHandler.testReadAllWithUser(url.Values{"s": []string{"Test1"}}, nil)
|
||||
|
@ -52,6 +54,17 @@ func TestList(t *testing.T) {
|
|||
assert.NotContains(t, rec.Body.String(), `Test4`)
|
||||
assert.NotContains(t, rec.Body.String(), `Test5`)
|
||||
})
|
||||
t.Run("Normal with archived lists", func(t *testing.T) {
|
||||
rec, err := testHandler.testReadAllWithUser(url.Values{"is_archived": []string{"true"}}, nil)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, rec.Body.String(), `Test1`)
|
||||
assert.NotContains(t, rec.Body.String(), `Test2"`)
|
||||
assert.Contains(t, rec.Body.String(), `Test3`) // Shared directly via users_list
|
||||
assert.Contains(t, rec.Body.String(), `Test4`) // Shared via namespace
|
||||
assert.NotContains(t, rec.Body.String(), `Test5`)
|
||||
assert.Contains(t, rec.Body.String(), `Test21`) // Archived through namespace
|
||||
assert.Contains(t, rec.Body.String(), `Test22`) // Archived directly
|
||||
})
|
||||
})
|
||||
t.Run("ReadOne", func(t *testing.T) {
|
||||
t.Run("Normal", func(t *testing.T) {
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
// Vikunja is a todo-list application to facilitate your life.
|
||||
// Copyright 2018-2020 Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package migration
|
||||
|
||||
import (
|
||||
"src.techknowlogick.com/xormigrate"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
type list20200308205855 struct {
|
||||
IsArchived bool `xorm:"not null default false"`
|
||||
}
|
||||
|
||||
func (s list20200308205855) TableName() string {
|
||||
return "list"
|
||||
}
|
||||
|
||||
func init() {
|
||||
migrations = append(migrations, &xormigrate.Migration{
|
||||
ID: "20200308205855",
|
||||
Description: "Add is archived flag to lists",
|
||||
Migrate: func(tx *xorm.Engine) error {
|
||||
return tx.Sync2(list20200308205855{})
|
||||
},
|
||||
Rollback: func(tx *xorm.Engine) error {
|
||||
return tx.DropTables(list20200308205855{})
|
||||
},
|
||||
})
|
||||
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
// Vikunja is a todo-list application to facilitate your life.
|
||||
// Copyright 2018-2020 Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package migration
|
||||
|
||||
import (
|
||||
"src.techknowlogick.com/xormigrate"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
type namepsace20200308210130 struct {
|
||||
IsArchived bool `xorm:"not null default false"`
|
||||
}
|
||||
|
||||
func (s namepsace20200308210130) TableName() string {
|
||||
return "namespaces"
|
||||
}
|
||||
|
||||
func init() {
|
||||
migrations = append(migrations, &xormigrate.Migration{
|
||||
ID: "20200308210130",
|
||||
Description: "Add is archived flag to namepaces",
|
||||
Migrate: func(tx *xorm.Engine) error {
|
||||
return tx.Sync2(namepsace20200308210130{})
|
||||
},
|
||||
Rollback: func(tx *xorm.Engine) error {
|
||||
return tx.DropTables(namepsace20200308210130{})
|
||||
},
|
||||
})
|
||||
|
||||
}
|
|
@ -231,6 +231,29 @@ func (err ErrListIdentifierIsNotUnique) HTTPError() web.HTTPError {
|
|||
}
|
||||
}
|
||||
|
||||
// ErrListIsArchived represents an error, where a list is archived
|
||||
type ErrListIsArchived struct {
|
||||
ListID int64
|
||||
}
|
||||
|
||||
// IsErrListIsArchived checks if an error is a .
|
||||
func IsErrListIsArchived(err error) bool {
|
||||
_, ok := err.(ErrListIsArchived)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrListIsArchived) Error() string {
|
||||
return fmt.Sprintf("List is archived [ListID: %d]", err.ListID)
|
||||
}
|
||||
|
||||
// ErrCodeListIsArchived holds the unique world-error code of this error
|
||||
const ErrCodeListIsArchived = 3008
|
||||
|
||||
// HTTPError holds the http error description
|
||||
func (err ErrListIsArchived) HTTPError() web.HTTPError {
|
||||
return web.HTTPError{HTTPCode: http.StatusPreconditionFailed, Code: ErrCodeListIsArchived, Message: "This lists is archived. Editing or creating new tasks is not possible."}
|
||||
}
|
||||
|
||||
// ================
|
||||
// List task errors
|
||||
// ================
|
||||
|
@ -777,6 +800,29 @@ func (err ErrUserAlreadyHasNamespaceAccess) HTTPError() web.HTTPError {
|
|||
return web.HTTPError{HTTPCode: http.StatusConflict, Code: ErrCodeUserAlreadyHasNamespaceAccess, Message: "This user already has access to this namespace."}
|
||||
}
|
||||
|
||||
// ErrNamespaceIsArchived represents an error where a namespace is archived
|
||||
type ErrNamespaceIsArchived struct {
|
||||
NamespaceID int64
|
||||
}
|
||||
|
||||
// IsErrNamespaceIsArchived checks if an error is a .
|
||||
func IsErrNamespaceIsArchived(err error) bool {
|
||||
_, ok := err.(ErrNamespaceIsArchived)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrNamespaceIsArchived) Error() string {
|
||||
return fmt.Sprintf("Namespace is archived [NamespaceID: %d]", err.NamespaceID)
|
||||
}
|
||||
|
||||
// ErrCodeNamespaceIsArchived holds the unique world-error code of this error
|
||||
const ErrCodeNamespaceIsArchived = 5012
|
||||
|
||||
// HTTPError holds the http error description
|
||||
func (err ErrNamespaceIsArchived) HTTPError() web.HTTPError {
|
||||
return web.HTTPError{HTTPCode: http.StatusPreconditionFailed, Code: ErrCodeNamespaceIsArchived, Message: "This namespaces is archived. Editing or creating new lists is not possible."}
|
||||
}
|
||||
|
||||
// ============
|
||||
// Team errors
|
||||
// ============
|
||||
|
|
|
@ -203,7 +203,10 @@ func getLabelByIDSimple(labelID int64) (*Label, error) {
|
|||
func getUserTaskIDs(u *user.User) (taskIDs []int64, err error) {
|
||||
|
||||
// Get all lists
|
||||
lists, _, _, err := getRawListsForUser("", u, -1, 0)
|
||||
lists, _, _, err := getRawListsForUser(&listOptions{
|
||||
user: u,
|
||||
page: -1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
@ -21,6 +21,7 @@ import (
|
|||
"code.vikunja.io/api/pkg/timeutil"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
"code.vikunja.io/web"
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// List represents a list of tasks
|
||||
|
@ -43,6 +44,9 @@ type List struct {
|
|||
// Deprecated: you should use the dedicated task list endpoint because it has support for pagination and filtering
|
||||
Tasks []*Task `xorm:"-" json:"-"`
|
||||
|
||||
// Whether or not a list is archived.
|
||||
IsArchived bool `xorm:"not null default false" json:"is_archived" query:"is_archived"`
|
||||
|
||||
// A timestamp when this list was created. You cannot change this value.
|
||||
Created timeutil.TimeStamp `xorm:"created not null" json:"created"`
|
||||
// A timestamp when this list was last updated. You cannot change this value.
|
||||
|
@ -57,16 +61,24 @@ func GetListsByNamespaceID(nID int64, doer *user.User) (lists []*List, err error
|
|||
if nID == -1 {
|
||||
err = x.Select("l.*").
|
||||
Table("list").
|
||||
Alias("l").
|
||||
Join("LEFT", []string{"team_list", "tl"}, "l.id = tl.list_id").
|
||||
Join("LEFT", []string{"team_members", "tm"}, "tm.team_id = tl.team_id").
|
||||
Join("LEFT", []string{"users_list", "ul"}, "ul.list_id = l.id").
|
||||
Join("LEFT", []string{"namespaces", "n"}, "l.namespace_id = n.id").
|
||||
Where("tm.user_id = ?", doer.ID).
|
||||
Where("l.is_archived = false").
|
||||
Where("n.is_archived = false").
|
||||
Or("ul.user_id = ?", doer.ID).
|
||||
GroupBy("l.id").
|
||||
Find(&lists)
|
||||
} else {
|
||||
err = x.Where("namespace_id = ?", nID).Find(&lists)
|
||||
err = x.Select("l.*").
|
||||
Alias("l").
|
||||
Join("LEFT", []string{"namespaces", "n"}, "l.namespace_id = n.id").
|
||||
Where("l.is_archived = false").
|
||||
Where("n.is_archived = false").
|
||||
Where("namespace_id = ?", nID).
|
||||
Find(&lists)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
@ -86,6 +98,7 @@ func GetListsByNamespaceID(nID int64, doer *user.User) (lists []*List, err error
|
|||
// @Param page query int false "The page number. Used for pagination. If not provided, the first page of results is returned."
|
||||
// @Param per_page query int false "The maximum number of items per page. Note this parameter is limited by the configured maximum of items per page."
|
||||
// @Param s query string false "Search lists by title."
|
||||
// @Param is_archived query bool false "If true, also returns all archived lists."
|
||||
// @Security JWTKeyAuth
|
||||
// @Success 200 {array} models.List "The lists"
|
||||
// @Failure 403 {object} code.vikunja.io/web.HTTPError "The user does not have access to the list"
|
||||
|
@ -105,7 +118,13 @@ func (l *List) ReadAll(a web.Auth, search string, page int, perPage int) (result
|
|||
return lists, 0, 0, err
|
||||
}
|
||||
|
||||
lists, resultCount, totalItems, err := getRawListsForUser(search, &user.User{ID: a.GetID()}, page, perPage)
|
||||
lists, resultCount, totalItems, err := getRawListsForUser(&listOptions{
|
||||
search: search,
|
||||
user: &user.User{ID: a.GetID()},
|
||||
page: page,
|
||||
perPage: perPage,
|
||||
isArchived: l.IsArchived,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
|
@ -177,13 +196,30 @@ func GetListSimplByTaskID(taskID int64) (l *List, err error) {
|
|||
return &list, nil
|
||||
}
|
||||
|
||||
type listOptions struct {
|
||||
search string
|
||||
user *user.User
|
||||
page int
|
||||
perPage int
|
||||
isArchived bool
|
||||
}
|
||||
|
||||
// Gets the lists only, without any tasks or so
|
||||
func getRawListsForUser(search string, u *user.User, page int, perPage int) (lists []*List, resultCount int, totalItems int64, err error) {
|
||||
fullUser, err := user.GetUserByID(u.ID)
|
||||
func getRawListsForUser(opts *listOptions) (lists []*List, resultCount int, totalItems int64, err error) {
|
||||
fullUser, err := user.GetUserByID(opts.user.ID)
|
||||
if err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
|
||||
// Adding a 1=1 condition by default here because xorm always needs a condition and cannot handle nil conditions
|
||||
var isArchivedCond builder.Cond = builder.Eq{"1": 1}
|
||||
if !opts.isArchived {
|
||||
isArchivedCond = builder.And(
|
||||
builder.Eq{"l.is_archived": false},
|
||||
builder.Eq{"n.is_archived": false},
|
||||
)
|
||||
}
|
||||
|
||||
// Gets all Lists where the user is either owner or in a team which has access to the list
|
||||
// Or in a team which has namespace read access
|
||||
err = x.Select("l.*").
|
||||
|
@ -202,8 +238,9 @@ func getRawListsForUser(search string, u *user.User, page int, perPage int) (lis
|
|||
Or("ul.user_id = ?", fullUser.ID).
|
||||
Or("un.user_id = ?", fullUser.ID).
|
||||
GroupBy("l.id").
|
||||
Limit(getLimitFromPageIndex(page, perPage)).
|
||||
Where("l.title LIKE ?", "%"+search+"%").
|
||||
Limit(getLimitFromPageIndex(opts.page, opts.perPage)).
|
||||
Where("l.title LIKE ?", "%"+opts.search+"%").
|
||||
Where(isArchivedCond).
|
||||
Find(&lists)
|
||||
if err != nil {
|
||||
return nil, 0, 0, err
|
||||
|
@ -225,8 +262,9 @@ func getRawListsForUser(search string, u *user.User, page int, perPage int) (lis
|
|||
Or("ul.user_id = ?", fullUser.ID).
|
||||
Or("un.user_id = ?", fullUser.ID).
|
||||
GroupBy("l.id").
|
||||
Limit(getLimitFromPageIndex(page, perPage)).
|
||||
Where("l.title LIKE ?", "%"+search+"%").
|
||||
Limit(getLimitFromPageIndex(opts.page, opts.perPage)).
|
||||
Where("l.title LIKE ?", "%"+opts.search+"%").
|
||||
Where(isArchivedCond).
|
||||
Count(&List{})
|
||||
return lists, len(lists), totalItems, err
|
||||
}
|
||||
|
@ -259,6 +297,38 @@ func AddListDetails(lists []*List) (err error) {
|
|||
return
|
||||
}
|
||||
|
||||
// NamespaceList is a meta type to be able to join a list with its namespace
|
||||
type NamespaceList struct {
|
||||
List List `xorm:"extends"`
|
||||
Namespace Namespace `xorm:"extends"`
|
||||
}
|
||||
|
||||
// CheckIsArchived returns an ErrListIsArchived or ErrNamespaceIsArchived if the list or its namespace is archived.
|
||||
func (l *List) CheckIsArchived() (err error) {
|
||||
// When creating a new list, we check if the namespace is archived
|
||||
if l.ID == 0 {
|
||||
n := &Namespace{ID: l.NamespaceID}
|
||||
return n.CheckIsArchived()
|
||||
}
|
||||
|
||||
nl := &NamespaceList{}
|
||||
exists, err := x.
|
||||
Table("list").
|
||||
Join("LEFT", "namespaces", "list.namespace_id = namespaces.id").
|
||||
Where("list.id = ? AND (list.is_archived = true OR namespaces.is_archived = true)", l.ID).
|
||||
Get(nl)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if exists && nl.List.ID != 0 && nl.List.IsArchived {
|
||||
return ErrListIsArchived{ListID: l.ID}
|
||||
}
|
||||
if exists && nl.Namespace.ID != 0 && nl.Namespace.IsArchived {
|
||||
return ErrNamespaceIsArchived{NamespaceID: nl.Namespace.ID}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateOrUpdateList updates a list or creates it if it doesn't exist
|
||||
func CreateOrUpdateList(list *List) (err error) {
|
||||
|
||||
|
@ -285,7 +355,22 @@ func CreateOrUpdateList(list *List) (err error) {
|
|||
_, err = x.Insert(list)
|
||||
metrics.UpdateCount(1, metrics.ListCountKey)
|
||||
} else {
|
||||
_, err = x.ID(list.ID).Update(list)
|
||||
// We need to specify the cols we want to update here to be able to un-archive lists
|
||||
colsToUpdate := []string{
|
||||
"title",
|
||||
"is_archived",
|
||||
}
|
||||
if list.Description != "" {
|
||||
colsToUpdate = append(colsToUpdate, "description")
|
||||
}
|
||||
if list.Identifier != "" {
|
||||
colsToUpdate = append(colsToUpdate, "identifier")
|
||||
}
|
||||
|
||||
_, err = x.
|
||||
ID(list.ID).
|
||||
Cols(colsToUpdate...).
|
||||
Update(list)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
|
@ -350,6 +435,11 @@ func updateListByTaskID(taskID int64) (err error) {
|
|||
// @Failure 500 {object} models.Message "Internal error"
|
||||
// @Router /namespaces/{namespaceID}/lists [put]
|
||||
func (l *List) Create(a web.Auth) (err error) {
|
||||
err = l.CheckIsArchived()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
doer, err := user.GetFromAuth(a)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
@ -32,19 +32,33 @@ func (l *List) CanWrite(a web.Auth) (bool, error) {
|
|||
return false, err
|
||||
}
|
||||
|
||||
// We put the result of the is archived check in a separate variable to be able to return it later without
|
||||
// needing to recheck it again
|
||||
errIsArchived := originalList.CheckIsArchived()
|
||||
|
||||
var canWrite bool
|
||||
|
||||
// Check if we're dealing with a share auth
|
||||
shareAuth, ok := a.(*LinkSharing)
|
||||
if ok {
|
||||
return originalList.ID == shareAuth.ListID &&
|
||||
(shareAuth.Right == RightWrite || shareAuth.Right == RightAdmin), nil
|
||||
(shareAuth.Right == RightWrite || shareAuth.Right == RightAdmin), errIsArchived
|
||||
}
|
||||
|
||||
// Check if the user is either owner or can write to the list
|
||||
if originalList.isOwner(&user.User{ID: a.GetID()}) {
|
||||
return true, nil
|
||||
canWrite = true
|
||||
}
|
||||
|
||||
return originalList.checkRight(a, RightWrite, RightAdmin)
|
||||
if canWrite {
|
||||
return canWrite, errIsArchived
|
||||
}
|
||||
|
||||
canWrite, err = originalList.checkRight(a, RightWrite, RightAdmin)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return canWrite, errIsArchived
|
||||
}
|
||||
|
||||
// CanRead checks if a user has read access to a list
|
||||
|
@ -68,8 +82,13 @@ func (l *List) CanRead(a web.Auth) (bool, error) {
|
|||
}
|
||||
|
||||
// CanUpdate checks if the user can update a list
|
||||
func (l *List) CanUpdate(a web.Auth) (bool, error) {
|
||||
return l.CanWrite(a)
|
||||
func (l *List) CanUpdate(a web.Auth) (canUpdate bool, err error) {
|
||||
canUpdate, err = l.CanWrite(a)
|
||||
// If the list is archived and the user tries to un-archive it, let the request through
|
||||
if IsErrListIsArchived(err) && !l.IsArchived {
|
||||
err = nil
|
||||
}
|
||||
return canUpdate, err
|
||||
}
|
||||
|
||||
// CanDelete checks if the user can delete a list
|
||||
|
|
|
@ -35,6 +35,9 @@ type Namespace struct {
|
|||
Description string `xorm:"longtext null" json:"description"`
|
||||
OwnerID int64 `xorm:"int(11) not null INDEX" json:"-"`
|
||||
|
||||
// Whether or not a namespace is archived.
|
||||
IsArchived bool `xorm:"not null default false" json:"is_archived" query:"is_archived"`
|
||||
|
||||
// The user who owns this namespace
|
||||
Owner *user.User `xorm:"-" json:"owner" valid:"-"`
|
||||
|
||||
|
@ -103,6 +106,20 @@ func GetNamespaceByID(id int64) (namespace Namespace, err error) {
|
|||
return
|
||||
}
|
||||
|
||||
// CheckIsArchived returns an ErrNamespaceIsArchived if the namepace is archived.
|
||||
func (n *Namespace) CheckIsArchived() error {
|
||||
exists, err := x.
|
||||
Where("id = ? AND is_archived = true", n.ID).
|
||||
Exist(&Namespace{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return ErrNamespaceIsArchived{NamespaceID: n.ID}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadOne gets one namespace
|
||||
// @Summary Gets one namespace
|
||||
// @Description Returns a namespace by its ID.
|
||||
|
@ -136,6 +153,7 @@ type NamespaceWithLists struct {
|
|||
// @Param page query int false "The page number. Used for pagination. If not provided, the first page of results is returned."
|
||||
// @Param per_page query int false "The maximum number of items per page. Note this parameter is limited by the configured maximum of items per page."
|
||||
// @Param s query string false "Search namespaces by name."
|
||||
// @Param is_archived query bool false "If true, also returns all archived namespaces."
|
||||
// @Security JWTKeyAuth
|
||||
// @Success 200 {array} models.NamespaceWithLists "The Namespaces."
|
||||
// @Failure 500 {object} models.Message "Internal error"
|
||||
|
@ -169,6 +187,7 @@ func (n *Namespace) ReadAll(a web.Auth, search string, page int, perPage int) (r
|
|||
Where("team_members.user_id = ?", doer.ID).
|
||||
Or("namespaces.owner_id = ?", doer.ID).
|
||||
Or("users_namespace.user_id = ?", doer.ID).
|
||||
And("namespaces.is_archived = ?", n.IsArchived).
|
||||
GroupBy("namespaces.id").
|
||||
Limit(getLimitFromPageIndex(page, perPage)).
|
||||
Where("namespaces.name LIKE ?", "%"+search+"%").
|
||||
|
@ -188,6 +207,7 @@ func (n *Namespace) ReadAll(a web.Auth, search string, page int, perPage int) (r
|
|||
Where("team_members.user_id = ?", doer.ID).
|
||||
Or("namespaces.owner_id = ?", doer.ID).
|
||||
Or("users_namespace.user_id = ?", doer.ID).
|
||||
And("namespaces.is_archived = ?", n.IsArchived).
|
||||
GroupBy("users.id").
|
||||
Find(&users)
|
||||
|
||||
|
@ -220,6 +240,7 @@ func (n *Namespace) ReadAll(a web.Auth, search string, page int, perPage int) (r
|
|||
Join("LEFT", []string{"users_list", "ul"}, "ul.list_id = l.id").
|
||||
Where("tm.user_id = ?", doer.ID).
|
||||
Or("ul.user_id = ?", doer.ID).
|
||||
And("l.is_archived = false").
|
||||
GroupBy("l.id").
|
||||
Find(&individualLists)
|
||||
if err != nil {
|
||||
|
@ -272,6 +293,7 @@ func (n *Namespace) ReadAll(a web.Auth, search string, page int, perPage int) (r
|
|||
Where("team_members.user_id = ?", doer.ID).
|
||||
Or("namespaces.owner_id = ?", doer.ID).
|
||||
Or("users_namespace.user_id = ?", doer.ID).
|
||||
And("namespaces.is_archived = false").
|
||||
GroupBy("namespaces.id").
|
||||
Where("namespaces.name LIKE ?", "%"+search+"%").
|
||||
Count(&NamespaceWithLists{})
|
||||
|
@ -400,12 +422,19 @@ func (n *Namespace) Update() (err error) {
|
|||
return
|
||||
}
|
||||
|
||||
// Check if the namespace is archived and the update is not un-archiving it
|
||||
if currentNamespace.IsArchived && n.IsArchived {
|
||||
return ErrNamespaceIsArchived{NamespaceID: n.ID}
|
||||
}
|
||||
|
||||
// Check if the (new) owner exists
|
||||
n.OwnerID = n.Owner.ID
|
||||
if currentNamespace.OwnerID != n.OwnerID {
|
||||
n.Owner, err = user.GetUserByID(n.OwnerID)
|
||||
if err != nil {
|
||||
return
|
||||
if n.Owner != nil {
|
||||
n.OwnerID = n.Owner.ID
|
||||
if currentNamespace.OwnerID != n.OwnerID {
|
||||
n.Owner, err = user.GetUserByID(n.OwnerID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -64,7 +64,8 @@ func (n *Namespace) checkRight(a web.Auth, rights ...Right) (bool, error) {
|
|||
}
|
||||
|
||||
// Get the namespace and check the right
|
||||
err := n.GetSimpleByID()
|
||||
nn := &Namespace{ID: n.ID}
|
||||
err := nn.GetSimpleByID()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
|
|
@ -109,7 +109,10 @@ func (tf *TaskCollection) ReadAll(a web.Auth, search string, page int, perPage i
|
|||
// If the list ID is not set, we get all tasks for the user.
|
||||
// This allows to use this function in Task.ReadAll with a possibility to deprecate the latter at some point.
|
||||
if tf.ListID == 0 {
|
||||
tf.Lists, _, _, err = getRawListsForUser("", &user.User{ID: a.GetID()}, -1, 0)
|
||||
tf.Lists, _, _, err = getRawListsForUser(&listOptions{
|
||||
user: &user.User{ID: a.GetID()},
|
||||
page: -1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue