diff --git a/docs/content/doc/usage/errors.md b/docs/content/doc/usage/errors.md index 3e6650ec..88342632 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 055ba9bf..effc1e37 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 00000000..844ceb1a --- /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 fdf68a94..719ddd43 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 6c203901..8a823433 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 00000000..dfce801e --- /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 1e95ce93..f6786b57 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 dc0524cd..a9a29693 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 e18f1bc4..4c6515d2 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 875409e8..fddcbf8d 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 78fa5e8e..c8a73bdb 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 68decb34..c753d5ca 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 7afcac57..5e7667f2 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 00000000..18a0bbce --- /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 00000000..2e869fe8 --- /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 00000000..4656f09b --- /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 a7eb1d18..e1b76523 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 af86d83f..2d1d4533 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 781f14ba..36817a1f 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 4fc37320..df3e8368 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 3142b1d9..a09b3110 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 810ed1b7..ffbb848d 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 75c69b7f..bea8591a 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 14bebf07..d9633e90 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 e7c17a30..f7a86a7a 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 7dec28f3..0478dc06 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"`