1
0

Saved filters (#655)

Fix updating saved filters

Fix filter not loadable because of missing param declaration

Add fancy special cases for postgres exists in db

Add special case for postgrs json

Add read one test

Add rights tests

Fix lint

Fixed getting a single saved filter from db

Add tests for the usual crud methods

Add test stubs and TODOs

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

Add test fixture for saved filters and fix existing tests

Fix exposed json variables of filters

Fix creating saved filters table for tests

Add getting saved filters as pseudo namespace

Cleanup

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

Add custom erros for saved filters

Swagger docs

Fix lint

Add routes for saved filters

Add alias for mage build

Add method to get a saved filter from the lists endpoint

Add getting tasks from a saved filter

Add create, update, delete, read one methods

Add rights methods for saved filters

Fix docs minLength

Add saved filters column

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: https://kolaente.dev/vikunja/api/pulls/655
Co-Authored-By: konrad <konrad@kola-entertainments.de>
Co-Committed-By: konrad <konrad@kola-entertainments.de>
This commit is contained in:
konrad
2020-09-26 21:02:17 +00:00
parent a6fdf114d1
commit 0fb2edf051
26 changed files with 1650 additions and 119 deletions

View File

@ -1363,3 +1363,62 @@ func (err ErrBucketLimitExceeded) HTTPError() web.HTTPError {
Message: "You cannot add the task to this bucket as it already exceeded the limit of tasks it can hold.",
}
}
// =============
// Saved Filters
// =============
// ErrSavedFilterDoesNotExist represents an error where a kanban bucket does not exist
type ErrSavedFilterDoesNotExist struct {
SavedFilterID int64
}
// IsErrSavedFilterDoesNotExist checks if an error is ErrSavedFilterDoesNotExist.
func IsErrSavedFilterDoesNotExist(err error) bool {
_, ok := err.(ErrSavedFilterDoesNotExist)
return ok
}
func (err ErrSavedFilterDoesNotExist) Error() string {
return fmt.Sprintf("Saved filter does not exist [SavedFilterID: %d]", err.SavedFilterID)
}
// ErrCodeSavedFilterDoesNotExist holds the unique world-error code of this error
const ErrCodeSavedFilterDoesNotExist = 11001
// HTTPError holds the http error description
func (err ErrSavedFilterDoesNotExist) HTTPError() web.HTTPError {
return web.HTTPError{
HTTPCode: http.StatusNotFound,
Code: ErrCodeSavedFilterDoesNotExist,
Message: "This saved filter does not exist.",
}
}
// ErrSavedFilterNotAvailableForLinkShare represents an error where a kanban bucket does not exist
type ErrSavedFilterNotAvailableForLinkShare struct {
SavedFilterID int64
LinkShareID int64
}
// IsErrSavedFilterNotAvailableForLinkShare checks if an error is ErrSavedFilterNotAvailableForLinkShare.
func IsErrSavedFilterNotAvailableForLinkShare(err error) bool {
_, ok := err.(ErrSavedFilterNotAvailableForLinkShare)
return ok
}
func (err ErrSavedFilterNotAvailableForLinkShare) Error() string {
return fmt.Sprintf("Saved filters are not available for link shares [SavedFilterID: %d, LinkShareID: %d]", err.SavedFilterID, err.LinkShareID)
}
// ErrCodeSavedFilterNotAvailableForLinkShare holds the unique world-error code of this error
const ErrCodeSavedFilterNotAvailableForLinkShare = 11002
// HTTPError holds the http error description
func (err ErrSavedFilterNotAvailableForLinkShare) HTTPError() web.HTTPError {
return web.HTTPError{
HTTPCode: http.StatusPreconditionFailed,
Code: ErrCodeSavedFilterNotAvailableForLinkShare,
Message: "Saved filters are not available for link shares.",
}
}

View File

