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"`