From 0fb2edf0512deedd7fa995e53b3391c084325f5f Mon Sep 17 00:00:00 2001 From: konrad Date: Sat, 26 Sep 2020 21:02:17 +0000 Subject: [PATCH] Saved filters (#655) Fix updating saved filters Fix filter not loadable because of missing param declaration Add fancy special cases for postgres exists in db Add special case for postgrs json Add read one test Add rights tests Fix lint Fixed getting a single saved filter from db Add tests for the usual crud methods Add test stubs and TODOs Add test for converting saved filter ids to list ids and vice versa Add test fixture for saved filters and fix existing tests Fix exposed json variables of filters Fix creating saved filters table for tests Add getting saved filters as pseudo namespace Cleanup Refactor getting all namespaces to use a map for easier handling of pseudo namespaces Add custom erros for saved filters Swagger docs Fix lint Add routes for saved filters Add alias for mage build Add method to get a saved filter from the lists endpoint Add getting tasks from a saved filter Add create, update, delete, read one methods Add rights methods for saved filters Fix docs minLength Add saved filters column Co-authored-by: kolaente Reviewed-on: https://kolaente.dev/vikunja/api/pulls/655 Co-Authored-By: konrad Co-Committed-By: konrad --- docs/content/doc/usage/errors.md | 7 + magefile.go | 1 + pkg/db/fixtures/saved_filters.yml | 6 + pkg/db/test.go | 32 +++ pkg/integrations/task_collection_test.go | 23 ++ pkg/migration/20200906184746.go | 51 ++++ pkg/models/error.go | 59 +++++ pkg/models/label.go | 2 +- pkg/models/list.go | 15 +- pkg/models/list_rights.go | 6 + pkg/models/models.go | 1 + pkg/models/namespace.go | 165 ++++++++----- pkg/models/namespace_test.go | 24 +- pkg/models/saved_filters.go | 182 ++++++++++++++ pkg/models/saved_filters_rights.go | 68 +++++ pkg/models/saved_filters_test.go | 257 +++++++++++++++++++ pkg/models/task_collection.go | 39 +-- pkg/models/tasks.go | 2 +- pkg/models/teams.go | 2 +- pkg/models/unit_tests.go | 1 + pkg/routes/api/v1/list_by_namespace.go | 2 +- pkg/routes/routes.go | 10 + pkg/swagger/docs.go | 302 ++++++++++++++++++++++- pkg/swagger/swagger.json | 302 ++++++++++++++++++++++- pkg/swagger/swagger.yaml | 208 +++++++++++++++- pkg/user/user.go | 2 +- 26 files changed, 1650 insertions(+), 119 deletions(-) create mode 100644 pkg/db/fixtures/saved_filters.yml create mode 100644 pkg/migration/20200906184746.go create mode 100644 pkg/models/saved_filters.go create mode 100644 pkg/models/saved_filters_rights.go create mode 100644 pkg/models/saved_filters_test.go diff --git a/docs/content/doc/usage/errors.md b/docs/content/doc/usage/errors.md index 3e6650ec5..883426323 100644 --- a/docs/content/doc/usage/errors.md +++ b/docs/content/doc/usage/errors.md @@ -135,3 +135,10 @@ This document describes the different errors Vikunja can return. | 10002 | 400 | The bucket does not belong to that list. | | 10003 | 412 | You cannot remove the last bucket on a list. | | 10004 | 412 | You cannot add the task to this bucket as it already exceeded the limit of tasks it can hold. | + +## Saved Filters + +| ErrorCode | HTTP Status Code | Description | +|-----------|------------------|-------------| +| 11001 | 404 | The saved filter does not exist. | +| 11002 | 412 | Saved filters are not available for link shares. | diff --git a/magefile.go b/magefile.go index 055ba9bf5..effc1e37a 100644 --- a/magefile.go +++ b/magefile.go @@ -57,6 +57,7 @@ var ( // Aliases are mage aliases of targets Aliases = map[string]interface{}{ + "build": Build.Build, "do-the-swag": DoTheSwag, "check:go-sec": Check.GoSec, "check:got-swag": Check.GotSwag, diff --git a/pkg/db/fixtures/saved_filters.yml b/pkg/db/fixtures/saved_filters.yml new file mode 100644 index 000000000..844ceb1a4 --- /dev/null +++ b/pkg/db/fixtures/saved_filters.yml @@ -0,0 +1,6 @@ +- id: 1 + filters: '{"sort_by":null,"order_by":null,"filter_by":["start_date","end_date","due_date"],"filter_value":["2018-12-11T03:46:40+00:00","2018-12-13T11:20:01+00:00","2018-11-29T14:00:00+00:00"],"filter_comparator":["greater","less","greater"],"filter_concat":"","filter_include_nulls":false}' + title: testfilter1 + owner_id: 1 + updated: 2020-09-08 15:13:12 + created: 2020-09-08 14:13:12 diff --git a/pkg/db/test.go b/pkg/db/test.go index fdf68a946..719ddd435 100644 --- a/pkg/db/test.go +++ b/pkg/db/test.go @@ -20,7 +20,10 @@ package db import ( "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/log" + "fmt" + "github.com/stretchr/testify/assert" "os" + "testing" "xorm.io/core" "xorm.io/xorm" ) @@ -69,3 +72,32 @@ func InitTestFixtures(tablenames ...string) (err error) { return nil } + +// AssertDBExists checks and asserts the existence of certain entries in the db +func AssertDBExists(t *testing.T, table string, values map[string]interface{}, custom bool) { + var exists bool + var err error + v := make(map[string]interface{}) + // Postgres sometimes needs to build raw sql. Because it won't always need to do this and this isn't fun, it's a flag. + if custom { + //#nosec + sql := "SELECT * FROM " + table + " WHERE " + for col, val := range values { + sql += col + "=" + fmt.Sprintf("%v", val) + " AND " + } + sql = sql[:len(sql)-5] + exists, err = x.SQL(sql).Get(&v) + } else { + exists, err = x.Table(table).Where(values).Get(&v) + } + assert.NoError(t, err, fmt.Sprintf("Failed to assert entries exist in db, error was: %s", err)) + assert.True(t, exists, fmt.Sprintf("Entries %v do not exist in table %s", values, table)) +} + +// AssertDBMissing checks and asserts the nonexiste nce of certain entries in the db +func AssertDBMissing(t *testing.T, table string, values map[string]interface{}) { + v := make(map[string]interface{}) + exists, err := x.Table(table).Where(values).Exist(&v) + assert.NoError(t, err, fmt.Sprintf("Failed to assert entries don't exist in db, error was: %s", err)) + assert.False(t, exists, fmt.Sprintf("Entries %v exist in table %s", values, table)) +} diff --git a/pkg/integrations/task_collection_test.go b/pkg/integrations/task_collection_test.go index 6c203901e..8a8234332 100644 --- a/pkg/integrations/task_collection_test.go +++ b/pkg/integrations/task_collection_test.go @@ -258,6 +258,29 @@ func TestTaskCollection(t *testing.T) { assertHandlerErrorCode(t, err, models.ErrCodeInvalidTaskFilterValue) }) }) + t.Run("saved filter", func(t *testing.T) { + t.Run("date range", func(t *testing.T) { + rec, err := testHandler.testReadAllWithUser( + nil, + map[string]string{"list": "-2"}, // Actually a saved filter - contains the same filter arguments as the start and end date filter from above + ) + assert.NoError(t, err) + assert.NotContains(t, rec.Body.String(), `task #1`) + assert.NotContains(t, rec.Body.String(), `task #2`) + assert.NotContains(t, rec.Body.String(), `task #3`) + assert.NotContains(t, rec.Body.String(), `task #4`) + assert.Contains(t, rec.Body.String(), `task #5`) + assert.Contains(t, rec.Body.String(), `task #6`) + assert.Contains(t, rec.Body.String(), `task #7`) + assert.Contains(t, rec.Body.String(), `task #8`) + assert.Contains(t, rec.Body.String(), `task #9`) + assert.NotContains(t, rec.Body.String(), `task #10`) + assert.NotContains(t, rec.Body.String(), `task #11`) + assert.NotContains(t, rec.Body.String(), `task #12`) + assert.NotContains(t, rec.Body.String(), `task #13`) + assert.NotContains(t, rec.Body.String(), `task #14`) + }) + }) }) t.Run("ReadAll for all tasks", func(t *testing.T) { diff --git a/pkg/migration/20200906184746.go b/pkg/migration/20200906184746.go new file mode 100644 index 000000000..dfce801ea --- /dev/null +++ b/pkg/migration/20200906184746.go @@ -0,0 +1,51 @@ +// Vikunja is a to-do 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 ( + "code.vikunja.io/api/pkg/models" + "src.techknowlogick.com/xormigrate" + "time" + "xorm.io/xorm" +) + +type savedFilters20200906184746 struct { + ID int64 `xorm:"autoincr not null unique pk" json:"id"` + Filters *models.TaskCollection `xorm:"JSON not null" json:"filters"` + Title string `xorm:"varchar(250) not null" json:"title" valid:"required,runelength(1|250)" minLength:"1" maxLength:"250"` + Description string `xorm:"longtext null" json:"description"` + OwnerID int64 `xorm:"int(11) not null INDEX" json:"-"` + Created time.Time `xorm:"created not null" json:"created"` + Updated time.Time `xorm:"updated not null" json:"updated"` +} + +func (savedFilters20200906184746) TableName() string { + return "saved_filters" +} + +func init() { + migrations = append(migrations, &xormigrate.Migration{ + ID: "20200906184746", + Description: "Add the saved filters column", + Migrate: func(tx *xorm.Engine) error { + return tx.Sync2(savedFilters20200906184746{}) + }, + Rollback: func(tx *xorm.Engine) error { + return nil + }, + }) +} diff --git a/pkg/models/error.go b/pkg/models/error.go index 1e95ce93d..f6786b57e 100644 --- a/pkg/models/error.go +++ b/pkg/models/error.go @@ -1363,3 +1363,62 @@ func (err ErrBucketLimitExceeded) HTTPError() web.HTTPError { Message: "You cannot add the task to this bucket as it already exceeded the limit of tasks it can hold.", } } + +// ============= +// Saved Filters +// ============= + +// ErrSavedFilterDoesNotExist represents an error where a kanban bucket does not exist +type ErrSavedFilterDoesNotExist struct { + SavedFilterID int64 +} + +// IsErrSavedFilterDoesNotExist checks if an error is ErrSavedFilterDoesNotExist. +func IsErrSavedFilterDoesNotExist(err error) bool { + _, ok := err.(ErrSavedFilterDoesNotExist) + return ok +} + +func (err ErrSavedFilterDoesNotExist) Error() string { + return fmt.Sprintf("Saved filter does not exist [SavedFilterID: %d]", err.SavedFilterID) +} + +// ErrCodeSavedFilterDoesNotExist holds the unique world-error code of this error +const ErrCodeSavedFilterDoesNotExist = 11001 + +// HTTPError holds the http error description +func (err ErrSavedFilterDoesNotExist) HTTPError() web.HTTPError { + return web.HTTPError{ + HTTPCode: http.StatusNotFound, + Code: ErrCodeSavedFilterDoesNotExist, + Message: "This saved filter does not exist.", + } +} + +// ErrSavedFilterNotAvailableForLinkShare represents an error where a kanban bucket does not exist +type ErrSavedFilterNotAvailableForLinkShare struct { + SavedFilterID int64 + LinkShareID int64 +} + +// IsErrSavedFilterNotAvailableForLinkShare checks if an error is ErrSavedFilterNotAvailableForLinkShare. +func IsErrSavedFilterNotAvailableForLinkShare(err error) bool { + _, ok := err.(ErrSavedFilterNotAvailableForLinkShare) + return ok +} + +func (err ErrSavedFilterNotAvailableForLinkShare) Error() string { + return fmt.Sprintf("Saved filters are not available for link shares [SavedFilterID: %d, LinkShareID: %d]", err.SavedFilterID, err.LinkShareID) +} + +// ErrCodeSavedFilterNotAvailableForLinkShare holds the unique world-error code of this error +const ErrCodeSavedFilterNotAvailableForLinkShare = 11002 + +// HTTPError holds the http error description +func (err ErrSavedFilterNotAvailableForLinkShare) HTTPError() web.HTTPError { + return web.HTTPError{ + HTTPCode: http.StatusPreconditionFailed, + Code: ErrCodeSavedFilterNotAvailableForLinkShare, + Message: "Saved filters are not available for link shares.", + } +} diff --git a/pkg/models/label.go b/pkg/models/label.go index dc0524cdf..a9a296931 100644 --- a/pkg/models/label.go +++ b/pkg/models/label.go @@ -27,7 +27,7 @@ type Label struct { // The unique, numeric id of this label. ID int64 `xorm:"int(11) autoincr not null unique pk" json:"id" param:"label"` // The title of the lable. You'll see this one on tasks associated with it. - Title string `xorm:"varchar(250) not null" json:"title" valid:"runelength(1|250)" minLength:"3" maxLength:"250"` + Title string `xorm:"varchar(250) not null" json:"title" valid:"runelength(1|250)" minLength:"1" maxLength:"250"` // The label description. Description string `xorm:"longtext null" json:"description"` // The color this label has diff --git a/pkg/models/list.go b/pkg/models/list.go index e18f1bc43..4c6515d28 100644 --- a/pkg/models/list.go +++ b/pkg/models/list.go @@ -32,7 +32,7 @@ type List struct { // The unique, numeric id of this list. ID int64 `xorm:"int(11) autoincr not null unique pk" json:"id" param:"list"` // The title of the list. You'll see this in the namespace overview. - Title string `xorm:"varchar(250) not null" json:"title" valid:"required,runelength(1|250)" minLength:"3" maxLength:"250"` + Title string `xorm:"varchar(250) not null" json:"title" valid:"required,runelength(1|250)" minLength:"1" maxLength:"250"` // The description of the list. Description string `xorm:"longtext null" json:"description"` // The unique list short identifier. Used to build task identifiers. @@ -185,6 +185,19 @@ func (l *List) ReadOne() (err error) { return nil } + // Check for saved filters + if getSavedFilterIDFromListID(l.ID) > 0 { + sf, err := getSavedFilterSimpleByID(getSavedFilterIDFromListID(l.ID)) + if err != nil { + return err + } + l.Title = sf.Title + l.Description = sf.Description + l.Created = sf.Created + l.Updated = sf.Updated + l.OwnerID = sf.OwnerID + } + // Get list owner l.Owner, err = user.GetUserByID(l.OwnerID) if err != nil { diff --git a/pkg/models/list_rights.go b/pkg/models/list_rights.go index 875409e8c..fddcbf8de 100644 --- a/pkg/models/list_rights.go +++ b/pkg/models/list_rights.go @@ -81,6 +81,12 @@ func (l *List) CanRead(a web.Auth) (bool, int, error) { return true, int(RightRead), nil } + // Saved Filter Lists need a special case + if getSavedFilterIDFromListID(l.ID) > 0 { + sf := &SavedFilter{ID: getSavedFilterIDFromListID(l.ID)} + return sf.CanRead(a) + } + // Check if the user is either owner or can read if err := l.GetSimpleByID(); err != nil { return false, 0, err diff --git a/pkg/models/models.go b/pkg/models/models.go index 78fa5e8e8..c8a73bdbe 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -57,6 +57,7 @@ func GetTables() []interface{} { &TaskComment{}, &Bucket{}, &UnsplashPhoto{}, + &SavedFilter{}, } } diff --git a/pkg/models/namespace.go b/pkg/models/namespace.go index 68decb34d..c753d5ca3 100644 --- a/pkg/models/namespace.go +++ b/pkg/models/namespace.go @@ -21,6 +21,7 @@ import ( "code.vikunja.io/api/pkg/user" "code.vikunja.io/web" "github.com/imdario/mergo" + "sort" "time" "xorm.io/builder" ) @@ -30,7 +31,7 @@ type Namespace struct { // The unique, numeric id of this namespace. ID int64 `xorm:"int(11) autoincr not null unique pk" json:"id" param:"namespace"` // The name of this namespace. - Title string `xorm:"varchar(250) not null" json:"title" valid:"required,runelength(1|250)" minLength:"5" maxLength:"250"` + Title string `xorm:"varchar(250) not null" json:"title" valid:"required,runelength(1|250)" minLength:"1" maxLength:"250"` // The description of the namespace Description string `xorm:"longtext null" json:"description"` OwnerID int64 `xorm:"int(11) not null INDEX" json:"-"` @@ -53,8 +54,8 @@ type Namespace struct { web.Rights `xorm:"-" json:"-"` } -// PseudoNamespace is a pseudo namespace used to hold shared lists -var PseudoNamespace = Namespace{ +// SharedListsPseudoNamespace is a pseudo namespace used to hold shared lists +var SharedListsPseudoNamespace = Namespace{ ID: -1, Title: "Shared Lists", Description: "Lists of other users shared with you via teams or directly.", @@ -71,6 +72,15 @@ var FavoritesPseudoNamespace = Namespace{ Updated: time.Now(), } +// SavedFiltersPseudoNamespace is a pseudo namespace used to hold saved filters +var SavedFiltersPseudoNamespace = Namespace{ + ID: -3, + Title: "Filters", + Description: "Saved filters.", + Created: time.Now(), + Updated: time.Now(), +} + // TableName makes beautiful table names func (Namespace) TableName() string { return "namespaces" @@ -84,7 +94,7 @@ func (n *Namespace) GetSimpleByID() (err error) { // Get the namesapce with shared lists if n.ID == -1 { - *n = PseudoNamespace + *n = SharedListsPseudoNamespace return } @@ -179,13 +189,18 @@ func (n *Namespace) ReadAll(a web.Auth, search string, page int, perPage int) (r return nil, 0, 0, ErrGenericForbidden{} } + // This map will hold all namespaces and their lists. The key is usually the id of the namespace. + // We're using a map here because it makes a few things like adding lists or removing pseudo namespaces easier. + namespaces := make(map[int64]*NamespaceWithLists) + + ////////////////////////////// + // Lists with their namespaces + doer, err := user.GetFromAuth(a) if err != nil { return nil, 0, 0, err } - all := []*NamespaceWithLists{} - // 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 !n.IsArchived { @@ -194,26 +209,7 @@ func (n *Namespace) ReadAll(a web.Auth, search string, page int, perPage int) (r ) } - // Create our pseudo namespace with favorite lists - // We want this one at the beginning, which is why we create it here - pseudoFavoriteNamespace := FavoritesPseudoNamespace - pseudoFavoriteNamespace.Owner = doer - all = append(all, &NamespaceWithLists{ - Namespace: pseudoFavoriteNamespace, - Lists: []*List{{}}, - }) - *all[0].Lists[0] = FavoritesPseudoList // Copying the list to be able to modify it later - - // Create our pseudo namespace to hold the shared lists - pseudonamespace := PseudoNamespace - pseudonamespace.Owner = doer - all = append(all, &NamespaceWithLists{ - pseudonamespace, - []*List{}, - }) - limit, start := getLimitFromPageIndex(page, perPage) - query := x.Select("namespaces.*"). Table("namespaces"). Join("LEFT", "team_namespaces", "namespaces.id = team_namespaces.namespace_id"). @@ -228,15 +224,15 @@ func (n *Namespace) ReadAll(a web.Auth, search string, page int, perPage int) (r if limit > 0 { query = query.Limit(limit, start) } - err = query.Find(&all) + err = query.Find(&namespaces) if err != nil { - return all, 0, 0, err + return nil, 0, 0, err } // Make a list of namespace ids var namespaceids []int64 var userIDs []int64 - for _, nsp := range all { + for _, nsp := range namespaces { namespaceids = append(namespaceids, nsp.ID) userIDs = append(userIDs, nsp.OwnerID) } @@ -245,7 +241,7 @@ func (n *Namespace) ReadAll(a web.Auth, search string, page int, perPage int) (r userMap := make(map[int64]*user.User) err = x.In("id", userIDs).Find(&userMap) if err != nil { - return all, 0, 0, err + return nil, 0, 0, err } // Get all lists @@ -258,7 +254,34 @@ func (n *Namespace) ReadAll(a web.Auth, search string, page int, perPage int) (r } err = listQuery.Find(&lists) if err != nil { - return all, 0, 0, err + return nil, 0, 0, err + } + + numberOfTotalItems, err = x. + Table("namespaces"). + Join("LEFT", "team_namespaces", "namespaces.id = team_namespaces.namespace_id"). + Join("LEFT", "team_members", "team_members.team_id = team_namespaces.team_id"). + Join("LEFT", "users_namespace", "users_namespace.namespace_id = namespaces.id"). + 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.title LIKE ?", "%"+search+"%"). + Count(&NamespaceWithLists{}) + if err != nil { + return nil, 0, 0, err + } + + /////////////// + // Shared Lists + + // Create our pseudo namespace to hold the shared lists + sharedListsPseudonamespace := SharedListsPseudoNamespace + sharedListsPseudonamespace.Owner = doer + namespaces[sharedListsPseudonamespace.ID] = &NamespaceWithLists{ + sharedListsPseudonamespace, + []*List{}, } // Get all lists individually shared with our user (not via a namespace) @@ -287,9 +310,9 @@ func (n *Namespace) ReadAll(a web.Auth, search string, page int, perPage int) (r lists = append(lists, l) } - // Remove the pseudonamespace if we don't have any shared lists + // Remove the sharedListsPseudonamespace if we don't have any shared lists if len(individualLists) == 0 { - all = append(all[:1], all[2:]...) + delete(namespaces, sharedListsPseudonamespace.ID) } // More details for the lists @@ -298,22 +321,23 @@ func (n *Namespace) ReadAll(a web.Auth, search string, page int, perPage int) (r return nil, 0, 0, err } - nMap := make(map[int64]*NamespaceWithLists, len(all)) + ///////////////// + // Favorite lists - // Put objects in our namespace list - for _, n := range all { - - // Users - n.Owner = userMap[n.OwnerID] - - nMap[n.ID] = n + // Create our pseudo namespace with favorite lists + pseudoFavoriteNamespace := FavoritesPseudoNamespace + pseudoFavoriteNamespace.Owner = doer + namespaces[pseudoFavoriteNamespace.ID] = &NamespaceWithLists{ + Namespace: pseudoFavoriteNamespace, + Lists: []*List{{}}, } + *namespaces[pseudoFavoriteNamespace.ID].Lists[0] = FavoritesPseudoList // Copying the list to be able to modify it later for _, list := range lists { if list.IsFavorite { - nMap[pseudoFavoriteNamespace.ID].Lists = append(nMap[pseudoFavoriteNamespace.ID].Lists, list) + namespaces[pseudoFavoriteNamespace.ID].Lists = append(namespaces[pseudoFavoriteNamespace.ID].Lists, list) } - nMap[list.NamespaceID].Lists = append(nMap[list.NamespaceID].Lists, list) + namespaces[list.NamespaceID].Lists = append(namespaces[list.NamespaceID].Lists, list) } // Check if we have any favorites or favorited lists and remove the favorites namespace from the list if not @@ -329,35 +353,58 @@ func (n *Namespace) ReadAll(a web.Auth, search string, page int, perPage int) (r // If we don't have any favorites in the favorites pseudo list, remove that pseudo list from the namespace if favoriteCount == 0 { - for in, l := range nMap[pseudoFavoriteNamespace.ID].Lists { + for in, l := range namespaces[pseudoFavoriteNamespace.ID].Lists { if l.ID == FavoritesPseudoList.ID { - nMap[pseudoFavoriteNamespace.ID].Lists = append(nMap[pseudoFavoriteNamespace.ID].Lists[:in], nMap[pseudoFavoriteNamespace.ID].Lists[in+1:]...) + namespaces[pseudoFavoriteNamespace.ID].Lists = append(namespaces[pseudoFavoriteNamespace.ID].Lists[:in], namespaces[pseudoFavoriteNamespace.ID].Lists[in+1:]...) break } } } // If we don't have any favorites in the namespace, remove it - if len(nMap[pseudoFavoriteNamespace.ID].Lists) == 0 { - all = append(all[:0], all[1:]...) + if len(namespaces[pseudoFavoriteNamespace.ID].Lists) == 0 { + delete(namespaces, pseudoFavoriteNamespace.ID) } - numberOfTotalItems, err = x. - Table("namespaces"). - Join("LEFT", "team_namespaces", "namespaces.id = team_namespaces.namespace_id"). - Join("LEFT", "team_members", "team_members.team_id = team_namespaces.team_id"). - Join("LEFT", "users_namespace", "users_namespace.namespace_id = namespaces.id"). - 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.title LIKE ?", "%"+search+"%"). - Count(&NamespaceWithLists{}) + ///////////////// + // Saved Filters + + savedFilters, err := getSavedFiltersForUser(a) if err != nil { - return all, 0, 0, err + return nil, 0, 0, err } + if len(savedFilters) > 0 { + savedFiltersPseudoNamespace := SavedFiltersPseudoNamespace + savedFiltersPseudoNamespace.Owner = doer + namespaces[savedFiltersPseudoNamespace.ID] = &NamespaceWithLists{ + Namespace: savedFiltersPseudoNamespace, + Lists: make([]*List, 0, len(savedFilters)), + } + + for _, filter := range savedFilters { + namespaces[savedFiltersPseudoNamespace.ID].Lists = append(namespaces[savedFiltersPseudoNamespace.ID].Lists, &List{ + ID: getListIDFromSavedFilterID(filter.ID), + Title: filter.Title, + Description: filter.Description, + Created: filter.Created, + Updated: filter.Updated, + Owner: doer, + }) + } + } + + ////////////////////// + // Put it all together (and sort it) + all := make([]*NamespaceWithLists, 0, len(namespaces)) + for _, n := range namespaces { + n.Owner = userMap[n.OwnerID] + all = append(all, n) + } + sort.Slice(all, func(i, j int) bool { + return all[i].ID < all[j].ID + }) + return all, len(all), numberOfTotalItems, nil } diff --git a/pkg/models/namespace_test.go b/pkg/models/namespace_test.go index 7afcac578..5e7667f25 100644 --- a/pkg/models/namespace_test.go +++ b/pkg/models/namespace_test.go @@ -142,12 +142,13 @@ func TestNamespace_ReadAll(t *testing.T) { t.Run("normal", func(t *testing.T) { n := &Namespace{} nn, _, _, err := n.ReadAll(user1, "", 1, -1) - namespaces := nn.([]*NamespaceWithLists) assert.NoError(t, err) + namespaces := nn.([]*NamespaceWithLists) assert.NotNil(t, namespaces) - assert.Len(t, namespaces, 10) // Total of 10 including shared & favorites - assert.Equal(t, int64(-2), namespaces[0].ID) // The first one should be the one with favorites - assert.Equal(t, int64(-1), namespaces[1].ID) // The second one should be the one with the shared namespaces + assert.Len(t, namespaces, 11) // Total of 10 including shared, favorites and saved filters + assert.Equal(t, int64(-3), namespaces[0].ID) // The first one should be the one with shared filters + assert.Equal(t, int64(-2), namespaces[1].ID) // The second one should be the one with favorites + assert.Equal(t, int64(-1), namespaces[2].ID) // The third one should be the one with the shared namespaces // Ensure every list and namespace are not archived for _, namespace := range namespaces { assert.False(t, namespace.IsArchived) @@ -164,9 +165,10 @@ func TestNamespace_ReadAll(t *testing.T) { namespaces := nn.([]*NamespaceWithLists) assert.NoError(t, err) assert.NotNil(t, namespaces) - assert.Len(t, namespaces, 11) // Total of 11 including shared & favorites, one is archived - assert.Equal(t, int64(-2), namespaces[0].ID) // The first one should be the one with favorites - assert.Equal(t, int64(-1), namespaces[1].ID) // The second one should be the one with the shared namespaces + assert.Len(t, namespaces, 12) // Total of 12 including shared & favorites, one is archived + assert.Equal(t, int64(-3), namespaces[0].ID) // The first one should be the one with shared filters + assert.Equal(t, int64(-2), namespaces[1].ID) // The second one should be the one with favorites + assert.Equal(t, int64(-1), namespaces[2].ID) // The third one should be the one with the shared namespaces }) t.Run("no favorites", func(t *testing.T) { n := &Namespace{} @@ -185,4 +187,12 @@ func TestNamespace_ReadAll(t *testing.T) { assert.Equal(t, FavoritesPseudoNamespace.ID, namespaces[0].ID) assert.NotEqual(t, 0, namespaces[0].Lists) }) + t.Run("no saved filters", func(t *testing.T) { + n := &Namespace{} + nn, _, _, err := n.ReadAll(user11, "", 1, -1) + namespaces := nn.([]*NamespaceWithLists) + assert.NoError(t, err) + // Assert the first namespace is not the favorites namespace + assert.NotEqual(t, SavedFiltersPseudoNamespace.ID, namespaces[0].ID) + }) } diff --git a/pkg/models/saved_filters.go b/pkg/models/saved_filters.go new file mode 100644 index 000000000..18a0bbce5 --- /dev/null +++ b/pkg/models/saved_filters.go @@ -0,0 +1,182 @@ +// Vikunja is a to-do 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 models + +import ( + "code.vikunja.io/api/pkg/user" + "code.vikunja.io/web" + "time" +) + +// SavedFilter represents a saved bunch of filters +type SavedFilter struct { + // The unique numeric id of this saved filter + ID int64 `xorm:"autoincr not null unique pk" json:"id" param:"filter"` + // The actual filters this filter contains + Filters *TaskCollection `xorm:"JSON not null" json:"filters"` + // The title of the filter. + Title string `xorm:"varchar(250) not null" json:"title" valid:"required,runelength(1|250)" minLength:"1" maxLength:"250"` + // The description of the filter + Description string `xorm:"longtext null" json:"description"` + OwnerID int64 `xorm:"int(11) not null INDEX" json:"-"` + + // The user who owns this filter + Owner *user.User `xorm:"-" json:"owner" valid:"-"` + + // A timestamp when this filter was created. You cannot change this value. + Created time.Time `xorm:"created not null" json:"created"` + // A timestamp when this filter was last updated. You cannot change this value. + Updated time.Time `xorm:"updated not null" json:"updated"` + + web.CRUDable `xorm:"-" json:"-"` + web.Rights `xorm:"-" json:"-"` +} + +// TableName returns a better table name for saved filters +func (s *SavedFilter) TableName() string { + return "saved_filters" +} + +func (s *SavedFilter) getTaskCollection() *TaskCollection { + // We're resetting the listID to return tasks from all lists + s.Filters.ListID = 0 + return s.Filters +} + +// Returns the saved filter ID from a list ID. Will not check if the filter actually exists. +// If the returned ID is zero, means that it is probably invalid. +func getSavedFilterIDFromListID(listID int64) (filterID int64) { + // We get the id of the saved filter by multiplying the ListID with -1 and subtracting one + filterID = listID*-1 - 1 + // FilterIDs from listIDs are always positive + if filterID < 0 { + filterID = 0 + } + return +} + +func getListIDFromSavedFilterID(filterID int64) (listID int64) { + listID = filterID*-1 - 1 + // ListIDs from saved filters are always negative + if listID > 0 { + listID = 0 + } + return +} + +func getSavedFiltersForUser(auth web.Auth) (filters []*SavedFilter, err error) { + // Link shares can't view or modify saved filters, therefore we can error out right away + if _, is := auth.(*LinkSharing); is { + return nil, ErrSavedFilterNotAvailableForLinkShare{LinkShareID: auth.GetID()} + } + + err = x.Where("owner_id = ?", auth.GetID()).Find(&filters) + return +} + +// Create creates a new saved filter +// @Summary Creates a new saved filter +// @Description Creates a new saved filter +// @tags filter +// @Accept json +// @Produce json +// @Security JWTKeyAuth +// @Success 200 {object} models.SavedFilter "The Saved Filter" +// @Failure 403 {object} web.HTTPError "The user does not have access to that saved filter." +// @Failure 500 {object} models.Message "Internal error" +// @Router /filters [put] +func (s *SavedFilter) Create(auth web.Auth) error { + s.OwnerID = auth.GetID() + _, err := x.Insert(s) + return err +} + +func getSavedFilterSimpleByID(id int64) (s *SavedFilter, err error) { + s = &SavedFilter{} + exists, err := x. + Where("id = ?", id). + Get(s) + if err != nil { + return nil, err + } + if !exists { + return nil, ErrSavedFilterDoesNotExist{SavedFilterID: id} + } + return +} + +// ReadOne returns one saved filter +// @Summary Gets one saved filter +// @Description Returns a saved filter by its ID. +// @tags filter +// @Accept json +// @Produce json +// @Security JWTKeyAuth +// @Param id path int true "Filter ID" +// @Success 200 {object} models.SavedFilter "The Saved Filter" +// @Failure 403 {object} web.HTTPError "The user does not have access to that saved filter." +// @Failure 500 {object} models.Message "Internal error" +// @Router /filters/{id} [get] +func (s *SavedFilter) ReadOne() error { + // s already contains almost the full saved filter from the rights check, we only need to add the user + u, err := user.GetUserByID(s.OwnerID) + s.Owner = u + return err +} + +// Update updates an existing filter +// @Summary Updates a saved filter +// @Description Updates a saved filter by its ID. +// @tags filter +// @Accept json +// @Produce json +// @Security JWTKeyAuth +// @Param id path int true "Filter ID" +// @Success 200 {object} models.SavedFilter "The Saved Filter" +// @Failure 403 {object} web.HTTPError "The user does not have access to that saved filter." +// @Failure 404 {object} web.HTTPError "The saved filter does not exist." +// @Failure 500 {object} models.Message "Internal error" +// @Router /filters/{id} [post] +func (s *SavedFilter) Update() error { + _, err := x. + Where("id = ?", s.ID). + Cols( + "title", + "description", + "filters", + ). + Update(s) + return err +} + +// Delete removes a saved filter +// @Summary Removes a saved filter +// @Description Removes a saved filter by its ID. +// @tags filter +// @Accept json +// @Produce json +// @Security JWTKeyAuth +// @Param id path int true "Filter ID" +// @Success 200 {object} models.SavedFilter "The Saved Filter" +// @Failure 403 {object} web.HTTPError "The user does not have access to that saved filter." +// @Failure 404 {object} web.HTTPError "The saved filter does not exist." +// @Failure 500 {object} models.Message "Internal error" +// @Router /filters/{id} [delete] +func (s *SavedFilter) Delete() error { + _, err := x.Where("id = ?", s.ID).Delete(s) + return err +} diff --git a/pkg/models/saved_filters_rights.go b/pkg/models/saved_filters_rights.go new file mode 100644 index 000000000..2e869fe8d --- /dev/null +++ b/pkg/models/saved_filters_rights.go @@ -0,0 +1,68 @@ +// Vikunja is a to-do 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 models + +import "code.vikunja.io/web" + +// CanRead checks if a user has the right to read a saved filter +func (s *SavedFilter) CanRead(auth web.Auth) (bool, int, error) { + can, err := s.canDoFilter(auth) + return can, int(RightAdmin), err +} + +// CanDelete checks if a user has the right to delete a saved filter +func (s *SavedFilter) CanDelete(auth web.Auth) (bool, error) { + return s.canDoFilter(auth) +} + +// CanUpdate checks if a user has the right to update a saved filter +func (s *SavedFilter) CanUpdate(auth web.Auth) (bool, error) { + // A normal check would replace the passed struct which in our case would override the values we want to update. + sf := &SavedFilter{ID: s.ID} + return sf.canDoFilter(auth) +} + +// CanCreate checks if a user has the right to update a saved filter +func (s *SavedFilter) CanCreate(auth web.Auth) (bool, error) { + if _, is := auth.(*LinkSharing); is { + return false, nil + } + + return true, nil +} + +// Helper function to check saved filter rights sind they all have the same logic +func (s *SavedFilter) canDoFilter(auth web.Auth) (can bool, err error) { + // Link shares can't view or modify saved filters, therefore we can error out right away + if _, is := auth.(*LinkSharing); is { + return false, ErrSavedFilterNotAvailableForLinkShare{LinkShareID: auth.GetID(), SavedFilterID: s.ID} + } + + sf, err := getSavedFilterSimpleByID(s.ID) + if err != nil { + return false, err + } + + // Only owners are allowed to do something with a saved filter + if sf.OwnerID != auth.GetID() { + return false, nil + } + + *s = *sf + + return true, nil +} diff --git a/pkg/models/saved_filters_test.go b/pkg/models/saved_filters_test.go new file mode 100644 index 000000000..4656f09b7 --- /dev/null +++ b/pkg/models/saved_filters_test.go @@ -0,0 +1,257 @@ +// Vikunja is a to-do 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 models + +import ( + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/user" + "github.com/stretchr/testify/assert" + "testing" + "xorm.io/xorm/schemas" +) + +func TestSavedFilter_getListIDFromFilter(t *testing.T) { + t.Run("normal", func(t *testing.T) { + assert.Equal(t, int64(-2), getListIDFromSavedFilterID(1)) + }) + t.Run("invalid", func(t *testing.T) { + assert.Equal(t, int64(0), getListIDFromSavedFilterID(-1)) + }) +} + +func TestSavedFilter_getFilterIDFromListID(t *testing.T) { + t.Run("normal", func(t *testing.T) { + assert.Equal(t, int64(1), getSavedFilterIDFromListID(-2)) + }) + t.Run("invalid", func(t *testing.T) { + assert.Equal(t, int64(0), getSavedFilterIDFromListID(2)) + }) +} + +func TestSavedFilter_Create(t *testing.T) { + db.LoadAndAssertFixtures(t) + sf := &SavedFilter{ + Title: "test", + Description: "Lorem Ipsum dolor sit amet", + Filters: &TaskCollection{}, // Empty filter + } + + u := &user.User{ID: 1} + err := sf.Create(u) + assert.NoError(t, err) + assert.Equal(t, u.ID, sf.OwnerID) + vals := map[string]interface{}{ + "title": "'test'", + "description": "'Lorem Ipsum dolor sit amet'", + "filters": "'{\"sort_by\":null,\"order_by\":null,\"filter_by\":null,\"filter_value\":null,\"filter_comparator\":null,\"filter_concat\":\"\",\"filter_include_nulls\":false}'", + "owner_id": 1, + } + // Postgres can't compare json values directly, see https://dba.stackexchange.com/a/106290/210721 + if x.Dialect().URI().DBType == schemas.POSTGRES { + vals["filters::jsonb"] = vals["filters"].(string) + "::jsonb" + delete(vals, "filters") + } + db.AssertDBExists(t, "saved_filters", vals, true) +} + +func TestSavedFilter_ReadOne(t *testing.T) { + user1 := &user.User{ID: 1} + db.LoadAndAssertFixtures(t) + sf := &SavedFilter{ + ID: 1, + } + // canRead pre-populates the struct + _, _, err := sf.CanRead(user1) + assert.NoError(t, err) + err = sf.ReadOne() + assert.NoError(t, err) + assert.NotNil(t, sf.Owner) +} + +func TestSavedFilter_Update(t *testing.T) { + db.LoadAndAssertFixtures(t) + sf := &SavedFilter{ + ID: 1, + Title: "NewTitle", + Description: "", // Explicitly reset the description + Filters: &TaskCollection{}, + } + err := sf.Update() + assert.NoError(t, err) + db.AssertDBExists(t, "saved_filters", map[string]interface{}{ + "id": 1, + "title": "NewTitle", + "description": "", + }, false) +} + +func TestSavedFilter_Delete(t *testing.T) { + db.LoadAndAssertFixtures(t) + sf := &SavedFilter{ + ID: 1, + } + err := sf.Delete() + assert.NoError(t, err) + db.AssertDBMissing(t, "saved_filters", map[string]interface{}{ + "id": 1, + }) +} + +func TestSavedFilter_Rights(t *testing.T) { + user1 := &user.User{ID: 1} + user2 := &user.User{ID: 2} + ls := &LinkSharing{ID: 1} + + t.Run("create", func(t *testing.T) { + // Should always be true + db.LoadAndAssertFixtures(t) + can, err := (&SavedFilter{}).CanCreate(user1) + assert.NoError(t, err) + assert.True(t, can) + }) + t.Run("read", func(t *testing.T) { + t.Run("owner", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + sf := &SavedFilter{ + ID: 1, + Title: "Lorem", + } + can, max, err := sf.CanRead(user1) + assert.NoError(t, err) + assert.Equal(t, int(RightAdmin), max) + assert.True(t, can) + }) + t.Run("not owner", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + sf := &SavedFilter{ + ID: 1, + Title: "Lorem", + } + can, _, err := sf.CanRead(user2) + assert.NoError(t, err) + assert.False(t, can) + }) + t.Run("nonexisting", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + sf := &SavedFilter{ + ID: 9999, + Title: "Lorem", + } + can, _, err := sf.CanRead(user1) + assert.Error(t, err) + assert.True(t, IsErrSavedFilterDoesNotExist(err)) + assert.False(t, can) + }) + t.Run("link share", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + sf := &SavedFilter{ + ID: 1, + Title: "Lorem", + } + can, _, err := sf.CanRead(ls) + assert.Error(t, err) + assert.True(t, IsErrSavedFilterNotAvailableForLinkShare(err)) + assert.False(t, can) + }) + }) + t.Run("update", func(t *testing.T) { + t.Run("owner", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + sf := &SavedFilter{ + ID: 1, + Title: "Lorem", + } + can, err := sf.CanUpdate(user1) + assert.NoError(t, err) + assert.True(t, can) + }) + t.Run("not owner", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + sf := &SavedFilter{ + ID: 1, + Title: "Lorem", + } + can, err := sf.CanUpdate(user2) + assert.NoError(t, err) + assert.False(t, can) + }) + t.Run("nonexisting", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + sf := &SavedFilter{ + ID: 9999, + Title: "Lorem", + } + can, err := sf.CanUpdate(user1) + assert.Error(t, err) + assert.True(t, IsErrSavedFilterDoesNotExist(err)) + assert.False(t, can) + }) + t.Run("link share", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + sf := &SavedFilter{ + ID: 1, + Title: "Lorem", + } + can, err := sf.CanUpdate(ls) + assert.Error(t, err) + assert.True(t, IsErrSavedFilterNotAvailableForLinkShare(err)) + assert.False(t, can) + }) + }) + t.Run("delete", func(t *testing.T) { + t.Run("owner", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + sf := &SavedFilter{ + ID: 1, + } + can, err := sf.CanDelete(user1) + assert.NoError(t, err) + assert.True(t, can) + }) + t.Run("not owner", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + sf := &SavedFilter{ + ID: 1, + } + can, err := sf.CanDelete(user2) + assert.NoError(t, err) + assert.False(t, can) + }) + t.Run("nonexisting", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + sf := &SavedFilter{ + ID: 9999, + Title: "Lorem", + } + can, err := sf.CanDelete(user1) + assert.Error(t, err) + assert.True(t, IsErrSavedFilterDoesNotExist(err)) + assert.False(t, can) + }) + t.Run("link share", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + sf := &SavedFilter{ + ID: 1, + Title: "Lorem", + } + can, err := sf.CanDelete(ls) + assert.Error(t, err) + assert.True(t, IsErrSavedFilterNotAvailableForLinkShare(err)) + assert.False(t, can) + }) + }) +} diff --git a/pkg/models/task_collection.go b/pkg/models/task_collection.go index a7eb1d184..e1b765234 100644 --- a/pkg/models/task_collection.go +++ b/pkg/models/task_collection.go @@ -24,29 +24,29 @@ import ( // TaskCollection is a struct used to hold filter details and not clutter the Task struct with information not related to actual tasks. type TaskCollection struct { - ListID int64 `param:"list"` - Lists []*List + ListID int64 `param:"list" json:"-"` + Lists []*List `json:"-"` // The query parameter to sort by. This is for ex. done, priority, etc. - SortBy []string `query:"sort_by"` - SortByArr []string `query:"sort_by[]"` + SortBy []string `query:"sort_by" json:"sort_by"` + SortByArr []string `query:"sort_by[]" json:"-"` // The query parameter to order the items by. This can be either asc or desc, with asc being the default. - OrderBy []string `query:"order_by"` - OrderByArr []string `query:"order_by[]"` + OrderBy []string `query:"order_by" json:"order_by"` + OrderByArr []string `query:"order_by[]" json:"-"` // The field name of the field to filter by - FilterBy []string `query:"filter_by"` - FilterByArr []string `query:"filter_by[]"` + FilterBy []string `query:"filter_by" json:"filter_by"` + FilterByArr []string `query:"filter_by[]" json:"-"` // The value of the field name to filter by - FilterValue []string `query:"filter_value"` - FilterValueArr []string `query:"filter_value[]"` + FilterValue []string `query:"filter_value" json:"filter_value"` + FilterValueArr []string `query:"filter_value[]" json:"-"` // The comparator for field and value - FilterComparator []string `query:"filter_comparator"` - FilterComparatorArr []string `query:"filter_comparator[]"` + FilterComparator []string `query:"filter_comparator" json:"filter_comparator"` + FilterComparatorArr []string `query:"filter_comparator[]" json:"-"` // The way all filter conditions are concatenated together, can be either "and" or "or"., - FilterConcat string `query:"filter_concat"` + FilterConcat string `query:"filter_concat" json:"filter_concat"` // If set to true, the result will also include null values - FilterIncludeNulls bool `query:"filter_include_nulls"` + FilterIncludeNulls bool `query:"filter_include_nulls" json:"filter_include_nulls"` web.CRUDable `xorm:"-" json:"-"` web.Rights `xorm:"-" json:"-"` @@ -102,6 +102,17 @@ func validateTaskField(fieldName string) error { // @Router /lists/{listID}/tasks [get] func (tf *TaskCollection) ReadAll(a web.Auth, search string, page int, perPage int) (result interface{}, resultCount int, totalItems int64, err error) { + // If the list id is < -1 this means we're dealing with a saved filter - in that case we get and populate the filter + // -1 is the favorites list which works as intended + if tf.ListID < -1 { + s, err := getSavedFilterSimpleByID(getSavedFilterIDFromListID(tf.ListID)) + if err != nil { + return nil, 0, 0, err + } + + return s.getTaskCollection().ReadAll(a, search, page, perPage) + } + if len(tf.SortByArr) > 0 { tf.SortBy = append(tf.SortBy, tf.SortByArr...) } diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index af86d83ff..2d1d4533b 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -37,7 +37,7 @@ type Task struct { // The unique, numeric id of this task. ID int64 `xorm:"int(11) autoincr not null unique pk" json:"id" param:"listtask"` // The task text. This is what you'll see in the list. - Title string `xorm:"varchar(250) not null" json:"title" valid:"runelength(1|250)" minLength:"3" maxLength:"250"` + Title string `xorm:"varchar(250) not null" json:"title" valid:"runelength(1|250)" minLength:"1" maxLength:"250"` // The task description. Description string `xorm:"longtext null" json:"description"` // Whether a task is done or not. diff --git a/pkg/models/teams.go b/pkg/models/teams.go index 781f14ba4..36817a1fa 100644 --- a/pkg/models/teams.go +++ b/pkg/models/teams.go @@ -29,7 +29,7 @@ type Team struct { // The unique, numeric id of this team. ID int64 `xorm:"int(11) autoincr not null unique pk" json:"id" param:"team"` // The name of this team. - Name string `xorm:"varchar(250) not null" json:"name" valid:"required,runelength(1|250)" minLength:"5" maxLength:"250"` + Name string `xorm:"varchar(250) not null" json:"name" valid:"required,runelength(1|250)" minLength:"1" maxLength:"250"` // The team's description. Description string `xorm:"longtext null" json:"description"` CreatedByID int64 `xorm:"int(11) not null INDEX" json:"-"` diff --git a/pkg/models/unit_tests.go b/pkg/models/unit_tests.go index 4fc373205..df3e83687 100644 --- a/pkg/models/unit_tests.go +++ b/pkg/models/unit_tests.go @@ -58,6 +58,7 @@ func SetupTests() { "users_list", "users_namespace", "buckets", + "saved_filters", ) if err != nil { log.Fatal(err) diff --git a/pkg/routes/api/v1/list_by_namespace.go b/pkg/routes/api/v1/list_by_namespace.go index 3142b1d98..a09b3110c 100644 --- a/pkg/routes/api/v1/list_by_namespace.go +++ b/pkg/routes/api/v1/list_by_namespace.go @@ -69,7 +69,7 @@ func getNamespace(c echo.Context) (namespace *models.Namespace, err error) { } if namespaceID == -1 { - namespace = &models.PseudoNamespace + namespace = &models.SharedListsPseudoNamespace return } diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 810ed1b72..ffbb848db 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -431,6 +431,16 @@ func registerAPIRoutes(a *echo.Group) { a.DELETE("/lists/:list/users/:user", listUserHandler.DeleteWeb) a.POST("/lists/:list/users/:user", listUserHandler.UpdateWeb) + savedFiltersHandler := &handler.WebHandler{ + EmptyStruct: func() handler.CObject { + return &models.SavedFilter{} + }, + } + a.GET("/filters/:filter", savedFiltersHandler.ReadOneWeb) + a.PUT("/filters", savedFiltersHandler.CreateWeb) + a.DELETE("/filters/:filter", savedFiltersHandler.DeleteWeb) + a.POST("/filters/:filter", savedFiltersHandler.UpdateWeb) + namespaceHandler := &handler.WebHandler{ EmptyStruct: func() handler.CObject { return &models.Namespace{} diff --git a/pkg/swagger/docs.go b/pkg/swagger/docs.go index 75c69b7ff..bea8591ac 100644 --- a/pkg/swagger/docs.go +++ b/pkg/swagger/docs.go @@ -172,6 +172,201 @@ var doc = `{ } } }, + "/filters": { + "put": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Creates a new saved filter", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "filter" + ], + "summary": "Creates a new saved filter", + "responses": { + "200": { + "description": "The Saved Filter", + "schema": { + "$ref": "#/definitions/models.SavedFilter" + } + }, + "403": { + "description": "The user does not have access to that saved filter.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, + "/filters/{id}": { + "get": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Returns a saved filter by its ID.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "filter" + ], + "summary": "Gets one saved filter", + "parameters": [ + { + "type": "integer", + "description": "Filter ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "The Saved Filter", + "schema": { + "$ref": "#/definitions/models.SavedFilter" + } + }, + "403": { + "description": "The user does not have access to that saved filter.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + }, + "post": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Updates a saved filter by its ID.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "filter" + ], + "summary": "Updates a saved filter", + "parameters": [ + { + "type": "integer", + "description": "Filter ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "The Saved Filter", + "schema": { + "$ref": "#/definitions/models.SavedFilter" + } + }, + "403": { + "description": "The user does not have access to that saved filter.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "404": { + "description": "The saved filter does not exist.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + }, + "delete": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Removes a saved filter by its ID.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "filter" + ], + "summary": "Removes a saved filter", + "parameters": [ + { + "type": "integer", + "description": "Filter ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "The Saved Filter", + "schema": { + "$ref": "#/definitions/models.SavedFilter" + } + }, + "403": { + "description": "The user does not have access to that saved filter.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "404": { + "description": "The saved filter does not exist.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, "/info": { "get": { "description": "Returns the version, frontendurl, motd and various settings of Vikunja", @@ -6403,7 +6598,7 @@ var doc = `{ "description": "The task text. This is what you'll see in the list.", "type": "string", "maxLength": 250, - "minLength": 3 + "minLength": 1 }, "updated": { "description": "A timestamp when this task was last updated. You cannot change this value.", @@ -6440,7 +6635,7 @@ var doc = `{ "description": "The title of the lable. You'll see this one on tasks associated with it.", "type": "string", "maxLength": 250, - "minLength": 3 + "minLength": 1 }, "updated": { "description": "A timestamp when this label was last updated. You cannot change this value.", @@ -6561,7 +6756,7 @@ var doc = `{ "description": "The title of the list. You'll see this in the namespace overview.", "type": "string", "maxLength": 250, - "minLength": 3 + "minLength": 1 }, "updated": { "description": "A timestamp when this list was last updated. You cannot change this value.", @@ -6652,7 +6847,7 @@ var doc = `{ "description": "The name of this namespace.", "type": "string", "maxLength": 250, - "minLength": 5 + "minLength": 1 }, "updated": { "description": "A timestamp when this namespace was last updated. You cannot change this value.", @@ -6726,7 +6921,7 @@ var doc = `{ "description": "The name of this namespace.", "type": "string", "maxLength": 250, - "minLength": 5 + "minLength": 1 }, "updated": { "description": "A timestamp when this namespace was last updated. You cannot change this value.", @@ -6743,6 +6938,43 @@ var doc = `{ } } }, + "models.SavedFilter": { + "type": "object", + "properties": { + "created": { + "description": "A timestamp when this filter was created. You cannot change this value.", + "type": "string" + }, + "description": { + "description": "The description of the filter", + "type": "string" + }, + "filters": { + "description": "The actual filters this filter contains", + "type": "object", + "$ref": "#/definitions/models.TaskCollection" + }, + "id": { + "description": "The unique numeric id of this saved filter", + "type": "integer" + }, + "owner": { + "description": "The user who owns this filter", + "type": "object", + "$ref": "#/definitions/user.User" + }, + "title": { + "description": "The title of the filter.", + "type": "string", + "maxLength": 250, + "minLength": 1 + }, + "updated": { + "description": "A timestamp when this filter was last updated. You cannot change this value.", + "type": "string" + } + } + }, "models.Task": { "type": "object", "properties": { @@ -6865,7 +7097,7 @@ var doc = `{ "description": "The task text. This is what you'll see in the list.", "type": "string", "maxLength": 250, - "minLength": 3 + "minLength": 1 }, "updated": { "description": "A timestamp when this task was last updated. You cannot change this value.", @@ -6906,6 +7138,54 @@ var doc = `{ } } }, + "models.TaskCollection": { + "type": "object", + "properties": { + "filter_by": { + "description": "The field name of the field to filter by", + "type": "array", + "items": { + "type": "string" + } + }, + "filter_comparator": { + "description": "The comparator for field and value", + "type": "array", + "items": { + "type": "string" + } + }, + "filter_concat": { + "description": "The way all filter conditions are concatenated together, can be either \"and\" or \"or\".,", + "type": "string" + }, + "filter_include_nulls": { + "description": "If set to true, the result will also include null values", + "type": "boolean" + }, + "filter_value": { + "description": "The value of the field name to filter by", + "type": "array", + "items": { + "type": "string" + } + }, + "order_by": { + "description": "The query parameter to order the items by. This can be either asc or desc, with asc being the default.", + "type": "array", + "items": { + "type": "string" + } + }, + "sort_by": { + "description": "The query parameter to sort by. This is for ex. done, priority, etc.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, "models.TaskComment": { "type": "object", "properties": { @@ -6984,7 +7264,7 @@ var doc = `{ "description": "The name of this team.", "type": "string", "maxLength": 250, - "minLength": 5 + "minLength": 1 }, "updated": { "description": "A timestamp when this relation was last updated. You cannot change this value.", @@ -7095,7 +7375,7 @@ var doc = `{ "description": "The username of the user. Is always unique.", "type": "string", "maxLength": 250, - "minLength": 3 + "minLength": 1 } } }, @@ -7130,7 +7410,7 @@ var doc = `{ "description": "The name of this team.", "type": "string", "maxLength": 250, - "minLength": 5 + "minLength": 1 }, "right": { "type": "integer" @@ -7168,7 +7448,7 @@ var doc = `{ "description": "The username of the user. Is always unique.", "type": "string", "maxLength": 250, - "minLength": 3 + "minLength": 1 } } }, @@ -7315,7 +7595,7 @@ var doc = `{ "description": "The username of the user. Is always unique.", "type": "string", "maxLength": 250, - "minLength": 3 + "minLength": 1 } } }, diff --git a/pkg/swagger/swagger.json b/pkg/swagger/swagger.json index 14bebf079..d9633e901 100644 --- a/pkg/swagger/swagger.json +++ b/pkg/swagger/swagger.json @@ -155,6 +155,201 @@ } } }, + "/filters": { + "put": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Creates a new saved filter", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "filter" + ], + "summary": "Creates a new saved filter", + "responses": { + "200": { + "description": "The Saved Filter", + "schema": { + "$ref": "#/definitions/models.SavedFilter" + } + }, + "403": { + "description": "The user does not have access to that saved filter.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, + "/filters/{id}": { + "get": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Returns a saved filter by its ID.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "filter" + ], + "summary": "Gets one saved filter", + "parameters": [ + { + "type": "integer", + "description": "Filter ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "The Saved Filter", + "schema": { + "$ref": "#/definitions/models.SavedFilter" + } + }, + "403": { + "description": "The user does not have access to that saved filter.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + }, + "post": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Updates a saved filter by its ID.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "filter" + ], + "summary": "Updates a saved filter", + "parameters": [ + { + "type": "integer", + "description": "Filter ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "The Saved Filter", + "schema": { + "$ref": "#/definitions/models.SavedFilter" + } + }, + "403": { + "description": "The user does not have access to that saved filter.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "404": { + "description": "The saved filter does not exist.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + }, + "delete": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Removes a saved filter by its ID.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "filter" + ], + "summary": "Removes a saved filter", + "parameters": [ + { + "type": "integer", + "description": "Filter ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "The Saved Filter", + "schema": { + "$ref": "#/definitions/models.SavedFilter" + } + }, + "403": { + "description": "The user does not have access to that saved filter.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "404": { + "description": "The saved filter does not exist.", + "schema": { + "$ref": "#/definitions/web.HTTPError" + } + }, + "500": { + "description": "Internal error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, "/info": { "get": { "description": "Returns the version, frontendurl, motd and various settings of Vikunja", @@ -6386,7 +6581,7 @@ "description": "The task text. This is what you'll see in the list.", "type": "string", "maxLength": 250, - "minLength": 3 + "minLength": 1 }, "updated": { "description": "A timestamp when this task was last updated. You cannot change this value.", @@ -6423,7 +6618,7 @@ "description": "The title of the lable. You'll see this one on tasks associated with it.", "type": "string", "maxLength": 250, - "minLength": 3 + "minLength": 1 }, "updated": { "description": "A timestamp when this label was last updated. You cannot change this value.", @@ -6544,7 +6739,7 @@ "description": "The title of the list. You'll see this in the namespace overview.", "type": "string", "maxLength": 250, - "minLength": 3 + "minLength": 1 }, "updated": { "description": "A timestamp when this list was last updated. You cannot change this value.", @@ -6635,7 +6830,7 @@ "description": "The name of this namespace.", "type": "string", "maxLength": 250, - "minLength": 5 + "minLength": 1 }, "updated": { "description": "A timestamp when this namespace was last updated. You cannot change this value.", @@ -6709,7 +6904,7 @@ "description": "The name of this namespace.", "type": "string", "maxLength": 250, - "minLength": 5 + "minLength": 1 }, "updated": { "description": "A timestamp when this namespace was last updated. You cannot change this value.", @@ -6726,6 +6921,43 @@ } } }, + "models.SavedFilter": { + "type": "object", + "properties": { + "created": { + "description": "A timestamp when this filter was created. You cannot change this value.", + "type": "string" + }, + "description": { + "description": "The description of the filter", + "type": "string" + }, + "filters": { + "description": "The actual filters this filter contains", + "type": "object", + "$ref": "#/definitions/models.TaskCollection" + }, + "id": { + "description": "The unique numeric id of this saved filter", + "type": "integer" + }, + "owner": { + "description": "The user who owns this filter", + "type": "object", + "$ref": "#/definitions/user.User" + }, + "title": { + "description": "The title of the filter.", + "type": "string", + "maxLength": 250, + "minLength": 1 + }, + "updated": { + "description": "A timestamp when this filter was last updated. You cannot change this value.", + "type": "string" + } + } + }, "models.Task": { "type": "object", "properties": { @@ -6848,7 +7080,7 @@ "description": "The task text. This is what you'll see in the list.", "type": "string", "maxLength": 250, - "minLength": 3 + "minLength": 1 }, "updated": { "description": "A timestamp when this task was last updated. You cannot change this value.", @@ -6889,6 +7121,54 @@ } } }, + "models.TaskCollection": { + "type": "object", + "properties": { + "filter_by": { + "description": "The field name of the field to filter by", + "type": "array", + "items": { + "type": "string" + } + }, + "filter_comparator": { + "description": "The comparator for field and value", + "type": "array", + "items": { + "type": "string" + } + }, + "filter_concat": { + "description": "The way all filter conditions are concatenated together, can be either \"and\" or \"or\".,", + "type": "string" + }, + "filter_include_nulls": { + "description": "If set to true, the result will also include null values", + "type": "boolean" + }, + "filter_value": { + "description": "The value of the field name to filter by", + "type": "array", + "items": { + "type": "string" + } + }, + "order_by": { + "description": "The query parameter to order the items by. This can be either asc or desc, with asc being the default.", + "type": "array", + "items": { + "type": "string" + } + }, + "sort_by": { + "description": "The query parameter to sort by. This is for ex. done, priority, etc.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, "models.TaskComment": { "type": "object", "properties": { @@ -6967,7 +7247,7 @@ "description": "The name of this team.", "type": "string", "maxLength": 250, - "minLength": 5 + "minLength": 1 }, "updated": { "description": "A timestamp when this relation was last updated. You cannot change this value.", @@ -7078,7 +7358,7 @@ "description": "The username of the user. Is always unique.", "type": "string", "maxLength": 250, - "minLength": 3 + "minLength": 1 } } }, @@ -7113,7 +7393,7 @@ "description": "The name of this team.", "type": "string", "maxLength": 250, - "minLength": 5 + "minLength": 1 }, "right": { "type": "integer" @@ -7151,7 +7431,7 @@ "description": "The username of the user. Is always unique.", "type": "string", "maxLength": 250, - "minLength": 3 + "minLength": 1 } } }, @@ -7298,7 +7578,7 @@ "description": "The username of the user. Is always unique.", "type": "string", "maxLength": 250, - "minLength": 3 + "minLength": 1 } } }, diff --git a/pkg/swagger/swagger.yaml b/pkg/swagger/swagger.yaml index e7c17a301..f7a86a7ad 100644 --- a/pkg/swagger/swagger.yaml +++ b/pkg/swagger/swagger.yaml @@ -182,7 +182,7 @@ definitions: title: description: The task text. This is what you'll see in the list. maxLength: 250 - minLength: 3 + minLength: 1 type: string updated: description: A timestamp when this task was last updated. You cannot change this value. @@ -210,7 +210,7 @@ definitions: title: description: The title of the lable. You'll see this one on tasks associated with it. maxLength: 250 - minLength: 3 + minLength: 1 type: string updated: description: A timestamp when this label was last updated. You cannot change this value. @@ -300,7 +300,7 @@ definitions: title: description: The title of the list. You'll see this in the namespace overview. maxLength: 250 - minLength: 3 + minLength: 1 type: string updated: description: A timestamp when this list was last updated. You cannot change this value. @@ -367,7 +367,7 @@ definitions: title: description: The name of this namespace. maxLength: 250 - minLength: 5 + minLength: 1 type: string updated: description: A timestamp when this namespace was last updated. You cannot change this value. @@ -422,7 +422,7 @@ definitions: title: description: The name of this namespace. maxLength: 250 - minLength: 5 + minLength: 1 type: string updated: description: A timestamp when this namespace was last updated. You cannot change this value. @@ -434,6 +434,34 @@ definitions: $ref: '#/definitions/models.Task' type: array type: object + models.SavedFilter: + properties: + created: + description: A timestamp when this filter was created. You cannot change this value. + type: string + description: + description: The description of the filter + type: string + filters: + $ref: '#/definitions/models.TaskCollection' + description: The actual filters this filter contains + type: object + id: + description: The unique numeric id of this saved filter + type: integer + owner: + $ref: '#/definitions/user.User' + description: The user who owns this filter + type: object + title: + description: The title of the filter. + maxLength: 250 + minLength: 1 + type: string + updated: + description: A timestamp when this filter was last updated. You cannot change this value. + type: string + type: object models.Task: properties: assignees: @@ -531,7 +559,7 @@ definitions: title: description: The task text. This is what you'll see in the list. maxLength: 250 - minLength: 3 + minLength: 1 type: string updated: description: A timestamp when this task was last updated. You cannot change this value. @@ -559,6 +587,40 @@ definitions: task_id: type: integer type: object + models.TaskCollection: + properties: + filter_by: + description: The field name of the field to filter by + items: + type: string + type: array + filter_comparator: + description: The comparator for field and value + items: + type: string + type: array + filter_concat: + description: The way all filter conditions are concatenated together, can be either "and" or "or"., + type: string + filter_include_nulls: + description: If set to true, the result will also include null values + type: boolean + filter_value: + description: The value of the field name to filter by + items: + type: string + type: array + order_by: + description: The query parameter to order the items by. This can be either asc or desc, with asc being the default. + items: + type: string + type: array + sort_by: + description: The query parameter to sort by. This is for ex. done, priority, etc. + items: + type: string + type: array + type: object models.TaskComment: properties: author: @@ -615,7 +677,7 @@ definitions: name: description: The name of this team. maxLength: 250 - minLength: 5 + minLength: 1 type: string updated: description: A timestamp when this relation was last updated. You cannot change this value. @@ -697,7 +759,7 @@ definitions: username: description: The username of the user. Is always unique. maxLength: 250 - minLength: 3 + minLength: 1 type: string type: object models.TeamWithRight: @@ -723,7 +785,7 @@ definitions: name: description: The name of this team. maxLength: 250 - minLength: 5 + minLength: 1 type: string right: type: integer @@ -751,7 +813,7 @@ definitions: username: description: The username of the user. Is always unique. maxLength: 250 - minLength: 3 + minLength: 1 type: string type: object todoist.Migration: @@ -855,7 +917,7 @@ definitions: username: description: The username of the user. Is always unique. maxLength: 250 - minLength: 3 + minLength: 1 type: string type: object v1.Token: @@ -1069,6 +1131,130 @@ paths: summary: Search for a background from unsplash tags: - list + /filters: + put: + consumes: + - application/json + description: Creates a new saved filter + produces: + - application/json + responses: + "200": + description: The Saved Filter + schema: + $ref: '#/definitions/models.SavedFilter' + "403": + description: The user does not have access to that saved filter. + schema: + $ref: '#/definitions/web.HTTPError' + "500": + description: Internal error + schema: + $ref: '#/definitions/models.Message' + security: + - JWTKeyAuth: [] + summary: Creates a new saved filter + tags: + - filter + /filters/{id}: + delete: + consumes: + - application/json + description: Removes a saved filter by its ID. + parameters: + - description: Filter ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: The Saved Filter + schema: + $ref: '#/definitions/models.SavedFilter' + "403": + description: The user does not have access to that saved filter. + schema: + $ref: '#/definitions/web.HTTPError' + "404": + description: The saved filter does not exist. + schema: + $ref: '#/definitions/web.HTTPError' + "500": + description: Internal error + schema: + $ref: '#/definitions/models.Message' + security: + - JWTKeyAuth: [] + summary: Removes a saved filter + tags: + - filter + get: + consumes: + - application/json + description: Returns a saved filter by its ID. + parameters: + - description: Filter ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: The Saved Filter + schema: + $ref: '#/definitions/models.SavedFilter' + "403": + description: The user does not have access to that saved filter. + schema: + $ref: '#/definitions/web.HTTPError' + "500": + description: Internal error + schema: + $ref: '#/definitions/models.Message' + security: + - JWTKeyAuth: [] + summary: Gets one saved filter + tags: + - filter + post: + consumes: + - application/json + description: Updates a saved filter by its ID. + parameters: + - description: Filter ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: The Saved Filter + schema: + $ref: '#/definitions/models.SavedFilter' + "403": + description: The user does not have access to that saved filter. + schema: + $ref: '#/definitions/web.HTTPError' + "404": + description: The saved filter does not exist. + schema: + $ref: '#/definitions/web.HTTPError' + "500": + description: Internal error + schema: + $ref: '#/definitions/models.Message' + security: + - JWTKeyAuth: [] + summary: Updates a saved filter + tags: + - filter /info: get: description: Returns the version, frontendurl, motd and various settings of Vikunja diff --git a/pkg/user/user.go b/pkg/user/user.go index 7dec28f38..0478dc069 100644 --- a/pkg/user/user.go +++ b/pkg/user/user.go @@ -46,7 +46,7 @@ type User struct { // The unique, numeric id of this user. ID int64 `xorm:"int(11) autoincr not null unique pk" json:"id"` // The username of the user. Is always unique. - Username string `xorm:"varchar(250) not null unique" json:"username" valid:"length(1|250)" minLength:"3" maxLength:"250"` + Username string `xorm:"varchar(250) not null unique" json:"username" valid:"length(1|250)" minLength:"1" maxLength:"250"` Password string `xorm:"varchar(250) not null" json:"-"` // The user's email address. Email string `xorm:"varchar(250) null" json:"email,omitempty" valid:"email,length(0|250)" maxLength:"250"`