Saved filters (#655)

Fix updating saved filters

Fix filter not loadable because of missing param declaration

Add fancy special cases for postgres exists in db

Add special case for postgrs json

Add read one test

Add rights tests

Fix lint

Fixed getting a single saved filter from db

Add tests for the usual crud methods

Add test stubs and TODOs

Add test for converting saved filter ids to list ids and vice versa

Add test fixture for saved filters and fix existing tests

Fix exposed json variables of filters

Fix creating saved filters table for tests

Add getting saved filters as pseudo namespace

Cleanup

Refactor getting all namespaces to use a map for easier handling of pseudo namespaces

Add custom erros for saved filters

Swagger docs

Fix lint

Add routes for saved filters

Add alias for mage build

Add method to get a saved filter from the lists endpoint

Add getting tasks from a saved filter

Add create, update, delete, read one methods

Add rights methods for saved filters

Fix docs minLength

Add saved filters column

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: https://kolaente.dev/vikunja/api/pulls/655
Co-Authored-By: konrad <konrad@kola-entertainments.de>
Co-Committed-By: konrad <konrad@kola-entertainments.de>
This commit is contained in:
konrad 2020-09-26 21:02:17 +00:00
parent a6fdf114d1
commit 0fb2edf051
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"`