Saved filters #655

Merged
konrad merged 29 commits from feature/saved-filters into master 2020-09-26 21:02:18 +00:00
26 changed files with 1650 additions and 119 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 <https://www.gnu.org/licenses/>.
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
},
})
}

View File

@ -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.",
}
}

View File

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

View File

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

View File

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

View File

@ -57,6 +57,7 @@ func GetTables() []interface{} {
&TaskComment{},
&Bucket{},
&UnsplashPhoto{},
&SavedFilter{},
}
}

View File

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

View File

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

182
pkg/models/saved_filters.go Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
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
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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)
})
})
}

View File

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

View File

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

View File

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

View File

@ -58,6 +58,7 @@ func SetupTests() {
"users_list",
"users_namespace",
"buckets",
"saved_filters",
)
if err != nil {
log.Fatal(err)

View File

@ -69,7 +69,7 @@ func getNamespace(c echo.Context) (namespace *models.Namespace, err error) {
}
if namespaceID == -1 {
namespace = &models.PseudoNamespace
namespace = &models.SharedListsPseudoNamespace
return
}

View File

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

View File

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

View File

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

View File

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

View File

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