Favorite tasks (#653)
Fixed namespace tests Add test for favorite tasks Fix favorite tasks not being updated Fix integration tests Fix lint Return a pseudo namespace and list for favorites Make sure users can only see their favorites Add condition show tasks from the favorites list Regenerate swagger docs Add favorite field to task Co-authored-by: kolaente <k@knt.li> Reviewed-on: https://kolaente.dev/vikunja/api/pulls/653
This commit is contained in:
@ -128,7 +128,7 @@ func (b *Bucket) ReadAll(auth web.Auth, search string, page int, perPage int) (r
|
||||
},
|
||||
},
|
||||
}
|
||||
tasks, _, _, err := getTasksForLists([]*List{{ID: b.ListID}}, opts)
|
||||
tasks, _, _, err := getTasksForLists([]*List{{ID: b.ListID}}, auth, opts)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
@ -217,7 +217,7 @@ func getUserTaskIDs(u *user.User) (taskIDs []int64, err error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tasks, _, _, err := getRawTasksForLists(lists, &taskOptions{
|
||||
tasks, _, _, err := getRawTasksForLists(lists, u, &taskOptions{
|
||||
page: -1,
|
||||
perPage: 0,
|
||||
})
|
||||
|
@ -74,6 +74,16 @@ type ListBackgroundType struct {
|
||||
// ListBackgroundUpload represents the list upload background type
|
||||
const ListBackgroundUpload string = "upload"
|
||||
|
||||
// FavoritesPseudoList holds all tasks marked as favorites
|
||||
var FavoritesPseudoList = List{
|
||||
ID: -1,
|
||||
Title: "Favorites",
|
||||
Description: "This list has all tasks marked as favorites.",
|
||||
NamespaceID: FavoritesPseudoNamespace.ID,
|
||||
Created: time.Now(),
|
||||
Updated: time.Now(),
|
||||
}
|
||||
|
||||
// GetListsByNamespaceID gets all lists in a namespace
|
||||
func GetListsByNamespaceID(nID int64, doer *user.User) (lists []*List, err error) {
|
||||
if nID == -1 {
|
||||
@ -165,6 +175,12 @@ func (l *List) ReadAll(a web.Auth, search string, page int, perPage int) (result
|
||||
// @Failure 500 {object} models.Message "Internal error"
|
||||
// @Router /lists/{id} [get]
|
||||
func (l *List) ReadOne() (err error) {
|
||||
|
||||
if l.ID == FavoritesPseudoList.ID {
|
||||
// Already "built" the list in CanRead
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get list owner
|
||||
l.Owner, err = user.GetUserByID(l.OwnerID)
|
||||
if err != nil {
|
||||
|
@ -106,7 +106,7 @@ func (ld *ListDuplicate) Create(a web.Auth) (err error) {
|
||||
log.Debugf("Duplicated all buckets from list %d into %d", ld.ListID, ld.List.ID)
|
||||
|
||||
// Get all tasks + all task details
|
||||
tasks, _, _, err := getTasksForLists([]*List{{ID: ld.ListID}}, &taskOptions{})
|
||||
tasks, _, _, err := getTasksForLists([]*List{{ID: ld.ListID}}, a, &taskOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -25,6 +25,11 @@ import (
|
||||
// CanWrite return whether the user can write on that list or not
|
||||
func (l *List) CanWrite(a web.Auth) (bool, error) {
|
||||
|
||||
// The favorite list can't be edited
|
||||
if l.ID == FavoritesPseudoList.ID {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Get the list and check the right
|
||||
originalList := &List{ID: l.ID}
|
||||
err := originalList.GetSimpleByID()
|
||||
@ -63,6 +68,19 @@ func (l *List) CanWrite(a web.Auth) (bool, error) {
|
||||
|
||||
// CanRead checks if a user has read access to a list
|
||||
func (l *List) CanRead(a web.Auth) (bool, int, error) {
|
||||
|
||||
// The favorite list needs a special treatment
|
||||
if l.ID == FavoritesPseudoList.ID {
|
||||
owner, err := user.GetFromAuth(a)
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
|
||||
*l = FavoritesPseudoList
|
||||
l.Owner = owner
|
||||
return true, int(RightRead), nil
|
||||
}
|
||||
|
||||
// Check if the user is either owner or can read
|
||||
if err := l.GetSimpleByID(); err != nil {
|
||||
return false, 0, err
|
||||
@ -83,6 +101,10 @@ func (l *List) CanRead(a web.Auth) (bool, int, error) {
|
||||
|
||||
// CanUpdate checks if the user can update a list
|
||||
func (l *List) CanUpdate(a web.Auth) (canUpdate bool, err error) {
|
||||
// The favorite list can't be edited
|
||||
if l.ID == FavoritesPseudoList.ID {
|
||||
return false, nil
|
||||
}
|
||||
canUpdate, err = l.CanWrite(a)
|
||||
// If the list is archived and the user tries to un-archive it, let the request through
|
||||
if IsErrListIsArchived(err) && !l.IsArchived {
|
||||
@ -105,6 +127,11 @@ func (l *List) CanCreate(a web.Auth) (bool, error) {
|
||||
|
||||
// IsAdmin returns whether the user has admin rights on the list or not
|
||||
func (l *List) IsAdmin(a web.Auth) (bool, error) {
|
||||
// The favorite list can't be edited
|
||||
if l.ID == FavoritesPseudoList.ID {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
originalList := &List{ID: l.ID}
|
||||
err := originalList.GetSimpleByID()
|
||||
if err != nil {
|
||||
|
@ -62,6 +62,15 @@ var PseudoNamespace = Namespace{
|
||||
Updated: time.Now(),
|
||||
}
|
||||
|
||||
// FavoritesPseudoNamespace is a pseudo namespace used to hold favorited lists and tasks
|
||||
var FavoritesPseudoNamespace = Namespace{
|
||||
ID: -2,
|
||||
Title: "Favorites",
|
||||
Description: "Favorite lists and tasks.",
|
||||
Created: time.Now(),
|
||||
Updated: time.Now(),
|
||||
}
|
||||
|
||||
// TableName makes beautiful table names
|
||||
func (Namespace) TableName() string {
|
||||
return "namespaces"
|
||||
@ -79,6 +88,11 @@ func (n *Namespace) GetSimpleByID() (err error) {
|
||||
return
|
||||
}
|
||||
|
||||
if n.ID == FavoritesPseudoNamespace.ID {
|
||||
*n = FavoritesPseudoNamespace
|
||||
return
|
||||
}
|
||||
|
||||
namespaceFromDB := &Namespace{}
|
||||
exists, err := x.Where("id = ?", n.ID).Get(namespaceFromDB)
|
||||
if err != nil {
|
||||
@ -180,8 +194,17 @@ func (n *Namespace) ReadAll(a web.Auth, search string, page int, perPage int) (r
|
||||
)
|
||||
}
|
||||
|
||||
// Create our pseudo-namespace to hold the shared lists
|
||||
// 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{
|
||||
@ -266,6 +289,19 @@ func (n *Namespace) ReadAll(a web.Auth, search string, page int, perPage int) (r
|
||||
|
||||
// Remove the pseudonamespace if we don't have any shared lists
|
||||
if len(individualLists) == 0 {
|
||||
all = append(all[:1], all[2:]...)
|
||||
}
|
||||
|
||||
// Check if we have any favorites and remove the favorites namespace from the list if not
|
||||
favoriteCount, err := x.
|
||||
Join("INNER", "list", "tasks.list_id = list.id").
|
||||
Join("INNER", "namespaces", "list.namespace_id = namespaces.id").
|
||||
Where(builder.And(builder.Eq{"is_favorite": true}, builder.In("namespaces.id", namespaceids))).
|
||||
Count(&Task{})
|
||||
if err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
if favoriteCount == 0 {
|
||||
all = append(all[:0], all[1:]...)
|
||||
}
|
||||
|
||||
|
@ -143,8 +143,9 @@ func TestNamespace_ReadAll(t *testing.T) {
|
||||
namespaces := nn.([]*NamespaceWithLists)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, namespaces)
|
||||
assert.Len(t, namespaces, 9) // Total of 9 including shared
|
||||
assert.Equal(t, int64(-1), namespaces[0].ID) // The first one should be the one with the shared 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
|
||||
// Ensure every list and namespace are not archived
|
||||
for _, namespace := range namespaces {
|
||||
assert.False(t, namespace.IsArchived)
|
||||
@ -161,7 +162,8 @@ func TestNamespace_ReadAll(t *testing.T) {
|
||||
namespaces := nn.([]*NamespaceWithLists)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, namespaces)
|
||||
assert.Len(t, namespaces, 10) // Total of 10 including shared, one is archived
|
||||
assert.Equal(t, int64(-1), namespaces[0].ID) // The first one should be the one with the shared 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
|
||||
})
|
||||
}
|
||||
|
@ -150,7 +150,7 @@ func (tf *TaskCollection) ReadAll(a web.Auth, search string, page int, perPage i
|
||||
if err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
return getTasksForLists([]*List{list}, taskopts)
|
||||
return getTasksForLists([]*List{list}, a, taskopts)
|
||||
}
|
||||
|
||||
// If the list ID is not set, we get all tasks for the user.
|
||||
@ -176,5 +176,5 @@ func (tf *TaskCollection) ReadAll(a web.Auth, search string, page int, perPage i
|
||||
tf.Lists = []*List{{ID: tf.ListID}}
|
||||
}
|
||||
|
||||
return getTasksForLists(tf.Lists, taskopts)
|
||||
return getTasksForLists(tf.Lists, a, taskopts)
|
||||
}
|
||||
|
@ -66,6 +66,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
|
||||
CreatedBy: user1,
|
||||
ListID: 1,
|
||||
BucketID: 1,
|
||||
IsFavorite: true,
|
||||
Labels: []*Label{
|
||||
{
|
||||
ID: 4,
|
||||
@ -288,6 +289,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
|
||||
CreatedByID: 6,
|
||||
CreatedBy: user6,
|
||||
ListID: 6,
|
||||
IsFavorite: true,
|
||||
RelatedTasks: map[RelationKind][]*Task{},
|
||||
BucketID: 6,
|
||||
Created: time.Unix(1543626724, 0).In(loc),
|
||||
@ -484,6 +486,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
|
||||
Index: 1,
|
||||
CreatedByID: 1,
|
||||
ListID: 1,
|
||||
IsFavorite: true,
|
||||
Created: time.Unix(1543626724, 0).In(loc),
|
||||
Updated: time.Unix(1543626724, 0).In(loc),
|
||||
BucketID: 1,
|
||||
@ -836,6 +839,18 @@ func TestTaskCollection_ReadAll(t *testing.T) {
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "favorited tasks",
|
||||
args: defaultArgs,
|
||||
fields: fields{
|
||||
ListID: FavoritesPseudoList.ID,
|
||||
},
|
||||
want: []*Task{
|
||||
task1,
|
||||
task15,
|
||||
// Task 34 is also a favorite, but on a list user 1 has no access to.
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
@ -84,6 +84,9 @@ type Task struct {
|
||||
// All attachments this task has
|
||||
Attachments []*TaskAttachment `xorm:"-" json:"attachments"`
|
||||
|
||||
// True if a task is a favorite task. Favorite tasks show up in a separate "Important" list
|
||||
IsFavorite bool `xorm:"default false" json:"is_favorite"`
|
||||
|
||||
// A timestamp when this task was created. You cannot change this value.
|
||||
Created time.Time `xorm:"created not null" json:"created"`
|
||||
// A timestamp when this task was last updated. You cannot change this value.
|
||||
@ -165,7 +168,7 @@ func (t *Task) ReadAll(a web.Auth, search string, page int, perPage int) (result
|
||||
return nil, 0, 0, nil
|
||||
}
|
||||
|
||||
func getRawTasksForLists(lists []*List, opts *taskOptions) (tasks []*Task, resultCount int, totalItems int64, err error) {
|
||||
func getRawTasksForLists(lists []*List, a web.Auth, opts *taskOptions) (tasks []*Task, resultCount int, totalItems int64, err error) {
|
||||
|
||||
// If the user does not have any lists, don't try to get any tasks
|
||||
if len(lists) == 0 {
|
||||
@ -179,7 +182,11 @@ func getRawTasksForLists(lists []*List, opts *taskOptions) (tasks []*Task, resul
|
||||
|
||||
// Get all list IDs and get the tasks
|
||||
var listIDs []int64
|
||||
var hasFavoriteLists bool
|
||||
for _, l := range lists {
|
||||
if l.ID == FavoritesPseudoList.ID {
|
||||
hasFavoriteLists = true
|
||||
}
|
||||
listIDs = append(listIDs, l.ID)
|
||||
}
|
||||
|
||||
@ -274,11 +281,34 @@ func getRawTasksForLists(lists []*List, opts *taskOptions) (tasks []*Task, resul
|
||||
}
|
||||
}
|
||||
|
||||
var listIDCond builder.Cond
|
||||
var listCond builder.Cond
|
||||
if len(listIDs) > 0 {
|
||||
query = query.In("list_id", listIDs)
|
||||
queryCount = queryCount.In("list_id", listIDs)
|
||||
listIDCond = builder.In("list_id", listIDs)
|
||||
listCond = listIDCond
|
||||
}
|
||||
|
||||
if hasFavoriteLists {
|
||||
// Make sure users can only see their favorites
|
||||
userLists, _, _, err := getRawListsForUser(&listOptions{
|
||||
user: &user.User{ID: a.GetID()},
|
||||
page: -1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
|
||||
userListIDs := make([]int64, len(userLists))
|
||||
for _, l := range userLists {
|
||||
userListIDs = append(userListIDs, l.ID)
|
||||
}
|
||||
|
||||
listCond = builder.Or(listIDCond, builder.And(builder.Eq{"is_favorite": true}, builder.In("list_id", userListIDs)))
|
||||
}
|
||||
|
||||
query = query.Where(listCond)
|
||||
queryCount = queryCount.Where(listCond)
|
||||
|
||||
if len(filters) > 0 {
|
||||
if opts.filterConcat == filterConcatOr {
|
||||
query = query.Where(builder.Or(filters...))
|
||||
@ -311,9 +341,9 @@ func getRawTasksForLists(lists []*List, opts *taskOptions) (tasks []*Task, resul
|
||||
return tasks, len(tasks), totalItems, nil
|
||||
}
|
||||
|
||||
func getTasksForLists(lists []*List, opts *taskOptions) (tasks []*Task, resultCount int, totalItems int64, err error) {
|
||||
func getTasksForLists(lists []*List, a web.Auth, opts *taskOptions) (tasks []*Task, resultCount int, totalItems int64, err error) {
|
||||
|
||||
tasks, resultCount, totalItems, err = getRawTasksForLists(lists, opts)
|
||||
tasks, resultCount, totalItems, err = getRawTasksForLists(lists, a, opts)
|
||||
if err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
@ -760,6 +790,7 @@ func (t *Task) Update() (err error) {
|
||||
"bucket_id",
|
||||
"position",
|
||||
"repeat_from_current_date",
|
||||
"is_favorite",
|
||||
}
|
||||
|
||||
// Make sure we have a bucket
|
||||
@ -868,6 +899,10 @@ func (t *Task) Update() (err error) {
|
||||
if !t.RepeatFromCurrentDate {
|
||||
ot.RepeatFromCurrentDate = false
|
||||
}
|
||||
// Is Favorite
|
||||
if !t.IsFavorite {
|
||||
ot.IsFavorite = false
|
||||
}
|
||||
|
||||
_, err = s.ID(t.ID).
|
||||
Cols(colsToUpdate...).
|
||||
|
Reference in New Issue
Block a user