diff --git a/src/dstack/_internal/server/routers/projects.py b/src/dstack/_internal/server/routers/projects.py index 32c14c571..864e7b313 100644 --- a/src/dstack/_internal/server/routers/projects.py +++ b/src/dstack/_internal/server/routers/projects.py @@ -75,7 +75,7 @@ async def list_only_no_fleets( ): """ Returns only projects where the user is a member and that have no active fleets, - sorted by ascending `created_at`. + neither owned nor imported, sorted by ascending `created_at`. Active fleets are those with `deleted == False`. Projects with deleted fleets (but no active fleets) are included. diff --git a/src/dstack/_internal/server/services/fleets.py b/src/dstack/_internal/server/services/fleets.py index 8fb93d08d..de7c86510 100644 --- a/src/dstack/_internal/server/services/fleets.py +++ b/src/dstack/_internal/server/services/fleets.py @@ -156,7 +156,8 @@ async def list_projects_with_no_active_fleets( user: UserModel, ) -> List[Project]: """ - Returns all projects where the user is a member that have no active fleets. + Returns all projects where the user is a member that have no active fleets, + neither owned nor imported. Active fleets are those with `deleted == False`. Projects with only deleted fleets (or no fleets) are included. Deleted projects are excluded. @@ -178,7 +179,14 @@ async def list_projects_with_no_active_fleets( .outerjoin( active_fleet_alias, and_( - active_fleet_alias.project_id == ProjectModel.id, + or_( + active_fleet_alias.project_id == ProjectModel.id, + exists().where( + ImportModel.project_id == ProjectModel.id, + ImportModel.export_id == ExportedFleetModel.export_id, + ExportedFleetModel.fleet_id == active_fleet_alias.id, + ), + ), active_fleet_alias.deleted == False, ), ) diff --git a/src/tests/_internal/server/routers/test_projects.py b/src/tests/_internal/server/routers/test_projects.py index 6903e7d3d..cf9068748 100644 --- a/src/tests/_internal/server/routers/test_projects.py +++ b/src/tests/_internal/server/routers/test_projects.py @@ -15,6 +15,7 @@ from dstack._internal.server.services.permissions import DefaultPermissions from dstack._internal.server.services.projects import add_project_member from dstack._internal.server.testing.common import ( + create_export, create_fleet, create_project, create_repo, @@ -33,14 +34,6 @@ async def test_returns_40x_if_not_authenticated(self, test_db, client: AsyncClie response = await client.post("/api/projects/list") assert response.status_code in [401, 403] - @pytest.mark.asyncio - @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) - async def test_list_only_no_fleets_returns_40x_if_not_authenticated( - self, test_db, client: AsyncClient - ): - response = await client.post("/api/projects/list_only_no_fleets") - assert response.status_code in [401, 403] - @pytest.mark.asyncio @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) 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: class TestListOnlyNoFleets: + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_list_only_no_fleets_returns_40x_if_not_authenticated( + self, test_db, client: AsyncClient + ): + response = await client.post("/api/projects/list_only_no_fleets") + assert response.status_code in [401, 403] + @pytest.mark.asyncio @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) async def test_only_no_fleets_returns_projects_without_active_fleets( @@ -556,6 +557,48 @@ async def test_only_no_fleets_empty_result( projects = response.json() assert len(projects) == 0 + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_only_no_fleets_not_includes_project_with_imported_fleets( + self, test_db, session: AsyncSession, client: AsyncClient + ): + user = await create_user(session=session, global_role=GlobalRole.USER) + exporter_project = await create_project( + session=session, owner=user, name="exporter_project" + ) + await add_project_member( + session=session, project=exporter_project, user=user, project_role=ProjectRole.USER + ) + fleet = await create_fleet(session=session, project=exporter_project) + importer_project = await create_project( + session=session, owner=user, name="importer_project" + ) + await add_project_member( + session=session, project=importer_project, user=user, project_role=ProjectRole.USER + ) + await create_export( + session=session, + exporter_project=exporter_project, + importer_projects=[importer_project], + exported_fleets=[fleet], + ) + project_no_fleets = await create_project( + session=session, owner=user, name="project_no_fleets" + ) + await add_project_member( + session=session, project=project_no_fleets, user=user, project_role=ProjectRole.USER + ) + + response = await client.post( + "/api/projects/list_only_no_fleets", + headers=get_auth_headers(user.token), + ) + assert response.status_code == 200 + projects = response.json() + + assert len(projects) == 1 + assert projects[0]["project_name"] == "project_no_fleets" + @pytest.mark.asyncio @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) async def test_only_no_fleets_respects_user_permissions(