Saved filters #655
|
@ -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. |
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
},
|
||||
})
|
||||
}
|
|
@ -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.",
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -57,6 +57,7 @@ func GetTables() []interface{} {
|
|||
&TaskComment{},
|
||||
&Bucket{},
|
||||
&UnsplashPhoto{},
|
||||
&SavedFilter{},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
}
|
|
@ -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...)
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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:"-"`
|
||||
|
|
|
@ -58,6 +58,7 @@ func SetupTests() {
|
|||
"users_list",
|
||||
"users_namespace",
|
||||
"buckets",
|
||||
"saved_filters",
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
|
|
|
@ -69,7 +69,7 @@ func getNamespace(c echo.Context) (namespace *models.Namespace, err error) {
|
|||
}
|
||||
|
||||
if namespaceID == -1 {
|
||||
namespace = &models.PseudoNamespace
|
||||
namespace = &models.SharedListsPseudoNamespace
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
@ -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{}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"`
|
||||
|
|
Loading…
Reference in New Issue