Enable searching users by full email or name

This commit is contained in:
kolaente 2021-04-07 18:28:58 +02:00
parent 8ddc00bd29
commit 126f3acdc8
No known key found for this signature in database
GPG key ID: F40E70337AB24C9B
14 changed files with 191 additions and 42 deletions

View file

@ -120,7 +120,7 @@ var userListCmd = &cobra.Command{
s := db.NewSession() s := db.NewSession()
defer s.Close() defer s.Close()
users, err := user.ListUsers(s, "") users, err := user.ListAllUsers(s)
if err != nil { if err != nil {
_ = s.Rollback() _ = s.Rollback()
log.Fatalf("Error getting users: %s", err) log.Fatalf("Error getting users: %s", err)

View file

@ -58,6 +58,7 @@
email: 'user7@example.com' email: 'user7@example.com'
is_active: true is_active: true
issuer: local issuer: local
discoverable_by_email: true
updated: 2018-12-02 15:13:12 updated: 2018-12-02 15:13:12
created: 2018-12-01 15:13:12 created: 2018-12-01 15:13:12
- id: 8 - id: 8
@ -86,6 +87,7 @@
created: 2018-12-01 15:13:12 created: 2018-12-01 15:13:12
- id: 11 - id: 11
username: 'user11' username: 'user11'
name: 'Some one else'
password: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.' # 1234 password: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.' # 1234
email: 'user11@example.com' email: 'user11@example.com'
is_active: true is_active: true
@ -94,10 +96,12 @@
created: 2018-12-01 15:13:12 created: 2018-12-01 15:13:12
- id: 12 - id: 12
username: 'user12' username: 'user12'
name: 'Name with spaces'
password: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.' # 1234 password: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.' # 1234
email: 'user12@example.com' email: 'user12@example.com'
is_active: true is_active: true
issuer: local issuer: local
discoverable_by_name: true
updated: 2018-12-02 15:13:12 updated: 2018-12-02 15:13:12
created: 2018-12-01 15:13:12 created: 2018-12-01 15:13:12
- id: 13 - id: 13

View file

@ -28,11 +28,7 @@ func TestUserList(t *testing.T) {
t.Run("Normal test", func(t *testing.T) { t.Run("Normal test", func(t *testing.T) {
rec, err := newTestRequestWithUser(t, http.MethodPost, apiv1.UserList, &testuser1, "", nil, nil) rec, err := newTestRequestWithUser(t, http.MethodPost, apiv1.UserList, &testuser1, "", nil, nil)
assert.NoError(t, err) assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), `user1`) assert.Equal(t, "null\n", rec.Body.String())
assert.Contains(t, rec.Body.String(), `user2`)
assert.Contains(t, rec.Body.String(), `user3`)
assert.Contains(t, rec.Body.String(), `user4`)
assert.Contains(t, rec.Body.String(), `user5`)
}) })
t.Run("Search for user3", func(t *testing.T) { t.Run("Search for user3", func(t *testing.T) {
rec, err := newTestRequestWithUser(t, http.MethodPost, apiv1.UserList, &testuser1, "", map[string][]string{"s": {"user3"}}, nil) rec, err := newTestRequestWithUser(t, http.MethodPost, apiv1.UserList, &testuser1, "", map[string][]string{"s": {"user3"}}, nil)

View file

@ -0,0 +1,44 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2021 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 Affero General Public Licensee 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 Affero General Public Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package migration
import (
"src.techknowlogick.com/xormigrate"
"xorm.io/xorm"
)
type users20210407170753 struct {
DiscoverableByName bool `xorm:"bool default false index" json:"-"`
DiscoverableByEmail bool `xorm:"bool default false index" json:"-"`
}
func (users20210407170753) TableName() string {
return "users"
}
func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "20210407170753",
Description: "Add discoverable by email or name columns",
Migrate: func(tx *xorm.Engine) error {
return tx.Sync2(users20210407170753{})
},
Rollback: func(tx *xorm.Engine) error {
return nil
},
})
}

View file

@ -93,6 +93,7 @@ func TestListUsersFromList(t *testing.T) {
IsActive: true, IsActive: true,
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true, EmailRemindersEnabled: true,
DiscoverableByEmail: true,
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
} }
@ -129,6 +130,7 @@ func TestListUsersFromList(t *testing.T) {
testuser11 := &user.User{ testuser11 := &user.User{
ID: 11, ID: 11,
Username: "user11", Username: "user11",
Name: "Some one else",
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
IsActive: true, IsActive: true,
Issuer: "local", Issuer: "local",
@ -139,10 +141,12 @@ func TestListUsersFromList(t *testing.T) {
testuser12 := &user.User{ testuser12 := &user.User{
ID: 12, ID: 12,
Username: "user12", Username: "user12",
Name: "Name with spaces",
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
IsActive: true, IsActive: true,
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true, EmailRemindersEnabled: true,
DiscoverableByName: true,
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
} }

View file

@ -31,11 +31,11 @@ import (
// UserList gets all information about a user // UserList gets all information about a user
// @Summary Get users // @Summary Get users
// @Description Lists all users (without emailadresses). Also possible to search for a specific user. // @Description Search for a user by its username, name or full email. Name (not username) or email require that the user has enabled this in their settings.
// @tags user // @tags user
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param s query string false "Search for a user by its name." // @Param s query string false "The search criteria."
// @Security JWTKeyAuth // @Security JWTKeyAuth
// @Success 200 {array} user.User "All (found) users." // @Success 200 {array} user.User "All (found) users."
// @Failure 400 {object} web.HTTPError "Something's invalid." // @Failure 400 {object} web.HTTPError "Something's invalid."

View file

@ -38,7 +38,11 @@ type UserSettings struct {
// The new name of the current user. // The new name of the current user.
Name string `json:"name"` Name string `json:"name"`
// If enabled, sends email reminders of tasks to the user. // If enabled, sends email reminders of tasks to the user.
EmailRemindersEnabled bool `xorm:"bool default false" json:"email_reminders_enabled"` EmailRemindersEnabled bool `json:"email_reminders_enabled"`
// If true, this user can be found by their name or parts of it when searching for it.
DiscoverableByName bool `json:"discoverable_by_name"`
// If true, the user can be found when searching for their exact email.
DiscoverableByEmail bool `json:"discoverable_by_email"`
} }
// GetUserAvatarProvider returns the currently set user avatar // GetUserAvatarProvider returns the currently set user avatar
@ -161,6 +165,8 @@ func UpdateGeneralUserSettings(c echo.Context) error {
user.Name = us.Name user.Name = us.Name
user.EmailRemindersEnabled = us.EmailRemindersEnabled user.EmailRemindersEnabled = us.EmailRemindersEnabled
user.DiscoverableByEmail = us.DiscoverableByEmail
user.DiscoverableByName = us.DiscoverableByName
_, err = user2.UpdateUser(s, user) _, err = user2.UpdateUser(s, user)
if err != nil { if err != nil {

View file

@ -19,6 +19,8 @@ package v1
import ( import (
"net/http" "net/http"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/auth" "code.vikunja.io/api/pkg/modules/auth"
@ -28,6 +30,11 @@ import (
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
type userWithSettings struct {
user.User
Settings *UserSettings `json:"settings"`
}
// UserShow gets all informations about the current user // UserShow gets all informations about the current user
// @Summary Get user information // @Summary Get user information
// @Description Returns the current user object. // @Description Returns the current user object.
@ -48,10 +55,20 @@ func UserShow(c echo.Context) error {
s := db.NewSession() s := db.NewSession()
defer s.Close() defer s.Close()
user, err := models.GetUserOrLinkShareUser(s, a) u, err := models.GetUserOrLinkShareUser(s, a)
if err != nil { if err != nil {
return handler.HandleHTTPError(err, c) return handler.HandleHTTPError(err, c)
} }
return c.JSON(http.StatusOK, user) us := &userWithSettings{
User: *u,
Settings: &UserSettings{
Name: u.Name,
EmailRemindersEnabled: u.EmailRemindersEnabled,
DiscoverableByName: u.DiscoverableByName,
DiscoverableByEmail: u.DiscoverableByEmail,
},
}
return c.JSON(http.StatusOK, us)
} }

View file

@ -6976,7 +6976,7 @@ var doc = `{
"JWTKeyAuth": [] "JWTKeyAuth": []
} }
], ],
"description": "Lists all users (without emailadresses). Also possible to search for a specific user.", "description": "Search for a user by its username, name or full email. Name (not username) or email require that the user has enabled this in their settings.",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@ -6990,7 +6990,7 @@ var doc = `{
"parameters": [ "parameters": [
{ {
"type": "string", "type": "string",
"description": "Search for a user by its name.", "description": "The search criteria.",
"name": "s", "name": "s",
"in": "query" "in": "query"
} }
@ -8534,6 +8534,14 @@ var doc = `{
"v1.UserSettings": { "v1.UserSettings": {
"type": "object", "type": "object",
"properties": { "properties": {
"discoverable_by_email": {
"description": "If true, the user can be found when searching for their exact email.",
"type": "boolean"
},
"discoverable_by_name": {
"description": "If true, this user can be found by their name or parts of it when searching for it.",
"type": "boolean"
},
"email_reminders_enabled": { "email_reminders_enabled": {
"description": "If enabled, sends email reminders of tasks to the user.", "description": "If enabled, sends email reminders of tasks to the user.",
"type": "boolean" "type": "boolean"

View file

@ -6959,7 +6959,7 @@
"JWTKeyAuth": [] "JWTKeyAuth": []
} }
], ],
"description": "Lists all users (without emailadresses). Also possible to search for a specific user.", "description": "Search for a user by its username, name or full email. Name (not username) or email require that the user has enabled this in their settings.",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@ -6973,7 +6973,7 @@
"parameters": [ "parameters": [
{ {
"type": "string", "type": "string",
"description": "Search for a user by its name.", "description": "The search criteria.",
"name": "s", "name": "s",
"in": "query" "in": "query"
} }
@ -8517,6 +8517,14 @@
"v1.UserSettings": { "v1.UserSettings": {
"type": "object", "type": "object",
"properties": { "properties": {
"discoverable_by_email": {
"description": "If true, the user can be found when searching for their exact email.",
"type": "boolean"
},
"discoverable_by_name": {
"description": "If true, this user can be found by their name or parts of it when searching for it.",
"type": "boolean"
},
"email_reminders_enabled": { "email_reminders_enabled": {
"description": "If enabled, sends email reminders of tasks to the user.", "description": "If enabled, sends email reminders of tasks to the user.",
"type": "boolean" "type": "boolean"

View file

@ -1079,6 +1079,12 @@ definitions:
type: object type: object
v1.UserSettings: v1.UserSettings:
properties: properties:
discoverable_by_email:
description: If true, the user can be found when searching for their exact email.
type: boolean
discoverable_by_name:
description: If true, this user can be found by their name or parts of it when searching for it.
type: boolean
email_reminders_enabled: email_reminders_enabled:
description: If enabled, sends email reminders of tasks to the user. description: If enabled, sends email reminders of tasks to the user.
type: boolean type: boolean
@ -5662,9 +5668,9 @@ paths:
get: get:
consumes: consumes:
- application/json - application/json
description: Lists all users (without emailadresses). Also possible to search for a specific user. description: Search for a user by its username, name or full email. Name (not username) or email require that the user has enabled this in their settings.
parameters: parameters:
- description: Search for a user by its name. - description: The search criteria.
in: query in: query
name: s name: s
type: string type: string

View file

@ -70,6 +70,9 @@ type User struct {
// If enabled, sends email reminders of tasks to the user. // If enabled, sends email reminders of tasks to the user.
EmailRemindersEnabled bool `xorm:"bool default true" json:"-"` EmailRemindersEnabled bool `xorm:"bool default true" json:"-"`
DiscoverableByName bool `xorm:"bool default false index" json:"-"`
DiscoverableByEmail bool `xorm:"bool default false index" json:"-"`
// A timestamp when this task was created. You cannot change this value. // A timestamp when this task was created. You cannot change this value.
Created time.Time `xorm:"created not null" json:"created"` Created time.Time `xorm:"created not null" json:"created"`
// A timestamp when this task was last updated. You cannot change this value. // A timestamp when this task was last updated. You cannot change this value.
@ -366,6 +369,8 @@ func UpdateUser(s *xorm.Session, user *User) (updatedUser *User, err error) {
"is_active", "is_active",
"name", "name",
"email_reminders_enabled", "email_reminders_enabled",
"discoverable_by_name",
"discoverable_by_email",
). ).
Update(user) Update(user)
if err != nil { if err != nil {

View file

@ -373,10 +373,63 @@ func TestListUsers(t *testing.T) {
s := db.NewSession() s := db.NewSession()
defer s.Close() defer s.Close()
all, err := ListUsers(s, "") all, err := ListAllUsers(s)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, all, 14) assert.Len(t, all, 14)
}) })
t.Run("no search term", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
all, err := ListUsers(s, "")
assert.NoError(t, err)
assert.Len(t, all, 0)
})
t.Run("not discoverable by email", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
all, err := ListUsers(s, "user1@example.com")
assert.NoError(t, err)
assert.Len(t, all, 0)
db.AssertExists(t, "users", map[string]interface{}{
"email": "user1@example.com",
}, false)
})
t.Run("not discoverable by name", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
all, err := ListUsers(s, "one else")
assert.NoError(t, err)
assert.Len(t, all, 0)
db.AssertExists(t, "users", map[string]interface{}{
"name": "Some one else",
}, false)
})
t.Run("discoverable by email", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
all, err := ListUsers(s, "user7@example.com")
assert.NoError(t, err)
assert.Len(t, all, 1)
assert.Equal(t, int64(7), all[0].ID)
})
t.Run("discoverable by partial name", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
all, err := ListUsers(s, "with space")
assert.NoError(t, err)
assert.Len(t, all, 1)
assert.Equal(t, int64(12), all[0].ID)
})
} }
func TestUserPasswordReset(t *testing.T) { func TestUserPasswordReset(t *testing.T) {

View file

@ -17,42 +17,40 @@
package user package user
import ( import (
"strconv"
"strings" "strings"
"xorm.io/builder"
"xorm.io/xorm" "xorm.io/xorm"
"code.vikunja.io/api/pkg/log"
) )
// ListUsers returns a list with all users, filtered by an optional searchstring // ListUsers returns a list with all users, filtered by an optional searchstring
func ListUsers(s *xorm.Session, searchterm string) (users []*User, err error) { func ListUsers(s *xorm.Session, search string) (users []*User, err error) {
vals := strings.Split(searchterm, ",") // Prevent searching for placeholders
ids := []int64{} search = strings.ReplaceAll(search, "%", "")
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 { if search == "" || strings.ReplaceAll(search, " ", "") == "" {
err = s.
In("id", ids).
Find(&users)
return
}
if searchterm == "" {
err = s.Find(&users)
return return
} }
err = s. err = s.
Where("username LIKE ?", "%"+searchterm+"%"). Where(builder.Or(
builder.Like{"username", "%" + search + "%"},
builder.And(
builder.Eq{"email": search},
builder.Eq{"discoverable_by_email": true},
),
builder.And(
builder.Like{"name", "%" + search + "%"},
builder.Eq{"discoverable_by_name": true},
),
)).
Find(&users) Find(&users)
return return
} }
// ListAllUsers returns all users
func ListAllUsers(s *xorm.Session) (users []*User, err error) {
err = s.Find(&users)
return
}