diff --git a/docs/content/doc/usage/errors.md b/docs/content/doc/usage/errors.md index 7010deeb79..f565daa561 100644 --- a/docs/content/doc/usage/errors.md +++ b/docs/content/doc/usage/errors.md @@ -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. | diff --git a/pkg/db/fixtures/label_task.yml b/pkg/db/fixtures/label_task.yml index c3fd91f7bf..218fef56c6 100644 --- a/pkg/db/fixtures/label_task.yml +++ b/pkg/db/fixtures/label_task.yml @@ -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 diff --git a/pkg/db/fixtures/list.yml b/pkg/db/fixtures/list.yml index fa8edcb9aa..f5effdd34d 100644 --- a/pkg/db/fixtures/list.yml +++ b/pkg/db/fixtures/list.yml @@ -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 diff --git a/pkg/db/fixtures/namespaces.yml b/pkg/db/fixtures/namespaces.yml index 447d9c317a..9d3c5511e4 100644 --- a/pkg/db/fixtures/namespaces.yml +++ b/pkg/db/fixtures/namespaces.yml @@ -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 diff --git a/pkg/db/fixtures/task_assignees.yml b/pkg/db/fixtures/task_assignees.yml index 9104a48c61..ae4ca1cbc5 100644 --- a/pkg/db/fixtures/task_assignees.yml +++ b/pkg/db/fixtures/task_assignees.yml @@ -5,4 +5,12 @@ - id: 2 task_id: 30 user_id: 2 - created: 0 \ No newline at end of file + created: 0 +- id: 3 + task_id: 35 + user_id: 2 + created: 0 +- id: 4 + task_id: 36 + user_id: 2 + created: 0 diff --git a/pkg/db/fixtures/task_comments.yml b/pkg/db/fixtures/task_comments.yml index 76f599fe57..385a3da3a3 100644 --- a/pkg/db/fixtures/task_comments.yml +++ b/pkg/db/fixtures/task_comments.yml @@ -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 diff --git a/pkg/db/fixtures/task_relations.yml b/pkg/db/fixtures/task_relations.yml index 6abf882a17..08bd448a50 100644 --- a/pkg/db/fixtures/task_relations.yml +++ b/pkg/db/fixtures/task_relations.yml @@ -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 diff --git a/pkg/db/fixtures/tasks.yml b/pkg/db/fixtures/tasks.yml index d7f7fae80b..40742e33e7 100644 --- a/pkg/db/fixtures/tasks.yml +++ b/pkg/db/fixtures/tasks.yml @@ -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 diff --git a/pkg/integrations/archived_test.go b/pkg/integrations/archived_test.go new file mode 100644 index 0000000000..0099f80100 --- /dev/null +++ b/pkg/integrations/archived_test.go @@ -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 . + +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) + }) + }) +} diff --git a/pkg/integrations/integrations.go b/pkg/integrations/integrations.go index 41c48159b8..3b0b31a78e 100644 --- a/pkg/integrations/integrations.go +++ b/pkg/integrations/integrations.go @@ -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) { diff --git a/pkg/integrations/list_test.go b/pkg/integrations/list_test.go index 8d771356f1..7b0e50f63a 100644 --- a/pkg/integrations/list_test.go +++ b/pkg/integrations/list_test.go @@ -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) { diff --git a/pkg/migration/20200308205855.go b/pkg/migration/20200308205855.go new file mode 100644 index 0000000000..fbe4d3a038 --- /dev/null +++ b/pkg/migration/20200308205855.go @@ -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 . + +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{}) + }, + }) + +} diff --git a/pkg/migration/20200308210130.go b/pkg/migration/20200308210130.go new file mode 100644 index 0000000000..c96a38be0d --- /dev/null +++ b/pkg/migration/20200308210130.go @@ -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 . + +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{}) + }, + }) + +} diff --git a/pkg/models/error.go b/pkg/models/error.go index 77b70ba6d6..5590cd956d 100644 --- a/pkg/models/error.go +++ b/pkg/models/error.go @@ -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 // ============ diff --git a/pkg/models/label.go b/pkg/models/label.go index bfdcd2c778..fab3280d35 100644 --- a/pkg/models/label.go +++ b/pkg/models/label.go @@ -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 } diff --git a/pkg/models/list.go b/pkg/models/list.go index 9ddde6a04c..3ff82c3fee 100644 --- a/pkg/models/list.go +++ b/pkg/models/list.go @@ -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 diff --git a/pkg/models/list_rights.go b/pkg/models/list_rights.go index 8bebf0f71b..b27f5a8e35 100644 --- a/pkg/models/list_rights.go +++ b/pkg/models/list_rights.go @@ -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 diff --git a/pkg/models/namespace.go b/pkg/models/namespace.go index 1bcf9eab99..21299633c5 100644 --- a/pkg/models/namespace.go +++ b/pkg/models/namespace.go @@ -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 + } } } diff --git a/pkg/models/namespace_rights.go b/pkg/models/namespace_rights.go index e4a518f6a2..e38a204ae1 100644 --- a/pkg/models/namespace_rights.go +++ b/pkg/models/namespace_rights.go @@ -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 } diff --git a/pkg/models/task_collection.go b/pkg/models/task_collection.go index 30955053a5..69ba194ba2 100644 --- a/pkg/models/task_collection.go +++ b/pkg/models/task_collection.go @@ -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 }