Skip to content

Commit d959828

Browse files
authored
Implement pagination for /api/project/list and /api/users/list (#3489)
* Implement pagination for /api/projects/list * Test /api/projects/list pagination * Update ProjectsAPIClient.list() * Return projects total_count * Fix APIClient.list() * Test APIClient.list() * Implement pagination for /api/users/list * Add name_pattern for projects * Add TestProjectsAPIClientList * Add server-side validation for project name * Allow _ in name_pattern * Add name_pattern for users * Add @overload for ProjectsAPIClient.list()
1 parent 28797f6 commit d959828

16 files changed

Lines changed: 903 additions & 82 deletions

File tree

src/dstack/_internal/core/models/projects.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from datetime import datetime
2-
from typing import List, Optional
2+
from typing import List, Optional, Union
33

44
from pydantic import UUID4
55

@@ -28,6 +28,16 @@ class Project(CoreModel):
2828
is_public: bool = False
2929

3030

31+
class ProjectsInfoList(CoreModel):
32+
total_count: Optional[int] = None
33+
projects: List[Project]
34+
35+
36+
# For backward compatibility with 0.20 clients, endpoints return `List[Project]` if `total_count` is None.
37+
# TODO: Replace with ProjectsInfoList in 0.21.
38+
ProjectsInfoListOrProjectsList = Union[List[Project], ProjectsInfoList]
39+
40+
3141
class ProjectHookConfig(CoreModel):
3242
"""
3343
This class can be inherited to extend the project creation configuration passed to the hooks.

src/dstack/_internal/core/models/users.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import enum
22
from datetime import datetime
3-
from typing import Optional
3+
from typing import List, Optional, Union
44

55
from pydantic import UUID4
66

@@ -42,6 +42,16 @@ class UserWithCreds(User):
4242
ssh_private_key: Optional[str] = None
4343

4444

45+
class UsersInfoList(CoreModel):
46+
total_count: Optional[int] = None
47+
users: List[User]
48+
49+
50+
# For backward compatibility with 0.20 clients, endpoints return `List[User]` if `total_count` is None.
51+
# TODO: Replace with UsersInfoList in 0.21.
52+
UsersInfoListOrUsersList = Union[List[User], UsersInfoList]
53+
54+
4555
class UserHookConfig(CoreModel):
4656
"""
4757
This class can be inherited to extend the user creation configuration passed to the hooks.

src/dstack/_internal/server/routers/projects.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from fastapi import APIRouter, Depends
44
from sqlalchemy.ext.asyncio import AsyncSession
55

6-
from dstack._internal.core.models.projects import Project
6+
from dstack._internal.core.models.projects import Project, ProjectsInfoListOrProjectsList
77
from dstack._internal.server.db import get_session
88
from dstack._internal.server.models import ProjectModel, UserModel
99
from dstack._internal.server.schemas.projects import (
@@ -36,14 +36,14 @@
3636
)
3737

3838

39-
@router.post("/list", response_model=List[Project])
39+
@router.post("/list", response_model=ProjectsInfoListOrProjectsList)
4040
async def list_projects(
4141
body: Optional[ListProjectsRequest] = None,
4242
session: AsyncSession = Depends(get_session),
4343
user: UserModel = Depends(Authenticated()),
4444
):
4545
"""
46-
Returns projects visible to the user, sorted by ascending `created_at`.
46+
Returns projects visible to the user.
4747
4848
Returns all accessible projects (member projects for regular users, all non-deleted
4949
projects for global admins, plus public projects if `include_not_joined` is `True`).
@@ -55,7 +55,15 @@ async def list_projects(
5555
body = ListProjectsRequest()
5656
return CustomORJSONResponse(
5757
await projects.list_user_accessible_projects(
58-
session=session, user=user, include_not_joined=body.include_not_joined
58+
session=session,
59+
user=user,
60+
include_not_joined=body.include_not_joined,
61+
return_total_count=body.return_total_count,
62+
name_pattern=body.name_pattern,
63+
prev_created_at=body.prev_created_at,
64+
prev_id=body.prev_id,
65+
limit=body.limit,
66+
ascending=body.ascending,
5967
)
6068
)
6169

src/dstack/_internal/server/routers/users.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
1-
from typing import List
1+
from typing import Optional
22

33
from fastapi import APIRouter, Depends
44
from sqlalchemy.ext.asyncio import AsyncSession
55

66
from dstack._internal.core.errors import ResourceNotExistsError
7-
from dstack._internal.core.models.users import User, UserWithCreds
7+
from dstack._internal.core.models.users import User, UsersInfoListOrUsersList, UserWithCreds
88
from dstack._internal.server.db import get_session
99
from dstack._internal.server.models import UserModel
1010
from dstack._internal.server.schemas.users import (
1111
CreateUserRequest,
1212
DeleteUsersRequest,
1313
GetUserRequest,
14+
ListUsersRequest,
1415
RefreshTokenRequest,
1516
UpdateUserRequest,
1617
)
@@ -28,12 +29,35 @@
2829
)
2930

3031

31-
@router.post("/list", response_model=List[User])
32+
@router.post("/list", response_model=UsersInfoListOrUsersList)
3233
async def list_users(
34+
body: Optional[ListUsersRequest] = None,
3335
session: AsyncSession = Depends(get_session),
3436
user: UserModel = Depends(Authenticated()),
3537
):
36-
return CustomORJSONResponse(await users.list_users_for_user(session=session, user=user))
38+
"""
39+
Returns users visible to the user, sorted by descending `created_at`.
40+
41+
Admins see all non-deleted users. Non-admins only see themselves.
42+
43+
The results are paginated. To get the next page, pass `created_at` and `id` of
44+
the last user from the previous page as `prev_created_at` and `prev_id`.
45+
"""
46+
if body is None:
47+
# For backward compatibility
48+
body = ListUsersRequest()
49+
return CustomORJSONResponse(
50+
await users.list_users_for_user(
51+
session=session,
52+
user=user,
53+
return_total_count=body.return_total_count,
54+
name_pattern=body.name_pattern,
55+
prev_created_at=body.prev_created_at,
56+
prev_id=body.prev_id,
57+
limit=body.limit,
58+
ascending=body.ascending,
59+
)
60+
)
3761

3862

3963
@router.post("/get_my_user", response_model=UserWithCreds)

src/dstack/_internal/server/schemas/fleets.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@
99

1010

1111
class ListFleetsRequest(CoreModel):
12-
project_name: Optional[str]
12+
project_name: Optional[str] = None
1313
only_active: bool = False
14-
prev_created_at: Optional[datetime]
15-
prev_id: Optional[UUID]
14+
prev_created_at: Optional[datetime] = None
15+
prev_id: Optional[UUID] = None
1616
limit: int = Field(100, ge=0, le=100)
1717
ascending: bool = False
1818

src/dstack/_internal/server/schemas/projects.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
from typing import Annotated, List
1+
from datetime import datetime
2+
from typing import Annotated, List, Optional
3+
from uuid import UUID
24

35
from pydantic import Field
46

@@ -8,8 +10,42 @@
810

911
class ListProjectsRequest(CoreModel):
1012
include_not_joined: Annotated[
11-
bool, Field(description="Include public projects where user is not a member")
13+
bool, Field(description="Include public projects where user is not a member.")
1214
] = True
15+
return_total_count: Annotated[
16+
bool, Field(description="Return `total_count` with the total number of projects.")
17+
] = False
18+
name_pattern: Annotated[
19+
Optional[str],
20+
Field(
21+
description="Include only projects with the name containing `name_pattern`.",
22+
regex="^[a-zA-Z0-9-_]*$",
23+
),
24+
] = None
25+
prev_created_at: Annotated[
26+
Optional[datetime],
27+
Field(
28+
description="Paginate projects by specifying `created_at` of the last (first) project in previous batch for descending (ascending)."
29+
),
30+
] = None
31+
prev_id: Annotated[
32+
Optional[UUID],
33+
Field(
34+
description=(
35+
"Paginate projects by specifying `id` of the last (first) project in previous batch for descending (ascending)."
36+
" Must be used together with `prev_created_at`."
37+
)
38+
),
39+
] = None
40+
limit: Annotated[
41+
int, Field(ge=0, le=2000, description="Limit number of projects returned.")
42+
] = 2000
43+
ascending: Annotated[
44+
bool,
45+
Field(
46+
description="Return projects sorted by `created_at` in ascending order. Defaults to descending."
47+
),
48+
] = False
1349

1450

1551
class CreateProjectRequest(CoreModel):

src/dstack/_internal/server/schemas/users.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,55 @@
1-
from typing import List, Optional
1+
from datetime import datetime
2+
from typing import Annotated, List, Optional
3+
from uuid import UUID
4+
5+
from pydantic import Field
26

37
from dstack._internal.core.models.common import CoreModel
48
from dstack._internal.core.models.users import GlobalRole
59

610

11+
class ListUsersRequest(CoreModel):
12+
return_total_count: Annotated[
13+
bool, Field(description="Return `total_count` with the total number of users.")
14+
] = False
15+
name_pattern: Annotated[
16+
Optional[str],
17+
Field(
18+
description="Include only users with the name containing `name_pattern`.",
19+
regex="^[a-zA-Z0-9-_]*$",
20+
),
21+
] = None
22+
prev_created_at: Annotated[
23+
Optional[datetime],
24+
Field(
25+
description=(
26+
"Paginate users by specifying `created_at` of the last (first) user in previous "
27+
"batch for descending (ascending)."
28+
)
29+
),
30+
] = None
31+
prev_id: Annotated[
32+
Optional[UUID],
33+
Field(
34+
description=(
35+
"Paginate users by specifying `id` of the last (first) user in previous batch "
36+
"for descending (ascending). Must be used together with `prev_created_at`."
37+
)
38+
),
39+
] = None
40+
limit: Annotated[int, Field(ge=0, le=2000, description="Limit number of users returned.")] = (
41+
2000
42+
)
43+
ascending: Annotated[
44+
bool,
45+
Field(
46+
description=(
47+
"Return users sorted by `created_at` in ascending order. Defaults to descending."
48+
)
49+
),
50+
] = False
51+
52+
753
class GetUserRequest(CoreModel):
854
username: str
955

0 commit comments

Comments
 (0)