Skip to content

Commit a284fff

Browse files
authored
Fix the "No fleets" warning in UI (#3669)
Do not return projects with imported fleets in `/list_only_no_fleets`, so that the UI does not show the "No fleets" warning for projects that import fleets.
1 parent ff712e0 commit a284fff

3 files changed

Lines changed: 62 additions & 11 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ async def list_only_no_fleets(
7575
):
7676
"""
7777
Returns only projects where the user is a member and that have no active fleets,
78-
sorted by ascending `created_at`.
78+
neither owned nor imported, sorted by ascending `created_at`.
7979
8080
Active fleets are those with `deleted == False`. Projects with deleted fleets
8181
(but no active fleets) are included.

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,8 @@ async def list_projects_with_no_active_fleets(
156156
user: UserModel,
157157
) -> List[Project]:
158158
"""
159-
Returns all projects where the user is a member that have no active fleets.
159+
Returns all projects where the user is a member that have no active fleets,
160+
neither owned nor imported.
160161
161162
Active fleets are those with `deleted == False`. Projects with only deleted fleets
162163
(or no fleets) are included. Deleted projects are excluded.
@@ -178,7 +179,14 @@ async def list_projects_with_no_active_fleets(
178179
.outerjoin(
179180
active_fleet_alias,
180181
and_(
181-
active_fleet_alias.project_id == ProjectModel.id,
182+
or_(
183+
active_fleet_alias.project_id == ProjectModel.id,
184+
exists().where(
185+
ImportModel.project_id == ProjectModel.id,
186+
ImportModel.export_id == ExportedFleetModel.export_id,
187+
ExportedFleetModel.fleet_id == active_fleet_alias.id,
188+
),
189+
),
182190
active_fleet_alias.deleted == False,
183191
),
184192
)

src/tests/_internal/server/routers/test_projects.py

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from dstack._internal.server.services.permissions import DefaultPermissions
1616
from dstack._internal.server.services.projects import add_project_member
1717
from dstack._internal.server.testing.common import (
18+
create_export,
1819
create_fleet,
1920
create_project,
2021
create_repo,
@@ -33,14 +34,6 @@ async def test_returns_40x_if_not_authenticated(self, test_db, client: AsyncClie
3334
response = await client.post("/api/projects/list")
3435
assert response.status_code in [401, 403]
3536

36-
@pytest.mark.asyncio
37-
@pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
38-
async def test_list_only_no_fleets_returns_40x_if_not_authenticated(
39-
self, test_db, client: AsyncClient
40-
):
41-
response = await client.post("/api/projects/list_only_no_fleets")
42-
assert response.status_code in [401, 403]
43-
4437
@pytest.mark.asyncio
4538
@pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
4639
async def test_returns_empty_list(self, test_db, session: AsyncSession, client: AsyncClient):
@@ -391,6 +384,14 @@ async def test_returns_total_count(self, test_db, session: AsyncSession, client:
391384

392385

393386
class TestListOnlyNoFleets:
387+
@pytest.mark.asyncio
388+
@pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
389+
async def test_list_only_no_fleets_returns_40x_if_not_authenticated(
390+
self, test_db, client: AsyncClient
391+
):
392+
response = await client.post("/api/projects/list_only_no_fleets")
393+
assert response.status_code in [401, 403]
394+
394395
@pytest.mark.asyncio
395396
@pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
396397
async def test_only_no_fleets_returns_projects_without_active_fleets(
@@ -556,6 +557,48 @@ async def test_only_no_fleets_empty_result(
556557
projects = response.json()
557558
assert len(projects) == 0
558559

560+
@pytest.mark.asyncio
561+
@pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
562+
async def test_only_no_fleets_not_includes_project_with_imported_fleets(
563+
self, test_db, session: AsyncSession, client: AsyncClient
564+
):
565+
user = await create_user(session=session, global_role=GlobalRole.USER)
566+
exporter_project = await create_project(
567+
session=session, owner=user, name="exporter_project"
568+
)
569+
await add_project_member(
570+
session=session, project=exporter_project, user=user, project_role=ProjectRole.USER
571+
)
572+
fleet = await create_fleet(session=session, project=exporter_project)
573+
importer_project = await create_project(
574+
session=session, owner=user, name="importer_project"
575+
)
576+
await add_project_member(
577+
session=session, project=importer_project, user=user, project_role=ProjectRole.USER
578+
)
579+
await create_export(
580+
session=session,
581+
exporter_project=exporter_project,
582+
importer_projects=[importer_project],
583+
exported_fleets=[fleet],
584+
)
585+
project_no_fleets = await create_project(
586+
session=session, owner=user, name="project_no_fleets"
587+
)
588+
await add_project_member(
589+
session=session, project=project_no_fleets, user=user, project_role=ProjectRole.USER
590+
)
591+
592+
response = await client.post(
593+
"/api/projects/list_only_no_fleets",
594+
headers=get_auth_headers(user.token),
595+
)
596+
assert response.status_code == 200
597+
projects = response.json()
598+
599+
assert len(projects) == 1
600+
assert projects[0]["project_name"] == "project_no_fleets"
601+
559602
@pytest.mark.asyncio
560603
@pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
561604
async def test_only_no_fleets_respects_user_permissions(

0 commit comments

Comments
 (0)