@ -27,7 +27,7 @@ type Label struct {
// The unique, numeric id of this label.
ID int64 `xorm:"int(11) autoincr not null unique pk" json:"id" param:"label"`
// The title of the lable. You'll see this one on tasks associated with it.
Title string `xorm:"varchar(250) not null" json:"title" valid:"runelength(1|250)" minLength:"3" maxLength:"250"`
Title string `xorm:"varchar(250) not null" json:"title" valid:"runelength(1|250)" minLength:"1" maxLength:"250"`
// The label description.
Description string `xorm:"longtext null" json:"description"`
// The color this label has

View File

@ -32,7 +32,7 @@ type List struct {
// The unique, numeric id of this list.
ID int64 `xorm:"int(11) autoincr not null unique pk" json:"id" param:"list"`
// The title of the list. You'll see this in the namespace overview.
Title string `xorm:"varchar(250) not null" json:"title" valid:"required,runelength(1|250)" minLength:"3" maxLength:"250"`
Title string `xorm:"varchar(250) not null" json:"title" valid:"required,runelength(1|250)" minLength:"1" maxLength:"250"`
// The description of the list.
Description string `xorm:"longtext null" json:"description"`
// The unique list short identifier. Used to build task identifiers.
@ -185,6 +185,19 @@ func (l *List) ReadOne() (err error) {
return nil
}
// Check for saved filters
if getSavedFilterIDFromListID(l.ID) > 0 {
sf, err := getSavedFilterSimpleByID(getSavedFilterIDFromListID(l.ID))
if err != nil {
return err
}
l.Title = sf.Title
l.Description = sf.Description
l.Created = sf.Created
l.Updated = sf.Updated
l.OwnerID = sf.OwnerID
}
// Get list owner
l.Owner, err = user.GetUserByID(l.OwnerID)
if err != nil {

View File

@ -81,6 +81,12 @@ func (l *List) CanRead(a web.Auth) (bool, int, error) {
return true, int(RightRead), nil
}
// Saved Filter Lists need a special case
if getSavedFilterIDFromListID(l.ID) > 0 {
sf := &SavedFilter{ID: getSavedFilterIDFromListID(l.ID)}
return sf.CanRead(a)
}
// Check if the user is either owner or can read
if err := l.GetSimpleByID(); err != nil {
return false, 0, err

View File

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

View File

@ -21,6 +21,7 @@ import (
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/web"
"github.com/imdario/mergo"
"sort"
"time"
"xorm.io/builder"
)
@ -30,7 +31,7 @@ type Namespace struct {
// The unique, numeric id of this namespace.
ID int64 `xorm:"int(11) autoincr not null unique pk" json:"id" param:"namespace"`
// The name of this namespace.
Title string `xorm:"varchar(250) not null" json:"title" valid:"required,runelength(1|250)" minLength:"5" maxLength:"250"`
Title string `xorm:"varchar(250) not null" json:"title" valid:"required,runelength(1|250)" minLength:"1" maxLength:"250"`
// The description of the namespace
Description string `xorm:"longtext null" json:"description"`
OwnerID int64 `xorm:"int(11) not null INDEX" json:"-"`
@ -53,8 +54,8 @@ type Namespace struct {
web.Rights `xorm:"-" json:"-"`
}
// PseudoNamespace is a pseudo namespace used to hold shared lists
var PseudoNamespace = Namespace{
// SharedListsPseudoNamespace is a pseudo namespace used to hold shared lists
var SharedListsPseudoNamespace = Namespace{
ID: -1,
Title: "Shared Lists",
Description: "Lists of other users shared with you via teams or directly.",
@ -71,6 +72,15 @@ var FavoritesPseudoNamespace = Namespace{
Updated: time.Now(),
}
// SavedFiltersPseudoNamespace is a pseudo namespace used to hold saved filters
var SavedFiltersPseudoNamespace = Namespace{
ID: -3,
Title: "Filters",
Description: "Saved filters.",
Created: time.Now(),
Updated: time.Now(),
}
// TableName makes beautiful table names
func (Namespace) TableName() string {
return "namespaces"
@ -84,7 +94,7 @@ func (n *Namespace) GetSimpleByID() (err error) {
// Get the namesapce with shared lists
if n.ID == -1 {
*n = PseudoNamespace
*n = SharedListsPseudoNamespace
return
}
@ -179,13 +189,18 @@ func (n *Namespace) ReadAll(a web.Auth, search string, page int, perPage int) (r
return nil, 0, 0, ErrGenericForbidden{}
}
// This map will hold all namespaces and their lists. The key is usually the id of the namespace.
// We're using a map here because it makes a few things like adding lists or removing pseudo namespaces easier.
namespaces := make(map[int64]*NamespaceWithLists)
//////////////////////////////
// Lists with their namespaces
doer, err := user.GetFromAuth(a)
if err != nil {
return nil, 0, 0, err
}
all := []*NamespaceWithLists{}
// Adding a 1=1 condition by default here because xorm always needs a condition and cannot handle nil conditions
var isArchivedCond builder.Cond = builder.Eq{"1": 1}
if !n.IsArchived {
@ -194,26 +209,7 @@ func (n *Namespace) ReadAll(a web.Auth, search string, page int, perPage int) (r
)
}
// Create our pseudo namespace with favorite lists
// We want this one at the beginning, which is why we create it here
pseudoFavoriteNamespace := FavoritesPseudoNamespace
pseudoFavoriteNamespace.Owner = doer
all = append(all, &NamespaceWithLists{
Namespace: pseudoFavoriteNamespace,
Lists: []*List{{}},
})
*all[0].Lists[0] = FavoritesPseudoList // Copying the list to be able to modify it later
// Create our pseudo namespace to hold the shared lists
pseudonamespace := PseudoNamespace
pseudonamespace.Owner = doer
all = append(all, &NamespaceWithLists{
pseudonamespace,
[]*List{},
})
limit, start := getLimitFromPageIndex(page, perPage)
query := x.Select("namespaces.*").
Table("namespaces").
Join("LEFT", "team_namespaces", "namespaces.id = team_namespaces.namespace_id").
@ -228,15 +224,15 @@ func (n *Namespace) ReadAll(a web.Auth, search string, page int, perPage int) (r
if limit > 0 {
query = query.Limit(limit, start)
}
err = query.Find(&all)
err = query.Find(&namespaces)
if err != nil {
return all, 0, 0, err
return nil, 0, 0, err
}
// Make a list of namespace ids
var namespaceids []int64
var userIDs []int64
for _, nsp := range all {
for _, nsp := range namespaces {
namespaceids = append(namespaceids, nsp.ID)
userIDs = append(userIDs, nsp.OwnerID)
}
@ -245,7 +241,7 @@ func (n *Namespace) ReadAll(a web.Auth, search string, page int, perPage int) (r
userMap := make(map[int64]*user.User)
err = x.In("id", userIDs).Find(&userMap)
if err != nil {
return all, 0, 0, err
return nil, 0, 0, err
}
// Get all lists
@ -258,7 +254,34 @@ func (n *Namespace) ReadAll(a web.Auth, search string, page int, perPage int) (r
}
err = listQuery.Find(&lists)
if err != nil {
return all, 0, 0, err
return nil, 0, 0, err
}
numberOfTotalItems, err = x.
Table("namespaces").
Join("LEFT", "team_namespaces", "namespaces.id = team_namespaces.namespace_id").
Join("LEFT", "team_members", "team_members.team_id = team_namespaces.team_id").
Join("LEFT", "users_namespace", "users_namespace.namespace_id = namespaces.id").
Where("team_members.user_id = ?", doer.ID).
Or("namespaces.owner_id = ?", doer.ID).
Or("users_namespace.user_id = ?", doer.ID).
And("namespaces.is_archived = false").
GroupBy("namespaces.id").
Where("namespaces.title LIKE ?", "%"+search+"%").
Count(&NamespaceWithLists{})
if err != nil {
return nil, 0, 0, err
}
///////////////
// Shared Lists
// Create our pseudo namespace to hold the shared lists
sharedListsPseudonamespace := SharedListsPseudoNamespace
sharedListsPseudonamespace.Owner = doer
namespaces[sharedListsPseudonamespace.ID] = &NamespaceWithLists{
sharedListsPseudonamespace,
[]*List{},
}
// Get all lists individually shared with our user (not via a namespace)
@ -287,9 +310,9 @@ func (n *Namespace) ReadAll(a web.Auth, search string, page int, perPage int) (r
lists = append(lists, l)
}
// Remove the pseudonamespace if we don't have any shared lists
// Remove the sharedListsPseudonamespace if we don't have any shared lists
if len(individualLists) == 0 {
all = append(all[:1], all[2:]...)
delete(namespaces, sharedListsPseudonamespace.ID)
}
// More details for the lists
@ -298,22 +321,23 @@ func (n *Namespace) ReadAll(a web.Auth, search string, page int, perPage int) (r
return nil, 0, 0, err
}
nMap := make(map[int64]*NamespaceWithLists, len(all))
/////////////////
// Favorite lists
// Put objects in our namespace list
for _, n := range all {
// Users
n.Owner = userMap[n.OwnerID]
nMap[n.ID] = n
// Create our pseudo namespace with favorite lists
pseudoFavoriteNamespace := FavoritesPseudoNamespace
pseudoFavoriteNamespace.Owner = doer
namespaces[pseudoFavoriteNamespace.ID] = &NamespaceWithLists{
Namespace: pseudoFavoriteNamespace,
Lists: []*List{{}},
}
*namespaces[pseudoFavoriteNamespace.ID].Lists[0] = FavoritesPseudoList // Copying the list to be able to modify it later
for _, list := range lists {
if list.IsFavorite {
nMap[pseudoFavoriteNamespace.ID].Lists = append(nMap[pseudoFavoriteNamespace.ID].Lists, list)
namespaces[pseudoFavoriteNamespace.ID].Lists = append(namespaces[pseudoFavoriteNamespace.ID].Lists, list)
}
nMap[list.NamespaceID].Lists = append(nMap[list.NamespaceID].Lists, list)
namespaces[list.NamespaceID].Lists = append(namespaces[list.NamespaceID].Lists, list)
}
// Check if we have any favorites or favorited lists and remove the favorites namespace from the list if not
@ -329,35 +353,58 @@ func (n *Namespace) ReadAll(a web.Auth, search string, page int, perPage int) (r
// If we don't have any favorites in the favorites pseudo list, remove that pseudo list from the namespace
if favoriteCount == 0 {
for in, l := range nMap[pseudoFavoriteNamespace.ID].Lists {
for in, l := range namespaces[pseudoFavoriteNamespace.ID].Lists {
if l.ID == FavoritesPseudoList.ID {
nMap[pseudoFavoriteNamespace.ID].Lists = append(nMap[pseudoFavoriteNamespace.ID].Lists[:in], nMap[pseudoFavoriteNamespace.ID].Lists[in+1:]...)
namespaces[pseudoFavoriteNamespace.ID].Lists = append(namespaces[pseudoFavoriteNamespace.ID].Lists[:in], namespaces[pseudoFavoriteNamespace.ID].Lists[in+1:]...)
break
}
}
}
// If we don't have any favorites in the namespace, remove it
if len(nMap[pseudoFavoriteNamespace.ID].Lists) == 0 {
all = append(all[:0], all[1:]...)
if len(namespaces[pseudoFavoriteNamespace.ID].Lists) == 0 {
delete(namespaces, pseudoFavoriteNamespace.ID)
}
numberOfTotalItems, err = x.
Table("namespaces").
Join("LEFT", "team_namespaces", "namespaces.id = team_namespaces.namespace_id").
Join("LEFT", "team_members", "team_members.team_id = team_namespaces.team_id").
Join("LEFT", "users_namespace", "users_namespace.namespace_id = namespaces.id").
Where("team_members.user_id = ?", doer.ID).
Or("namespaces.owner_id = ?", doer.ID).
Or("users_namespace.user_id = ?", doer.ID).
And("namespaces.is_archived = false").
GroupBy("namespaces.id").
Where("namespaces.title LIKE ?", "%"+search+"%").
Count(&NamespaceWithLists{})
/////////////////
// Saved Filters
savedFilters, err := getSavedFiltersForUser(a)
if err != nil {
return all, 0, 0, err
return nil, 0, 0, err
}
if len(savedFilters) > 0 {
savedFiltersPseudoNamespace := SavedFiltersPseudoNamespace
savedFiltersPseudoNamespace.Owner = doer
namespaces[savedFiltersPseudoNamespace.ID] = &NamespaceWithLists{
Namespace: savedFiltersPseudoNamespace,
Lists: make([]*List, 0, len(savedFilters)),
}
for _, filter := range savedFilters {
namespaces[savedFiltersPseudoNamespace.ID].Lists = append(namespaces[savedFiltersPseudoNamespace.ID].Lists, &List{
ID: getListIDFromSavedFilterID(filter.ID),
Title: filter.Title,
Description: filter.Description,
Created: filter.Created,
Updated: filter.Updated,
Owner: doer,
})
}
}
//////////////////////
// Put it all together (and sort it)
all := make([]*NamespaceWithLists, 0, len(namespaces))
for _, n := range namespaces {
n.Owner = userMap[n.OwnerID]
all = append(all, n)
}
sort.Slice(all, func(i, j int) bool {
return all[i].ID < all[j].ID
})
return all, len(all), numberOfTotalItems, nil
}

View File

@ -142,12 +142,13 @@ func TestNamespace_ReadAll(t *testing.T) {
t.Run("normal", func(t *testing.T) {
n := &Namespace{}
nn, _, _, err := n.ReadAll(user1, "", 1, -1)
namespaces := nn.([]*NamespaceWithLists)
assert.NoError(t, err)
namespaces := nn.([]*NamespaceWithLists)
assert.NotNil(t, namespaces)
assert.Len(t, namespaces, 10) // Total of 10 including shared & favorites
assert.Equal(t, int64(-2), namespaces[0].ID) // The first one should be the one with favorites
assert.Equal(t, int64(-1), namespaces[1].ID) // The second one should be the one with the shared namespaces
assert.Len(t, namespaces, 11) // Total of 10 including shared, favorites and saved filters
assert.Equal(t, int64(-3), namespaces[0].ID) // The first one should be the one with shared filters
assert.Equal(t, int64(-2), namespaces[1].ID) // The second one should be the one with favorites
assert.Equal(t, int64(-1), namespaces[2].ID) // The third one should be the one with the shared namespaces
// Ensure every list and namespace are not archived
for _, namespace := range namespaces {
assert.False(t, namespace.IsArchived)
@ -164,9 +165,10 @@ func TestNamespace_ReadAll(t *testing.T) {
namespaces := nn.([]*NamespaceWithLists)
assert.NoError(t, err)
assert.NotNil(t, namespaces)
assert.Len(t, namespaces, 11) // Total of 11 including shared & favorites, one is archived
assert.Equal(t, int64(-2), namespaces[0].ID) // The first one should be the one with favorites
assert.Equal(t, int64(-1), namespaces[1].ID) // The second one should be the one with the shared namespaces
assert.Len(t, namespaces, 12) // Total of 12 including shared & favorites, one is archived
assert.Equal(t, int64(-3), namespaces[0].ID) // The first one should be the one with shared filters
assert.Equal(t, int64(-2), namespaces[1].ID) // The second one should be the one with favorites
assert.Equal(t, int64(-1), namespaces[2].ID) // The third one should be the one with the shared namespaces
})
t.Run("no favorites", func(t *testing.T) {
n := &Namespace{}
@ -185,4 +187,12 @@ func TestNamespace_ReadAll(t *testing.T) {
assert.Equal(t, FavoritesPseudoNamespace.ID, namespaces[0].ID)
assert.NotEqual(t, 0, namespaces[0].Lists)
})
t.Run("no saved filters", func(t *testing.T) {
n := &Namespace{}
nn, _, _, err := n.ReadAll(user11, "", 1, -1)
namespaces := nn.([]*NamespaceWithLists)
assert.NoError(t, err)
// Assert the first namespace is not the favorites namespace
assert.NotEqual(t, SavedFiltersPseudoNamespace.ID, namespaces[0].ID)
})
}

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

@ -0,0 +1,182 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2020 Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package models
import (
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/web"
"time"
)
// SavedFilter represents a saved bunch of filters
type SavedFilter struct {
// The unique numeric id of this saved filter
ID int64 `xorm:"autoincr not null unique pk" json:"id" param:"filter"`
// The actual filters this filter contains
Filters *TaskCollection `xorm:"JSON not null" json:"filters"`
// The title of the filter.
Title string `xorm:"varchar(250) not null" json:"title" valid:"required,runelength(1|250)" minLength:"1" maxLength:"250"`
// The description of the filter
Description string `xorm:"longtext null" json:"description"`
OwnerID int64 `xorm:"int(11) not null INDEX" json:"-"`
// The user who owns this filter
Owner *user.User `xorm:"-" json:"owner" valid:"-"`
// A timestamp when this filter was created. You cannot change this value.
Created time.Time `xorm:"created not null" json:"created"`
// A timestamp when this filter was last updated. You cannot change this value.
Updated time.Time `xorm:"updated not null" json:"updated"`
web.CRUDable `xorm:"-" json:"-"`
web.Rights `xorm:"-" json:"-"`
}
// TableName returns a better table name for saved filters
func (s *SavedFilter) TableName() string {
return "saved_filters"
}
func (s *SavedFilter) getTaskCollection() *TaskCollection {
// We're resetting the listID to return tasks from all lists
s.Filters.ListID = 0
return s.Filters
}
// Returns the saved filter ID from a list ID. Will not check if the filter actually exists.
// If the returned ID is zero, means that it is probably invalid.
func getSavedFilterIDFromListID(listID int64) (filterID int64) {
// We get the id of the saved filter by multiplying the ListID with -1 and subtracting one
filterID = listID*-1 - 1
// FilterIDs from listIDs are always positive
if filterID < 0 {
filterID = 0
}
return
}
func getListIDFromSavedFilterID(filterID int64) (listID int64) {
listID = filterID*-1 - 1
// ListIDs from saved filters are always negative
if listID > 0 {
listID = 0
}
return
}
func getSavedFiltersForUser(auth web.Auth) (filters []*SavedFilter, err error) {
// Link shares can't view or modify saved filters, therefore we can error out right away
if _, is := auth.(*LinkSharing); is {
return nil, ErrSavedFilterNotAvailableForLinkShare{LinkShareID: auth.GetID()}
}
err = x.Where("owner_id = ?", auth.GetID()).Find(&filters)
return
}
// Create creates a new saved filter
// @Summary Creates a new saved filter
// @Description Creates a new saved filter
// @tags filter
// @Accept json
// @Produce json
// @Security JWTKeyAuth
// @Success 200 {object} models.SavedFilter "The Saved Filter"
// @Failure 403 {object} web.HTTPError "The user does not have access to that saved filter."
// @Failure 500 {object} models.Message "Internal error"
// @Router /filters [put]
func (s *SavedFilter) Create(auth web.Auth) error {
s.OwnerID = auth.GetID()
_, err := x.Insert(s)
return err
}
func getSavedFilterSimpleByID(id int64) (s *SavedFilter, err error) {
s = &SavedFilter{}
exists, err := x.
Where("id = ?", id).
Get(s)
if err != nil {
return nil, err
}
if !exists {
return nil, ErrSavedFilterDoesNotExist{SavedFilterID: id}
}
return
}
// ReadOne returns one saved filter
// @Summary Gets one saved filter
// @Description Returns a saved filter by its ID.
// @tags filter
// @Accept json
// @Produce json
// @Security JWTKeyAuth
// @Param id path int true "Filter ID"
// @Success 200 {object} models.SavedFilter "The Saved Filter"
// @Failure 403 {object} web.HTTPError "The user does not have access to that saved filter."
// @Failure 500 {object} models.Message "Internal error"
// @Router /filters/{id} [get]
func (s *SavedFilter) ReadOne() error {
// s already contains almost the full saved filter from the rights check, we only need to add the user
u, err := user.GetUserByID(s.OwnerID)
s.Owner = u
return err
}
// Update updates an existing filter
// @Summary Updates a saved filter
// @Description Updates a saved filter by its ID.
// @tags filter
// @Accept json
// @Produce json
// @Security JWTKeyAuth
// @Param id path int true "Filter ID"
// @Success 200 {object} models.SavedFilter "The Saved Filter"
// @Failure 403 {object} web.HTTPError "The user does not have access to that saved filter."
// @Failure 404 {object} web.HTTPError "The saved filter does not exist."
// @Failure 500 {object} models.Message "Internal error"
// @Router /filters/{id} [post]
func (s *SavedFilter) Update() error {
_, err := x.
Where("id = ?", s.ID).
Cols(
"title",
"description",
"filters",
).
Update(s)
return err
}
// Delete removes a saved filter
// @Summary Removes a saved filter
// @Description Removes a saved filter by its ID.
// @tags filter
// @Accept json
// @Produce json
// @Security JWTKeyAuth
// @Param id path int true "Filter ID"
// @Success 200 {object} models.SavedFilter "The Saved Filter"
// @Failure 403 {object} web.HTTPError "The user does not have access to that saved filter."
// @Failure 404 {object} web.HTTPError "The saved filter does not exist."
// @Failure 500 {object} models.Message "Internal error"
// @Router /filters/{id} [delete]
func (s *SavedFilter) Delete() error {
_, err := x.Where("id = ?", s.ID).Delete(s)
return err
}

View File

@ -0,0 +1,68 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2020 Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package models
import "code.vikunja.io/web"
// CanRead checks if a user has the right to read a saved filter
func (s *SavedFilter) CanRead(auth web.Auth) (bool, int, error) {
can, err := s.canDoFilter(auth)
return can, int(RightAdmin), err
}
// CanDelete checks if a user has the right to delete a saved filter
func (s *SavedFilter) CanDelete(auth web.Auth) (bool, error) {
return s.canDoFilter(auth)
}
// CanUpdate checks if a user has the right to update a saved filter
func (s *SavedFilter) CanUpdate(auth web.Auth) (bool, error) {
// A normal check would replace the passed struct which in our case would override the values we want to update.
sf := &SavedFilter{ID: s.ID}
return sf.canDoFilter(auth)
}
// CanCreate checks if a user has the right to update a saved filter
func (s *SavedFilter) CanCreate(auth web.Auth) (bool, error) {
if _, is := auth.(*LinkSharing); is {
return false, nil
}
return true, nil
}
// Helper function to check saved filter rights sind they all have the same logic
func (s *SavedFilter) canDoFilter(auth web.Auth) (can bool, err error) {
// Link shares can't view or modify saved filters, therefore we can error out right away
if _, is := auth.(*LinkSharing); is {
return false, ErrSavedFilterNotAvailableForLinkShare{LinkShareID: auth.GetID(), SavedFilterID: s.ID}
}
sf, err := getSavedFilterSimpleByID(s.ID)
if err != nil {
return false, err
}
// Only owners are allowed to do something with a saved filter
if sf.OwnerID != auth.GetID() {
return false, nil
}
*s = *sf
return true, nil
}

View File

@ -0,0 +1,257 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2020 Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package models
import (
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/user"
"github.com/stretchr/testify/assert"
"testing"
"xorm.io/xorm/schemas"
)
func TestSavedFilter_getListIDFromFilter(t *testing.T) {
t.Run("normal", func(t *testing.T) {
assert.Equal(t, int64(-2), getListIDFromSavedFilterID(1))
})
t.Run("invalid", func(t *testing.T) {
assert.Equal(t, int64(0), getListIDFromSavedFilterID(-1))
})
}
func TestSavedFilter_getFilterIDFromListID(t *testing.T) {
t.Run("normal", func(t *testing.T) {
assert.Equal(t, int64(1), getSavedFilterIDFromListID(-2))
})
t.Run("invalid", func(t *testing.T) {
assert.Equal(t, int64(0), getSavedFilterIDFromListID(2))
})
}
func TestSavedFilter_Create(t *testing.T) {
db.LoadAndAssertFixtures(t)
sf := &SavedFilter{
Title: "test",
Description: "Lorem Ipsum dolor sit amet",
Filters: &TaskCollection{}, // Empty filter
}
u := &user.User{ID: 1}
err := sf.Create(u)
assert.NoError(t, err)
assert.Equal(t, u.ID, sf.OwnerID)
vals := map[string]interface{}{
"title": "'test'",
"description": "'Lorem Ipsum dolor sit amet'",
"filters": "'{\"sort_by\":null,\"order_by\":null,\"filter_by\":null,\"filter_value\":null,\"filter_comparator\":null,\"filter_concat\":\"\",\"filter_include_nulls\":false}'",
"owner_id": 1,
}
// Postgres can't compare json values directly, see https://dba.stackexchange.com/a/106290/210721
if x.Dialect().URI().DBType == schemas.POSTGRES {
vals["filters::jsonb"] = vals["filters"].(string) + "::jsonb"
delete(vals, "filters")
}
db.AssertDBExists(t, "saved_filters", vals, true)
}
func TestSavedFilter_ReadOne(t *testing.T) {
user1 := &user.User{ID: 1}
db.LoadAndAssertFixtures(t)
sf := &SavedFilter{
ID: 1,
}
// canRead pre-populates the struct
_, _, err := sf.CanRead(user1)
assert.NoError(t, err)
err = sf.ReadOne()
assert.NoError(t, err)
assert.NotNil(t, sf.Owner)
}
func TestSavedFilter_Update(t *testing.T) {
db.LoadAndAssertFixtures(t)
sf := &SavedFilter{
ID: 1,
Title: "NewTitle",
Description: "", // Explicitly reset the description
Filters: &TaskCollection{},
}
err := sf.Update()
assert.NoError(t, err)
db.AssertDBExists(t, "saved_filters", map[string]interface{}{
"id": 1,
"title": "NewTitle",
"description": "",
}, false)
}
func TestSavedFilter_Delete(t *testing.T) {
db.LoadAndAssertFixtures(t)
sf := &SavedFilter{
ID: 1,
}
err := sf.Delete()
assert.NoError(t, err)
db.AssertDBMissing(t, "saved_filters", map[string]interface{}{
"id": 1,
})
}
func TestSavedFilter_Rights(t *testing.T) {
user1 := &user.User{ID: 1}
user2 := &user.User{ID: 2}
ls := &LinkSharing{ID: 1}
t.Run("create", func(t *testing.T) {
// Should always be true
db.LoadAndAssertFixtures(t)
can, err := (&SavedFilter{}).CanCreate(user1)
assert.NoError(t, err)
assert.True(t, can)
})
t.Run("read", func(t *testing.T) {
t.Run("owner", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
sf := &SavedFilter{
ID: 1,
Title: "Lorem",
}
can, max, err := sf.CanRead(user1)
assert.NoError(t, err)
assert.Equal(t, int(RightAdmin), max)
assert.True(t, can)
})
t.Run("not owner", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
sf := &SavedFilter{
ID: 1,
Title: "Lorem",
}
can, _, err := sf.CanRead(user2)
assert.NoError(t, err)
assert.False(t, can)
})
t.Run("nonexisting", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
sf := &SavedFilter{
ID: 9999,
Title: "Lorem",
}
can, _, err := sf.CanRead(user1)
assert.Error(t, err)
assert.True(t, IsErrSavedFilterDoesNotExist(err))
assert.False(t, can)
})
t.Run("link share", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
sf := &SavedFilter{
ID: 1,
Title: "Lorem",
}
can, _, err := sf.CanRead(ls)
assert.Error(t, err)
assert.True(t, IsErrSavedFilterNotAvailableForLinkShare(err))
assert.False(t, can)
})
})
t.Run("update", func(t *testing.T) {
t.Run("owner", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
sf := &SavedFilter{
ID: 1,
Title: "Lorem",
}
can, err := sf.CanUpdate(user1)
assert.NoError(t, err)
assert.True(t, can)
})
t.Run("not owner", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
sf := &SavedFilter{
ID: 1,
Title: "Lorem",
}
can, err := sf.CanUpdate(user2)
assert.NoError(t, err)
assert.False(t, can)
})
t.Run("nonexisting", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
sf := &SavedFilter{
ID: 9999,
Title: "Lorem",
}
can, err := sf.CanUpdate(user1)
assert.Error(t, err)
assert.True(t, IsErrSavedFilterDoesNotExist(err))
assert.False(t, can)
})
t.Run("link share", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
sf := &SavedFilter{
ID: 1,
Title: "Lorem",
}
can, err := sf.CanUpdate(ls)
assert.Error(t, err)
assert.True(t, IsErrSavedFilterNotAvailableForLinkShare(err))
assert.False(t, can)
})
})
t.Run("delete", func(t *testing.T) {
t.Run("owner", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
sf := &SavedFilter{
ID: 1,
}
can, err := sf.CanDelete(user1)
assert.NoError(t, err)
assert.True(t, can)
})
t.Run("not owner", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
sf := &SavedFilter{
ID: 1,
}
can, err := sf.CanDelete(user2)
assert.NoError(t, err)
assert.False(t, can)
})
t.Run("nonexisting", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
sf := &SavedFilter{
ID: 9999,
Title: "Lorem",
}
can, err := sf.CanDelete(user1)
assert.Error(t, err)
assert.True(t, IsErrSavedFilterDoesNotExist(err))
assert.False(t, can)
})
t.Run("link share", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
sf := &SavedFilter{
ID: 1,
Title: "Lorem",
}
can, err := sf.CanDelete(ls)
assert.Error(t, err)
assert.True(t, IsErrSavedFilterNotAvailableForLinkShare(err))
assert.False(t, can)
})
})
}

View File

@ -24,29 +24,29 @@ import (
// TaskCollection is a struct used to hold filter details and not clutter the Task struct with information not related to actual tasks.
type TaskCollection struct {
ListID int64 `param:"list"`
Lists []*List
ListID int64 `param:"list" json:"-"`
Lists []*List `json:"-"`
// The query parameter to sort by. This is for ex. done, priority, etc.
SortBy []string `query:"sort_by"`
SortByArr []string `query:"sort_by[]"`
SortBy []string `query:"sort_by" json:"sort_by"`
SortByArr []string `query:"sort_by[]" json:"-"`
// The query parameter to order the items by. This can be either asc or desc, with asc being the default.
OrderBy []string `query:"order_by"`
OrderByArr []string `query:"order_by[]"`
OrderBy []string `query:"order_by" json:"order_by"`
OrderByArr []string `query:"order_by[]" json:"-"`
// The field name of the field to filter by
FilterBy []string `query:"filter_by"`
FilterByArr []string `query:"filter_by[]"`
FilterBy []string `query:"filter_by" json:"filter_by"`
FilterByArr []string `query:"filter_by[]" json:"-"`
// The value of the field name to filter by
FilterValue []string `query:"filter_value"`
FilterValueArr []string `query:"filter_value[]"`
FilterValue []string `query:"filter_value" json:"filter_value"`
FilterValueArr []string `query:"filter_value[]" json:"-"`
// The comparator for field and value
FilterComparator []string `query:"filter_comparator"`
FilterComparatorArr []string `query:"filter_comparator[]"`
FilterComparator []string `query:"filter_comparator" json:"filter_comparator"`
FilterComparatorArr []string `query:"filter_comparator[]" json:"-"`
// The way all filter conditions are concatenated together, can be either "and" or "or".,
FilterConcat string `query:"filter_concat"`
FilterConcat string `query:"filter_concat" json:"filter_concat"`
// If set to true, the result will also include null values
FilterIncludeNulls bool `query:"filter_include_nulls"`
FilterIncludeNulls bool `query:"filter_include_nulls" json:"filter_include_nulls"`
web.CRUDable `xorm:"-" json:"-"`
web.Rights `xorm:"-" json:"-"`
@ -102,6 +102,17 @@ func validateTaskField(fieldName string) error {
// @Router /lists/{listID}/tasks [get]
func (tf *TaskCollection) ReadAll(a web.Auth, search string, page int, perPage int) (result interface{}, resultCount int, totalItems int64, err error) {
// If the list id is < -1 this means we're dealing with a saved filter - in that case we get and populate the filter
// -1 is the favorites list which works as intended
if tf.ListID < -1 {
s, err := getSavedFilterSimpleByID(getSavedFilterIDFromListID(tf.ListID))
if err != nil {
return nil, 0, 0, err
}
return s.getTaskCollection().ReadAll(a, search, page, perPage)
}
if len(tf.SortByArr) > 0 {
tf.SortBy = append(tf.SortBy, tf.SortByArr...)
}

View File

@ -37,7 +37,7 @@ type Task struct {
// The unique, numeric id of this task.
ID int64 `xorm:"int(11) autoincr not null unique pk" json:"id" param:"listtask"`
// The task text. This is what you'll see in the list.
Title string `xorm:"varchar(250) not null" json:"title" valid:"runelength(1|250)" minLength:"3" maxLength:"250"`
Title string `xorm:"varchar(250) not null" json:"title" valid:"runelength(1|250)" minLength:"1" maxLength:"250"`
// The task description.
Description string `xorm:"longtext null" json:"description"`
// Whether a task is done or not.

View File

@ -29,7 +29,7 @@ type Team struct {
// The unique, numeric id of this team.
ID int64 `xorm:"int(11) autoincr not null unique pk" json:"id" param:"team"`
// The name of this team.
Name string `xorm:"varchar(250) not null" json:"name" valid:"required,runelength(1|250)" minLength:"5" maxLength:"250"`
Name string `xorm:"varchar(250) not null" json:"name" valid:"required,runelength(1|250)" minLength:"1" maxLength:"250"`
// The team's description.
Description string `xorm:"longtext null" json:"description"`
CreatedByID int64 `xorm:"int(11) not null INDEX" json:"-"`

View File

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