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:
Joobi S B
2024-12-17 15:24:54 +11:00
committed by GitHub
parent f16c80916e
commit e191d9650c
8 changed files with 170 additions and 4 deletions
+20
View File
@@ -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 {
+2
View File
@@ -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,
) )
+17 -2
View File
@@ -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,
) )
+11
View File
@@ -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
+2
View File
@@ -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
+2
View File
@@ -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),
}) })
+110
View File
@@ -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")
}) })
} }
+6 -2
View File
@@ -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.