mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: support created_at filter for the GET /users endpoint (#15633)
Closes https://github.com/coder/coder/issues/12747 We support these filters currently: https://coder.com/docs/v2/latest/admin/users#user-filtering, adding `created_at` filter as well.
This commit is contained in:
@@ -5800,6 +5800,26 @@ func (q *FakeQuerier) GetUsers(_ context.Context, params database.GetUsersParams
|
|||||||
users = usersFilteredByRole
|
users = usersFilteredByRole
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !params.CreatedBefore.IsZero() {
|
||||||
|
usersFilteredByCreatedAt := make([]database.User, 0, len(users))
|
||||||
|
for i, user := range users {
|
||||||
|
if user.CreatedAt.Before(params.CreatedBefore) {
|
||||||
|
usersFilteredByCreatedAt = append(usersFilteredByCreatedAt, users[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
users = usersFilteredByCreatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
if !params.CreatedAfter.IsZero() {
|
||||||
|
usersFilteredByCreatedAt := make([]database.User, 0, len(users))
|
||||||
|
for i, user := range users {
|
||||||
|
if user.CreatedAt.After(params.CreatedAfter) {
|
||||||
|
usersFilteredByCreatedAt = append(usersFilteredByCreatedAt, users[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
users = usersFilteredByCreatedAt
|
||||||
|
}
|
||||||
|
|
||||||
if !params.LastSeenBefore.IsZero() {
|
if !params.LastSeenBefore.IsZero() {
|
||||||
usersFilteredByLastSeen := make([]database.User, 0, len(users))
|
usersFilteredByLastSeen := make([]database.User, 0, len(users))
|
||||||
for i, user := range users {
|
for i, user := range users {
|
||||||
|
|||||||
@@ -391,6 +391,8 @@ func (q *sqlQuerier) GetAuthorizedUsers(ctx context.Context, arg GetUsersParams,
|
|||||||
pq.Array(arg.RbacRole),
|
pq.Array(arg.RbacRole),
|
||||||
arg.LastSeenBefore,
|
arg.LastSeenBefore,
|
||||||
arg.LastSeenAfter,
|
arg.LastSeenAfter,
|
||||||
|
arg.CreatedBefore,
|
||||||
|
arg.CreatedAfter,
|
||||||
arg.OffsetOpt,
|
arg.OffsetOpt,
|
||||||
arg.LimitOpt,
|
arg.LimitOpt,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10404,16 +10404,27 @@ WHERE
|
|||||||
last_seen_at >= $6
|
last_seen_at >= $6
|
||||||
ELSE true
|
ELSE true
|
||||||
END
|
END
|
||||||
|
-- Filter by created_at
|
||||||
|
AND CASE
|
||||||
|
WHEN $7 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
|
||||||
|
created_at <= $7
|
||||||
|
ELSE true
|
||||||
|
END
|
||||||
|
AND CASE
|
||||||
|
WHEN $8 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
|
||||||
|
created_at >= $8
|
||||||
|
ELSE true
|
||||||
|
END
|
||||||
-- End of filters
|
-- End of filters
|
||||||
|
|
||||||
-- Authorize Filter clause will be injected below in GetAuthorizedUsers
|
-- Authorize Filter clause will be injected below in GetAuthorizedUsers
|
||||||
-- @authorize_filter
|
-- @authorize_filter
|
||||||
ORDER BY
|
ORDER BY
|
||||||
-- Deterministic and consistent ordering of all users. This is to ensure consistent pagination.
|
-- Deterministic and consistent ordering of all users. This is to ensure consistent pagination.
|
||||||
LOWER(username) ASC OFFSET $7
|
LOWER(username) ASC OFFSET $9
|
||||||
LIMIT
|
LIMIT
|
||||||
-- A null limit means "no limit", so 0 means return all
|
-- A null limit means "no limit", so 0 means return all
|
||||||
NULLIF($8 :: int, 0)
|
NULLIF($10 :: int, 0)
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetUsersParams struct {
|
type GetUsersParams struct {
|
||||||
@@ -10423,6 +10434,8 @@ type GetUsersParams struct {
|
|||||||
RbacRole []string `db:"rbac_role" json:"rbac_role"`
|
RbacRole []string `db:"rbac_role" json:"rbac_role"`
|
||||||
LastSeenBefore time.Time `db:"last_seen_before" json:"last_seen_before"`
|
LastSeenBefore time.Time `db:"last_seen_before" json:"last_seen_before"`
|
||||||
LastSeenAfter time.Time `db:"last_seen_after" json:"last_seen_after"`
|
LastSeenAfter time.Time `db:"last_seen_after" json:"last_seen_after"`
|
||||||
|
CreatedBefore time.Time `db:"created_before" json:"created_before"`
|
||||||
|
CreatedAfter time.Time `db:"created_after" json:"created_after"`
|
||||||
OffsetOpt int32 `db:"offset_opt" json:"offset_opt"`
|
OffsetOpt int32 `db:"offset_opt" json:"offset_opt"`
|
||||||
LimitOpt int32 `db:"limit_opt" json:"limit_opt"`
|
LimitOpt int32 `db:"limit_opt" json:"limit_opt"`
|
||||||
}
|
}
|
||||||
@@ -10458,6 +10471,8 @@ func (q *sqlQuerier) GetUsers(ctx context.Context, arg GetUsersParams) ([]GetUse
|
|||||||
pq.Array(arg.RbacRole),
|
pq.Array(arg.RbacRole),
|
||||||
arg.LastSeenBefore,
|
arg.LastSeenBefore,
|
||||||
arg.LastSeenAfter,
|
arg.LastSeenAfter,
|
||||||
|
arg.CreatedBefore,
|
||||||
|
arg.CreatedAfter,
|
||||||
arg.OffsetOpt,
|
arg.OffsetOpt,
|
||||||
arg.LimitOpt,
|
arg.LimitOpt,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -199,6 +199,17 @@ WHERE
|
|||||||
last_seen_at >= @last_seen_after
|
last_seen_at >= @last_seen_after
|
||||||
ELSE true
|
ELSE true
|
||||||
END
|
END
|
||||||
|
-- Filter by created_at
|
||||||
|
AND CASE
|
||||||
|
WHEN @created_before :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
|
||||||
|
created_at <= @created_before
|
||||||
|
ELSE true
|
||||||
|
END
|
||||||
|
AND CASE
|
||||||
|
WHEN @created_after :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
|
||||||
|
created_at >= @created_after
|
||||||
|
ELSE true
|
||||||
|
END
|
||||||
-- End of filters
|
-- End of filters
|
||||||
|
|
||||||
-- Authorize Filter clause will be injected below in GetAuthorizedUsers
|
-- Authorize Filter clause will be injected below in GetAuthorizedUsers
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ func Users(query string) (database.GetUsersParams, []codersdk.ValidationError) {
|
|||||||
RbacRole: parser.Strings(values, []string{}, "role"),
|
RbacRole: parser.Strings(values, []string{}, "role"),
|
||||||
LastSeenAfter: parser.Time3339Nano(values, time.Time{}, "last_seen_after"),
|
LastSeenAfter: parser.Time3339Nano(values, time.Time{}, "last_seen_after"),
|
||||||
LastSeenBefore: parser.Time3339Nano(values, time.Time{}, "last_seen_before"),
|
LastSeenBefore: parser.Time3339Nano(values, time.Time{}, "last_seen_before"),
|
||||||
|
CreatedAfter: parser.Time3339Nano(values, time.Time{}, "created_after"),
|
||||||
|
CreatedBefore: parser.Time3339Nano(values, time.Time{}, "created_before"),
|
||||||
}
|
}
|
||||||
parser.ErrorExcessParams(values)
|
parser.ErrorExcessParams(values)
|
||||||
return filter, parser.Errors
|
return filter, parser.Errors
|
||||||
|
|||||||
@@ -317,6 +317,8 @@ func (api *API) GetUsers(rw http.ResponseWriter, r *http.Request) ([]database.Us
|
|||||||
RbacRole: params.RbacRole,
|
RbacRole: params.RbacRole,
|
||||||
LastSeenBefore: params.LastSeenBefore,
|
LastSeenBefore: params.LastSeenBefore,
|
||||||
LastSeenAfter: params.LastSeenAfter,
|
LastSeenAfter: params.LastSeenAfter,
|
||||||
|
CreatedAfter: params.CreatedAfter,
|
||||||
|
CreatedBefore: params.CreatedBefore,
|
||||||
OffsetOpt: int32(paginationParams.Offset),
|
OffsetOpt: int32(paginationParams.Offset),
|
||||||
LimitOpt: int32(paginationParams.Limit),
|
LimitOpt: int32(paginationParams.Limit),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -26,9 +26,11 @@ import (
|
|||||||
"github.com/coder/coder/v2/coderd/audit"
|
"github.com/coder/coder/v2/coderd/audit"
|
||||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||||
"github.com/coder/coder/v2/coderd/database"
|
"github.com/coder/coder/v2/coderd/database"
|
||||||
|
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||||
"github.com/coder/coder/v2/coderd/database/dbfake"
|
"github.com/coder/coder/v2/coderd/database/dbfake"
|
||||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||||
|
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||||
"github.com/coder/coder/v2/coderd/rbac"
|
"github.com/coder/coder/v2/coderd/rbac"
|
||||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||||
@@ -1515,6 +1517,73 @@ func TestUsersFilter(t *testing.T) {
|
|||||||
users = append(users, user)
|
users = append(users, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add users with different creation dates for testing date filters
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
// nolint:gocritic // Using system context is necessary to seed data in tests
|
||||||
|
user1, err := api.Database.InsertUser(dbauthz.AsSystemRestricted(ctx), database.InsertUserParams{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Email: fmt.Sprintf("before%d@coder.com", i),
|
||||||
|
Username: fmt.Sprintf("before%d", i),
|
||||||
|
LoginType: database.LoginTypeNone,
|
||||||
|
Status: string(codersdk.UserStatusActive),
|
||||||
|
RBACRoles: []string{codersdk.RoleMember},
|
||||||
|
CreatedAt: dbtime.Time(time.Date(2022, 12, 15+i, 12, 0, 0, 0, time.UTC)),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// The expected timestamps must be parsed from strings to compare equal during `ElementsMatch`
|
||||||
|
sdkUser1 := db2sdk.User(user1, nil)
|
||||||
|
sdkUser1.CreatedAt, err = time.Parse(time.RFC3339, sdkUser1.CreatedAt.Format(time.RFC3339))
|
||||||
|
require.NoError(t, err)
|
||||||
|
sdkUser1.UpdatedAt, err = time.Parse(time.RFC3339, sdkUser1.UpdatedAt.Format(time.RFC3339))
|
||||||
|
require.NoError(t, err)
|
||||||
|
sdkUser1.LastSeenAt, err = time.Parse(time.RFC3339, sdkUser1.LastSeenAt.Format(time.RFC3339))
|
||||||
|
require.NoError(t, err)
|
||||||
|
users = append(users, sdkUser1)
|
||||||
|
|
||||||
|
// nolint:gocritic //Using system context is necessary to seed data in tests
|
||||||
|
user2, err := api.Database.InsertUser(dbauthz.AsSystemRestricted(ctx), database.InsertUserParams{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Email: fmt.Sprintf("during%d@coder.com", i),
|
||||||
|
Username: fmt.Sprintf("during%d", i),
|
||||||
|
LoginType: database.LoginTypeNone,
|
||||||
|
Status: string(codersdk.UserStatusActive),
|
||||||
|
RBACRoles: []string{codersdk.RoleOwner},
|
||||||
|
CreatedAt: dbtime.Time(time.Date(2023, 1, 15+i, 12, 0, 0, 0, time.UTC)),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
sdkUser2 := db2sdk.User(user2, nil)
|
||||||
|
sdkUser2.CreatedAt, err = time.Parse(time.RFC3339, sdkUser2.CreatedAt.Format(time.RFC3339))
|
||||||
|
require.NoError(t, err)
|
||||||
|
sdkUser2.UpdatedAt, err = time.Parse(time.RFC3339, sdkUser2.UpdatedAt.Format(time.RFC3339))
|
||||||
|
require.NoError(t, err)
|
||||||
|
sdkUser2.LastSeenAt, err = time.Parse(time.RFC3339, sdkUser2.LastSeenAt.Format(time.RFC3339))
|
||||||
|
require.NoError(t, err)
|
||||||
|
users = append(users, sdkUser2)
|
||||||
|
|
||||||
|
// nolint:gocritic // Using system context is necessary to seed data in tests
|
||||||
|
user3, err := api.Database.InsertUser(dbauthz.AsSystemRestricted(ctx), database.InsertUserParams{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Email: fmt.Sprintf("after%d@coder.com", i),
|
||||||
|
Username: fmt.Sprintf("after%d", i),
|
||||||
|
LoginType: database.LoginTypeNone,
|
||||||
|
Status: string(codersdk.UserStatusActive),
|
||||||
|
RBACRoles: []string{codersdk.RoleOwner},
|
||||||
|
CreatedAt: dbtime.Time(time.Date(2023, 2, 15+i, 12, 0, 0, 0, time.UTC)),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
sdkUser3 := db2sdk.User(user3, nil)
|
||||||
|
sdkUser3.CreatedAt, err = time.Parse(time.RFC3339, sdkUser3.CreatedAt.Format(time.RFC3339))
|
||||||
|
require.NoError(t, err)
|
||||||
|
sdkUser3.UpdatedAt, err = time.Parse(time.RFC3339, sdkUser3.UpdatedAt.Format(time.RFC3339))
|
||||||
|
require.NoError(t, err)
|
||||||
|
sdkUser3.LastSeenAt, err = time.Parse(time.RFC3339, sdkUser3.LastSeenAt.Format(time.RFC3339))
|
||||||
|
require.NoError(t, err)
|
||||||
|
users = append(users, sdkUser3)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Setup done ---
|
// --- Setup done ---
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
Name string
|
Name string
|
||||||
@@ -1657,6 +1726,37 @@ func TestUsersFilter(t *testing.T) {
|
|||||||
return u.LastSeenAt.Before(end) && u.LastSeenAt.After(start)
|
return u.LastSeenAt.Before(end) && u.LastSeenAt.After(start)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "CreatedAtBefore",
|
||||||
|
Filter: codersdk.UsersRequest{
|
||||||
|
SearchQuery: `created_before:"2023-01-31T23:59:59Z"`,
|
||||||
|
},
|
||||||
|
FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool {
|
||||||
|
end := time.Date(2023, 1, 31, 23, 59, 59, 0, time.UTC)
|
||||||
|
return u.CreatedAt.Before(end)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "CreatedAtAfter",
|
||||||
|
Filter: codersdk.UsersRequest{
|
||||||
|
SearchQuery: `created_after:"2023-01-01T00:00:00Z"`,
|
||||||
|
},
|
||||||
|
FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool {
|
||||||
|
start := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
return u.CreatedAt.After(start)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "CreatedAtRange",
|
||||||
|
Filter: codersdk.UsersRequest{
|
||||||
|
SearchQuery: `created_after:"2023-01-01T00:00:00Z" created_before:"2023-01-31T23:59:59Z"`,
|
||||||
|
},
|
||||||
|
FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool {
|
||||||
|
start := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
end := time.Date(2023, 1, 31, 23, 59, 59, 0, time.UTC)
|
||||||
|
return u.CreatedAt.After(start) && u.CreatedAt.Before(end)
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, c := range testCases {
|
for _, c := range testCases {
|
||||||
@@ -1677,6 +1777,16 @@ func TestUsersFilter(t *testing.T) {
|
|||||||
exp = append(exp, made)
|
exp = append(exp, made)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: This can be removed with dbmem
|
||||||
|
if !dbtestutil.WillUsePostgres() {
|
||||||
|
for i := range matched.Users {
|
||||||
|
if len(matched.Users[i].OrganizationIDs) == 0 {
|
||||||
|
matched.Users[i].OrganizationIDs = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
require.ElementsMatch(t, exp, matched.Users, "expected users returned")
|
require.ElementsMatch(t, exp, matched.Users, "expected users returned")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,8 +185,10 @@ to use the Coder's filter query:
|
|||||||
|
|
||||||
- To find active users, use the filter `status:active`.
|
- To find active users, use the filter `status:active`.
|
||||||
- To find admin users, use the filter `role:admin`.
|
- To find admin users, use the filter `role:admin`.
|
||||||
- To find users have not been active since July 2023:
|
- To find users who have not been active since July 2023:
|
||||||
`status:active last_seen_before:"2023-07-01T00:00:00Z"`
|
`status:active last_seen_before:"2023-07-01T00:00:00Z"`
|
||||||
|
- To find users who were created between January 1 and January 18, 2023:
|
||||||
|
`created_before:"2023-01-18T00:00:00Z" created_after:"2023-01-01T23:59:59Z"`
|
||||||
|
|
||||||
The following filters are supported:
|
The following filters are supported:
|
||||||
|
|
||||||
@@ -195,6 +197,8 @@ The following filters are supported:
|
|||||||
- `role` - Represents the role of the user. You can refer to the
|
- `role` - Represents the role of the user. You can refer to the
|
||||||
[TemplateRole documentation](https://pkg.go.dev/github.com/coder/coder/v2/codersdk#TemplateRole)
|
[TemplateRole documentation](https://pkg.go.dev/github.com/coder/coder/v2/codersdk#TemplateRole)
|
||||||
for a list of supported user roles.
|
for a list of supported user roles.
|
||||||
- `last_seen_before` and `last_seen_after` - The last time a used has used the
|
- `last_seen_before` and `last_seen_after` - The last time a user has used the
|
||||||
platform (e.g. logging in, any API requests, connecting to workspaces). Uses
|
platform (e.g. logging in, any API requests, connecting to workspaces). Uses
|
||||||
the RFC3339Nano format.
|
the RFC3339Nano format.
|
||||||
|
- `created_before` and `created_after` - The time a user was created. Uses the
|
||||||
|
RFC3339Nano format.
|
||||||
|
|||||||
Reference in New Issue
Block a user