Add task filter for lists and namespaces (#748)

Add more tests for getting namespaces

Fix namespaces not found

Fix namespaces not found

Make like the default

Update docs & fix docs

Enable searching namespaces by their ids

Enable searching lists by their ids

Enable searching labels by their ids

Enable searching by user ids

Update docs

Add namespace filter

Add task filter for lists

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: https://kolaente.dev/vikunja/api/pulls/748
Co-Authored-By: konrad <konrad@kola-entertainments.de>
Co-Committed-By: konrad <konrad@kola-entertainments.de>
This commit is contained in:
konrad 2020-12-21 23:13:15 +00:00
parent 111efd5fae
commit 2d4e2e452c
12 changed files with 288 additions and 51 deletions

View file

@ -17,8 +17,12 @@
package models
import (
"strconv"
"strings"
"time"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/web"
"xorm.io/builder"
@ -171,13 +175,29 @@ func getLabelsByTaskIDs(opts *LabelByTaskIDsOptions) (ls []*labelWithTaskID, res
cond = builder.Or(cond, builder.Eq{"labels.created_by_id": opts.User.ID})
}
vals := strings.Split(opts.Search, ",")
ids := []int64{}
for _, val := range vals {
v, err := strconv.ParseInt(val, 10, 64)
if err != nil {
log.Debugf("Label search string part '%s' is not a number: %s", val, err)
continue
}
ids = append(ids, v)
}
if len(ids) > 0 {
cond = builder.And(cond, builder.In("labels.id", ids))
} else {
cond = builder.And(cond, &builder.Like{"labels.title", "%" + opts.Search + "%"})
}
limit, start := getLimitFromPageIndex(opts.Page, opts.PerPage)
query := x.Table("labels").
Select(selectStmt).
Join("LEFT", "label_task", "label_task.label_id = labels.id").
Where(cond).
And("labels.title LIKE ?", "%"+opts.Search+"%").
GroupBy(groupBy).
OrderBy("labels.id ASC")
if limit > 0 {

View file

@ -17,8 +17,12 @@
package models
import (
"strconv"
"strings"
"time"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/files"
"code.vikunja.io/api/pkg/metrics"
"code.vikunja.io/api/pkg/user"
@ -320,6 +324,24 @@ func getRawListsForUser(opts *listOptions) (lists []*List, resultCount int, tota
limit, start := getLimitFromPageIndex(opts.page, opts.perPage)
var filterCond builder.Cond
vals := strings.Split(opts.search, ",")
ids := []int64{}
for _, val := range vals {
v, err := strconv.ParseInt(val, 10, 64)
if err != nil {
log.Debugf("List search string part '%s' is not a number: %s", val, err)
continue
}
ids = append(ids, v)
}
if len(ids) > 0 {
filterCond = builder.In("l.id", ids)
} else {
filterCond = &builder.Like{"l.title", "%" + opts.search + "%"}
}
// Gets all Lists where the user is either owner or in a team which has access to the list
// Or in a team which has namespace read access
query := x.Select("l.*").
@ -340,7 +362,7 @@ func getRawListsForUser(opts *listOptions) (lists []*List, resultCount int, tota
builder.Eq{"l.owner_id": fullUser.ID},
)).
GroupBy("l.id").
Where("l.title LIKE ?", "%"+opts.search+"%").
Where(filterCond).
Where(isArchivedCond)
if limit > 0 {
query = query.Limit(limit, start)
@ -368,7 +390,7 @@ func getRawListsForUser(opts *listOptions) (lists []*List, resultCount int, tota
builder.Eq{"l.owner_id": fullUser.ID},
)).
GroupBy("l.id").
Where("l.title LIKE ?", "%"+opts.search+"%").
Where(filterCond).
Where(isArchivedCond).
Count(&List{})
return lists, len(lists), totalItems, err

View file

@ -18,8 +18,12 @@ package models
import (
"sort"
"strconv"
"strings"
"time"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/metrics"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/web"
@ -51,6 +55,9 @@ type Namespace struct {
// A timestamp when this namespace was last updated. You cannot change this value.
Updated time.Time `xorm:"updated not null" json:"updated"`
// If set to true, will only return the namespaces, not their lists.
NamespacesOnly bool `xorm:"-" json:"-" query:"namespaces_only"`
web.CRUDable `xorm:"-" json:"-"`
web.Rights `xorm:"-" json:"-"`
}
@ -171,6 +178,19 @@ type NamespaceWithLists struct {
Lists []*List `xorm:"-" json:"lists"`
}
func makeNamespaceSliceFromMap(namespaces map[int64]*NamespaceWithLists, userMap map[int64]*user.User) []*NamespaceWithLists {
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
}
// ReadAll gets all namespaces a user has access to
// @Summary Get all namespaces a user has access to
// @Description Returns all namespaces a user has access to.
@ -181,10 +201,12 @@ type NamespaceWithLists struct {
// @Param per_page query int false "The maximum number of items per page. Note this parameter is limited by the configured maximum of items per page."
// @Param s query string false "Search namespaces by name."
// @Param is_archived query bool false "If true, also returns all archived namespaces."
// @Param namespaces_only query bool false "If true, also returns only namespaces without their lists."
// @Security JWTKeyAuth
// @Success 200 {array} models.NamespaceWithLists "The Namespaces."
// @Failure 500 {object} models.Message "Internal error"
// @Router /namespaces [get]
//nolint:gocyclo
func (n *Namespace) ReadAll(a web.Auth, search string, page int, perPage int) (result interface{}, resultCount int, numberOfTotalItems int64, err error) {
if _, is := a.(*LinkSharing); is {
return nil, 0, 0, ErrGenericForbidden{}
@ -210,6 +232,22 @@ func (n *Namespace) ReadAll(a web.Auth, search string, page int, perPage int) (r
)
}
var filterCond builder.Cond = &builder.Like{"namespaces.title", "%" + search + "%"}
vals := strings.Split(search, ",")
ids := []int64{}
for _, val := range vals {
v, err := strconv.ParseInt(val, 10, 64)
if err != nil {
log.Debugf("Namespace search string part '%s' is not a number: %s", val, err)
continue
}
ids = append(ids, v)
}
if len(ids) > 0 {
filterCond = builder.In("namespaces.id", ids)
}
limit, start := getLimitFromPageIndex(page, perPage)
query := x.Select("namespaces.*").
Table("namespaces").
@ -220,7 +258,7 @@ func (n *Namespace) ReadAll(a web.Auth, search string, page int, perPage int) (r
Or("namespaces.owner_id = ?", doer.ID).
Or("users_namespace.user_id = ?", doer.ID).
GroupBy("namespaces.id").
Where("namespaces.title LIKE ?", "%"+search+"%").
Where(filterCond).
Where(isArchivedCond)
if limit > 0 {
query = query.Limit(limit, start)
@ -230,6 +268,22 @@ func (n *Namespace) ReadAll(a web.Auth, search string, page int, perPage int) (r
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(filterCond).
Count(&NamespaceWithLists{})
if err != nil {
return nil, 0, 0, err
}
// Make a list of namespace ids
var namespaceids []int64
var userIDs []int64
@ -245,6 +299,11 @@ func (n *Namespace) ReadAll(a web.Auth, search string, page int, perPage int) (r
return nil, 0, 0, err
}
if n.NamespacesOnly {
all := makeNamespaceSliceFromMap(namespaces, userMap)
return all, len(all), numberOfTotalItems, nil
}
// Get all lists
lists := []*List{}
listQuery := x.
@ -258,22 +317,6 @@ func (n *Namespace) ReadAll(a web.Auth, search string, page int, perPage int) (r
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
@ -397,15 +440,7 @@ func (n *Namespace) ReadAll(a web.Auth, search string, page int, perPage int) (r
//////////////////////
// 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
})
all := makeNamespaceSliceFromMap(namespaces, userMap)
return all, len(all), numberOfTotalItems, nil
}

View file

@ -148,6 +148,7 @@ func TestNamespace_Delete(t *testing.T) {
func TestNamespace_ReadAll(t *testing.T) {
user1 := &user.User{ID: 1}
user7 := &user.User{ID: 7}
user11 := &user.User{ID: 11}
user12 := &user.User{ID: 12}
@ -157,7 +158,7 @@ func TestNamespace_ReadAll(t *testing.T) {
assert.NoError(t, err)
namespaces := nn.([]*NamespaceWithLists)
assert.NotNil(t, namespaces)
assert.Len(t, namespaces, 11) // Total of 10 including shared, favorites and saved filters
assert.Len(t, namespaces, 11) // Total of 11 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
@ -169,6 +170,43 @@ func TestNamespace_ReadAll(t *testing.T) {
}
}
})
t.Run("namespaces only", func(t *testing.T) {
n := &Namespace{
NamespacesOnly: true,
}
nn, _, _, err := n.ReadAll(user1, "", 1, -1)
assert.NoError(t, err)
namespaces := nn.([]*NamespaceWithLists)
assert.NotNil(t, namespaces)
assert.Len(t, namespaces, 8) // Total of 8 - excluding shared, favorites and saved filters (normally 11)
// Ensure every namespace does not contain lists
for _, namespace := range namespaces {
assert.Nil(t, namespace.Lists)
}
})
t.Run("ids only", func(t *testing.T) {
n := &Namespace{
NamespacesOnly: true,
}
nn, _, _, err := n.ReadAll(user7, "13,14", 1, -1)
assert.NoError(t, err)
namespaces := nn.([]*NamespaceWithLists)
assert.NotNil(t, namespaces)
assert.Len(t, namespaces, 2)
assert.Equal(t, int64(13), namespaces[0].ID)
assert.Equal(t, int64(14), namespaces[1].ID)
})
t.Run("ids only but ids with other people's namespace", func(t *testing.T) {
n := &Namespace{
NamespacesOnly: true,
}
nn, _, _, err := n.ReadAll(user1, "1,w", 1, -1)
assert.NoError(t, err)
namespaces := nn.([]*NamespaceWithLists)
assert.NotNil(t, namespaces)
assert.Len(t, namespaces, 1)
assert.Equal(t, int64(1), namespaces[0].ID)
})
t.Run("archived", func(t *testing.T) {
n := &Namespace{
IsArchived: true,

View file

@ -91,7 +91,7 @@ func validateTaskField(fieldName string) error {
// @Param s query string false "Search tasks by task text."
// @Param sort_by query string false "The sorting parameter. You can pass this multiple times to get the tasks ordered by multiple different parametes, along with `order_by`. Possible values to sort by are `id`, `title`, `description`, `done`, `done_at`, `due_date`, `created_by_id`, `list_id`, `repeat_after`, `priority`, `start_date`, `end_date`, `hex_color`, `percent_done`, `uid`, `created`, `updated`. Default is `id`."
// @Param order_by query string false "The ordering parameter. Possible values to order by are `asc` or `desc`. Default is `asc`."
// @Param filter_by query string false "The name of the field to filter by. Allowed values are all task properties except `list` and `namespace`. Task properties which are their own object require passing in the id of that entity. Accepts an array for multiple filters which will be chanied together, all supplied filter must match."
// @Param filter_by query string false "The name of the field to filter by. Allowed values are all task properties. Task properties which are their own object require passing in the id of that entity. Accepts an array for multiple filters which will be chanied together, all supplied filter must match."
// @Param filter_value query string false "The value to filter for."
// @Param filter_comparator query string false "The comparator to use for a filter. Available values are `equals`, `greater`, `greater_equals`, `less`, `less_equals`, `like` and `in`. `in` expects comma-separated values in `filter_value`. Defaults to `equals`"
// @Param filter_concat query string false "The concatinator to use for filters. Available values are `and` or `or`. Defaults to `or`."

View file

@ -188,9 +188,24 @@ func getValueForField(field reflect.StructField, rawValue string) (value interfa
func getNativeValueForTaskField(fieldName string, comparator taskFilterComparator, value string) (nativeValue interface{}, err error) {
var realFieldName = strcase.ToCamel(fieldName)
if strings.ToLower(fieldName) == "id" {
realFieldName = "ID"
realFieldName := strings.ReplaceAll(strcase.ToCamel(fieldName), "Id", "ID")
if realFieldName == "Namespace" {
if comparator == taskFilterComparatorIn {
vals := strings.Split(value, ",")
valueSlice := []interface{}{}
for _, val := range vals {
v, err := strconv.ParseInt(val, 10, 64)
if err != nil {
return nil, err
}
valueSlice = append(valueSlice, v)
}
return valueSlice, nil
}
nativeValue, err = strconv.ParseInt(value, 10, 64)
return
}
field, ok := reflect.TypeOf(&Task{}).Elem().FieldByName(realFieldName)

View file

@ -941,6 +941,46 @@ func TestTaskCollection_ReadAll(t *testing.T) {
},
wantErr: false,
},
{
name: "filter list",
fields: fields{
FilterBy: []string{"list_id"},
FilterValue: []string{"6"},
FilterComparator: []string{"equals"},
},
args: defaultArgs,
want: []*Task{
task15,
},
wantErr: false,
},
{
name: "filter namespace",
fields: fields{
FilterBy: []string{"namespace"},
FilterValue: []string{"7"},
FilterComparator: []string{"equals"},
},
args: defaultArgs,
want: []*Task{
task21,
},
wantErr: false,
},
{
name: "filter namespace in",
fields: fields{
FilterBy: []string{"namespace"},
FilterValue: []string{"7,8"},
FilterComparator: []string{"in"},
},
args: defaultArgs,
want: []*Task{
task21,
task22,
},
wantErr: false,
},
}
for _, tt := range tests {

View file

@ -144,7 +144,7 @@ type taskOptions struct {
// @Param s query string false "Search tasks by task text."
// @Param sort_by query string false "The sorting parameter. You can pass this multiple times to get the tasks ordered by multiple different parametes, along with `order_by`. Possible values to sort by are `id`, `title`, `description`, `done`, `done_at`, `due_date`, `created_by_id`, `list_id`, `repeat_after`, `priority`, `start_date`, `end_date`, `hex_color`, `percent_done`, `uid`, `created`, `updated`. Default is `id`."
// @Param order_by query string false "The ordering parameter. Possible values to order by are `asc` or `desc`. Default is `asc`."
// @Param filter_by query string false "The name of the field to filter by. Allowed values are all task properties except `list` and `namespace`. Task properties which are their own object require passing in the id of that entity. Accepts an array for multiple filters which will be chanied together, all supplied filter must match."
// @Param filter_by query string false "The name of the field to filter by. Allowed values are all task properties. Task properties which are their own object require passing in the id of that entity. Accepts an array for multiple filters which will be chanied together, all supplied filter must match."
// @Param filter_value query string false "The value to filter for."
// @Param filter_comparator query string false "The comparator to use for a filter. Available values are `equals`, `greater`, `greater_equals`, `less`, `less_equals`, `like` and `in`. `in` expects comma-separated values in `filter_value`. Defaults to `equals`"
// @Param filter_concat query string false "The concatinator to use for filters. Available values are `and` or `or`. Defaults to `or`."
@ -271,6 +271,7 @@ func getRawTasksForLists(lists []*List, a web.Auth, opts *taskOptions) (tasks []
reminderFilters := []builder.Cond{}
assigneeFilters := []builder.Cond{}
labelFilters := []builder.Cond{}
namespaceFilters := []builder.Cond{}
var filters = make([]builder.Cond, 0, len(opts.filters))
// To still find tasks with nil values, we exclude 0s when comparing with >/< values.
@ -305,6 +306,16 @@ func getRawTasksForLists(lists []*List, a web.Auth, opts *taskOptions) (tasks []
continue
}
if f.field == "namespace" {
f.field = "namespace_id"
filter, err := getFilterCond(f, opts.filterIncludeNulls)
if err != nil {
return nil, 0, 0, err
}
namespaceFilters = append(namespaceFilters, filter)
continue
}
filter, err := getFilterCond(f, opts.filterIncludeNulls)
if err != nil {
return nil, 0, 0, err
@ -369,6 +380,25 @@ func getRawTasksForLists(lists []*List, a web.Auth, opts *taskOptions) (tasks []
filters = append(filters, getFilterCondForSeparateTable("label_task", opts.filterConcat, labelFilters))
}
if len(namespaceFilters) > 0 {
var filtercond builder.Cond
if opts.filterConcat == filterConcatOr {
filtercond = builder.Or(namespaceFilters...)
}
if opts.filterConcat == filterConcatAnd {
filtercond = builder.And(namespaceFilters...)
}
cond := builder.In(
"list_id",
builder.
Select("id").
From("list").
Where(filtercond),
)
filters = append(filters, cond)
}
query = query.Where(listCond)
queryCount = queryCount.Where(listCond)

View file

@ -1892,7 +1892,7 @@ var doc = `{
},
{
"type": "string",
"description": "The name of the field to filter by. Allowed values are all task properties except ` + "`" + `list` + "`" + ` and ` + "`" + `namespace` + "`" + `. Task properties which are their own object require passing in the id of that entity. Accepts an array for multiple filters which will be chanied together, all supplied filter must match.",
"description": "The name of the field to filter by. Allowed values are all task properties. Task properties which are their own object require passing in the id of that entity. Accepts an array for multiple filters which will be chanied together, all supplied filter must match.",
"name": "filter_by",
"in": "query"
},
@ -3036,6 +3036,12 @@ var doc = `{
"description": "If true, also returns all archived namespaces.",
"name": "is_archived",
"in": "query"
},
{
"type": "boolean",
"description": "If true, also returns only namespaces without their lists.",
"name": "namespaces_only",
"in": "query"
}
],
"responses": {
@ -4006,7 +4012,7 @@ var doc = `{
},
{
"type": "string",
"description": "The name of the field to filter by. Allowed values are all task properties except ` + "`" + `list` + "`" + ` and ` + "`" + `namespace` + "`" + `. Task properties which are their own object require passing in the id of that entity. Accepts an array for multiple filters which will be chanied together, all supplied filter must match.",
"description": "The name of the field to filter by. Allowed values are all task properties. Task properties which are their own object require passing in the id of that entity. Accepts an array for multiple filters which will be chanied together, all supplied filter must match.",
"name": "filter_by",
"in": "query"
},

View file

@ -1875,7 +1875,7 @@
},
{
"type": "string",
"description": "The name of the field to filter by. Allowed values are all task properties except `list` and `namespace`. Task properties which are their own object require passing in the id of that entity. Accepts an array for multiple filters which will be chanied together, all supplied filter must match.",
"description": "The name of the field to filter by. Allowed values are all task properties. Task properties which are their own object require passing in the id of that entity. Accepts an array for multiple filters which will be chanied together, all supplied filter must match.",
"name": "filter_by",
"in": "query"
},
@ -3019,6 +3019,12 @@
"description": "If true, also returns all archived namespaces.",
"name": "is_archived",
"in": "query"
},
{
"type": "boolean",
"description": "If true, also returns only namespaces without their lists.",
"name": "namespaces_only",
"in": "query"
}
],
"responses": {
@ -3989,7 +3995,7 @@
},
{
"type": "string",
"description": "The name of the field to filter by. Allowed values are all task properties except `list` and `namespace`. Task properties which are their own object require passing in the id of that entity. Accepts an array for multiple filters which will be chanied together, all supplied filter must match.",
"description": "The name of the field to filter by. Allowed values are all task properties. Task properties which are their own object require passing in the id of that entity. Accepts an array for multiple filters which will be chanied together, all supplied filter must match.",
"name": "filter_by",
"in": "query"
},

View file

@ -2443,7 +2443,7 @@ paths:
in: query
name: order_by
type: string
- description: The name of the field to filter by. Allowed values are all task properties except `list` and `namespace`. Task properties which are their own object require passing in the id of that entity. Accepts an array for multiple filters which will be chanied together, all supplied filter must match.
- description: The name of the field to filter by. Allowed values are all task properties. Task properties which are their own object require passing in the id of that entity. Accepts an array for multiple filters which will be chanied together, all supplied filter must match.
in: query
name: filter_by
type: string
@ -3008,6 +3008,10 @@ paths:
in: query
name: is_archived
type: boolean
- description: If true, also returns only namespaces without their lists.
in: query
name: namespaces_only
type: boolean
produces:
- application/json
responses:
@ -4467,7 +4471,7 @@ paths:
in: query
name: order_by
type: string
- description: The name of the field to filter by. Allowed values are all task properties except `list` and `namespace`. Task properties which are their own object require passing in the id of that entity. Accepts an array for multiple filters which will be chanied together, all supplied filter must match.
- description: The name of the field to filter by. Allowed values are all task properties. Task properties which are their own object require passing in the id of that entity. Accepts an array for multiple filters which will be chanied together, all supplied filter must match.
in: query
name: filter_by
type: string

View file

@ -17,20 +17,41 @@
package user
import (
"strconv"
"strings"
"code.vikunja.io/api/pkg/log"
)
// ListUsers returns a list with all users, filtered by an optional searchstring
func ListUsers(searchterm string) (users []User, err error) {
func ListUsers(searchterm string) (users []*User, err error) {
vals := strings.Split(searchterm, ",")
ids := []int64{}
for _, val := range vals {
v, err := strconv.ParseInt(val, 10, 64)
if err != nil {
log.Debugf("User search string part '%s' is not a number: %s", val, err)
continue
}
ids = append(ids, v)
}
if len(ids) > 0 {
err = x.
In("id", ids).
Find(&users)
return
}
if searchterm == "" {
err = x.Find(&users)
} else {
err = x.
Where("username LIKE ?", "%"+searchterm+"%").
Find(&users)
return
}
if err != nil {
return []User{}, err
}
return users, nil
err = x.
Where("username LIKE ?", "%"+searchterm+"%").
Find(&users)
return
}