Add support for archiving lists and namespaces (#152)

Add query param to get all lists including archived ones

Add query param to get all namespaces including archived ones

Fix getting lists by namespace only not archived lists

Fix misspell

Fix lint

Merge branch 'master' into feature/archive-lists-namespaces

Add docs for error codes

Fix archive error codes

Don't let archived lists show up in general lists

Fix updating description

Fix updating lists with link shares

More comments

Fix un-archiving lists

Move check for archiving a list to canWrite Check

Add more tests

Add more checks

Add checks for namespaces and lists

Add namespace edit

Add tests

Add migrations and filter

Add basic tests

Add is archived property to lists and namespaces

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/api#152
This commit is contained in:
konrad 2020-03-15 21:50:39 +00:00
parent 4472020ee9
commit 5126330a10
20 changed files with 654 additions and 27 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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{})
},
})
}

View File

@ -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{})
},
})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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