diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 262aa46b75..d58dbc7d38 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -63,6 +63,7 @@ export const API = { PROJECTS: { BASE: () => `${API.BASE()}/projects`, LIST: () => `${API.PROJECTS.BASE()}/list`, + LIST_ONLY_NO_FLEETS: () => `${API.PROJECTS.BASE()}/list_only_no_fleets`, CREATE: () => `${API.PROJECTS.BASE()}/create`, CREATE_WIZARD: () => `${API.PROJECTS.BASE()}/create_wizard`, DELETE: () => `${API.PROJECTS.BASE()}/delete`, diff --git a/frontend/src/hooks/useCheckingForFleetsInProjectsOfMember.ts b/frontend/src/hooks/useCheckingForFleetsInProjectsOfMember.ts new file mode 100644 index 0000000000..d91b78a3b1 --- /dev/null +++ b/frontend/src/hooks/useCheckingForFleetsInProjectsOfMember.ts @@ -0,0 +1,37 @@ +import { useMemo } from 'react'; + +import { useGetOnlyNoFleetsProjectsQuery, useGetProjectsQuery } from 'services/project'; + +type Args = { projectNames?: IProject['project_name'][] }; + +export const useCheckingForFleetsInProjects = ({ projectNames }: Args) => { + const { data: projectsData } = useGetProjectsQuery(undefined, { + skip: !!projectNames?.length, + }); + + const { data: noFleetsProjectsData } = useGetOnlyNoFleetsProjectsQuery(); + + const projectNameForChecking = useMemo(() => { + if (projectNames) { + return projectNames; + } + + if (projectsData) { + return projectsData.map((project) => project.project_name); + } + + return []; + }, [projectNames, projectsData]); + + const projectHavingFleetMap = useMemo>(() => { + const map: Record = {}; + + projectNameForChecking.forEach((projectName) => { + map[projectName] = !noFleetsProjectsData?.some((i) => i.project_name === projectName); + }); + + return map; + }, [projectNameForChecking, noFleetsProjectsData]); + + return projectHavingFleetMap; +}; diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 7c07a5f938..f026151083 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -564,6 +564,11 @@ }, "fleets": { + "no_alert": { + "title": "No fleets", + "description": "The project has no fleets. Create one before submitting a run.", + "button_title": "Create a fleet" + }, "fleet": "Fleet", "fleet_placeholder": "Filtering by fleet", "fleet_name": "Fleet name", diff --git a/frontend/src/pages/Fleets/List/index.tsx b/frontend/src/pages/Fleets/List/index.tsx index 3ac92310fb..e3e2dfc234 100644 --- a/frontend/src/pages/Fleets/List/index.tsx +++ b/frontend/src/pages/Fleets/List/index.tsx @@ -5,9 +5,12 @@ import { Button, Header, Loader, PropertyFilter, SpaceBetween, Table, Toggle } f import { DEFAULT_TABLE_PAGE_SIZE } from 'consts'; import { useBreadcrumbs, useCollection, useInfiniteScroll } from 'hooks'; +import { useCheckingForFleetsInProjects } from 'hooks/useCheckingForFleetsInProjectsOfMember'; import { ROUTES } from 'routes'; import { useLazyGetFleetsQuery } from 'services/fleet'; +import { NoFleetProjectAlert } from 'pages/Project/components/NoFleetProjectAlert'; + import { useColumnsDefinitions, useEmptyMessages, useFilters } from './hooks'; import { useDeleteFleet } from './useDeleteFleet'; @@ -35,6 +38,8 @@ export const FleetList: React.FC = () => { isDisabledClearFilter, } = useFilters(); + const projectHavingFleetMap = useCheckingForFleetsInProjects({}); + const { data, isLoading, refreshList, isLoadingMore } = useInfiniteScroll({ useLazyQuery: useLazyGetFleetsQuery, args: { ...filteringRequestParams, limit: DEFAULT_TABLE_PAGE_SIZE }, @@ -67,6 +72,8 @@ export const FleetList: React.FC = () => { deleteFleets([...selectedItems]).catch(console.log); }; + const projectDontHasFleet = Object.keys(projectHavingFleetMap).find((project) => !projectHavingFleetMap[project]); + return ( { stickyHeader={true} selectionType="multi" header={ -
- - -
+ <> + + +
+ + +
+ } filter={
diff --git a/frontend/src/pages/Fleets/List/styles.module.scss b/frontend/src/pages/Fleets/List/styles.module.scss index 022678e83e..ec38338c42 100644 --- a/frontend/src/pages/Fleets/List/styles.module.scss +++ b/frontend/src/pages/Fleets/List/styles.module.scss @@ -1,3 +1,6 @@ +.noFleetAlert { + margin-bottom: 12px; +} .filters { display: flex; flex-wrap: wrap; diff --git a/frontend/src/pages/Project/Details/Settings/index.tsx b/frontend/src/pages/Project/Details/Settings/index.tsx index 2cd6b4915a..7d2b9bd3f0 100644 --- a/frontend/src/pages/Project/Details/Settings/index.tsx +++ b/frontend/src/pages/Project/Details/Settings/index.tsx @@ -22,6 +22,7 @@ import { import { HotspotIds } from 'layouts/AppLayout/TutorialPanel/constants'; import { useBreadcrumbs, useNotifications } from 'hooks'; +import { useCheckingForFleetsInProjects } from 'hooks/useCheckingForFleetsInProjectsOfMember'; import { riseRouterException } from 'libs'; import { copyToClipboard } from 'libs'; import { ROUTES } from 'routes'; @@ -37,6 +38,7 @@ import { getProjectRoleByUserName } from 'pages/Project/utils'; import { useBackendsTable } from '../../Backends/hooks'; import { BackendsTable } from '../../Backends/Table'; +import { NoFleetProjectAlert } from '../../components/NoFleetProjectAlert'; import { GatewaysTable } from '../../Gateways'; import { useGatewaysTable } from '../../Gateways/hooks'; import { ProjectSecrets } from '../../Secrets'; @@ -60,6 +62,10 @@ export const ProjectSettings: React.FC = () => { const { deleteProject, isDeleting } = useDeleteProject(); const { data: currentUser } = useGetUserDataQuery({}); + const projectNames = useMemo(() => [paramProjectName], [paramProjectName]); + + const projectHavingFleetMap = useCheckingForFleetsInProjects({ projectNames }); + const { data, isLoading, error } = useGetProjectQuery({ name: paramProjectName }); const { data: runsData } = useGetRunsQuery({ @@ -180,6 +186,8 @@ export const ProjectSettings: React.FC = () => { const [activeStepIndex, setActiveStepIndex] = React.useState(0); + const projectDontHasFleet = !projectHavingFleetMap?.[paramProjectName]; + if (isLoadingPage) return ( @@ -191,6 +199,8 @@ export const ProjectSettings: React.FC = () => { <> {data && backendsData && gatewaysData && ( + + {isProjectMember && ( = ({ projectName, show, className, dismissible }) => { + const { t } = useTranslation(); + const [dontShowAgain, setDontShowAgain] = useLocalStorageState(`noFleetProjectAlert-${projectName}`, false); + + const onCreateAFleet: ButtonProps['onClick'] = (event) => { + event.preventDefault(); + goToUrl('https://dstack.ai/docs/quickstart/#create-a-fleet', true); + }; + + const onDismiss: AlertProps['onDismiss'] = () => setDontShowAgain(true); + + if (!show || dontShowAgain) { + return null; + } + + return ( +
+ + {t('fleets.no_alert.button_title')} + + } + > + The project {projectName} has no fleets. Create one before submitting a run. + +
+ ); +}; diff --git a/frontend/src/pages/Project/components/NoFleetProjectAlert/styles.module.scss b/frontend/src/pages/Project/components/NoFleetProjectAlert/styles.module.scss new file mode 100644 index 0000000000..c49d1793fb --- /dev/null +++ b/frontend/src/pages/Project/components/NoFleetProjectAlert/styles.module.scss @@ -0,0 +1,10 @@ +.alertBox { + :global { + & [class^="awsui_alert"] { + & [class^="awsui_action-slot"] { + display: flex; + align-items: center; + } + } + } +} diff --git a/frontend/src/pages/Runs/CreateDevEnvironment/index.tsx b/frontend/src/pages/Runs/CreateDevEnvironment/index.tsx index 40e14c814d..278bc5b3c5 100644 --- a/frontend/src/pages/Runs/CreateDevEnvironment/index.tsx +++ b/frontend/src/pages/Runs/CreateDevEnvironment/index.tsx @@ -7,15 +7,17 @@ import * as yup from 'yup'; import { Box, Link, WizardProps } from '@cloudscape-design/components'; import { CardsProps } from '@cloudscape-design/components/cards'; -import type { TabsProps, ToggleProps } from 'components'; +import { TabsProps, ToggleProps } from 'components'; import { Container, FormCodeEditor, FormField, FormInput, FormSelect, SpaceBetween, Tabs, Toggle, Wizard } from 'components'; import { useBreadcrumbs, useNotifications } from 'hooks'; +import { useCheckingForFleetsInProjects } from 'hooks/useCheckingForFleetsInProjectsOfMember'; import { getServerError } from 'libs'; import { ROUTES } from 'routes'; import { useApplyRunMutation } from 'services/run'; import { OfferList } from 'pages/Offers/List'; +import { NoFleetProjectAlert } from 'pages/Project/components/NoFleetProjectAlert'; import { useGenerateYaml } from './hooks/useGenerateYaml'; import { useGetRunSpecFromYaml } from './hooks/useGetRunSpecFromYaml'; @@ -117,6 +119,9 @@ export const CreateDevEnvironment: React.FC = () => { const [getRunSpecFromYaml] = useGetRunSpecFromYaml({ projectName: selectedProject ?? '' }); + const projectHavingFleetMap = useCheckingForFleetsInProjects({ projectNames: selectedProject ? [selectedProject] : [] }); + const projectDontHasFleets = !!selectedProject && !projectHavingFleetMap[selectedProject]; + const [applyRun, { isLoading: isApplying }] = useApplyRunMutation(); const loading = isApplying; @@ -174,6 +179,10 @@ export const CreateDevEnvironment: React.FC = () => { const stepValidators = [validateOffer, validateSecondStep, validateConfig]; if (reason === 'next') { + if (projectDontHasFleets) { + window.scrollTo(0, 0); + } + stepValidators[activeStepIndex]?.().then((isValid) => { if (isValid) { setActiveStepIndex(requestedStepIndex); @@ -277,6 +286,12 @@ export const CreateDevEnvironment: React.FC = () => { return (
+ + { localStorePrefix: 'administration-run-list-page', }); + const projectHavingFleetMap = useCheckingForFleetsInProjects({}); + const { data, isLoading, refreshList, isLoadingMore } = useInfiniteScroll({ useLazyQuery: useLazyGetRunsQuery, args: { ...filteringRequestParams, limit: DEFAULT_TABLE_PAGE_SIZE, job_submissions_limit: 1 }, @@ -117,6 +122,8 @@ export const RunList: React.FC = () => { } }; + const projectDontHasFleet = Object.keys(projectHavingFleetMap).find((project) => !projectHavingFleetMap[project]); + return (
{ columnDisplay={preferences.contentDisplay} preferences={} header={ -
- - {t('common.new')} - - - - - - - {/**/} - -
+ <> + + +
+ + {t('common.new')} + + + + + + + {/**/} + +
+ } filter={
diff --git a/frontend/src/pages/Runs/List/styles.module.scss b/frontend/src/pages/Runs/List/styles.module.scss index 0b5efa7b66..0598087317 100644 --- a/frontend/src/pages/Runs/List/styles.module.scss +++ b/frontend/src/pages/Runs/List/styles.module.scss @@ -1,3 +1,7 @@ +.noFleetAlert { + margin-bottom: 12px; +} + .selectFilters { display: flex; flex-wrap: wrap; diff --git a/frontend/src/services/fleet.ts b/frontend/src/services/fleet.ts index e74753b3ce..3405a18b8b 100644 --- a/frontend/src/services/fleet.ts +++ b/frontend/src/services/fleet.ts @@ -69,4 +69,4 @@ export const fleetApi = createApi({ }), }); -export const { useLazyGetFleetsQuery, useDeleteFleetMutation, useGetFleetDetailsQuery } = fleetApi; +export const { useGetFleetsQuery, useLazyGetFleetsQuery, useDeleteFleetMutation, useGetFleetDetailsQuery } = fleetApi; diff --git a/frontend/src/services/project.ts b/frontend/src/services/project.ts index 1dfbe25ef5..2f0a4bd6b5 100644 --- a/frontend/src/services/project.ts +++ b/frontend/src/services/project.ts @@ -20,7 +20,7 @@ export const projectApi = createApi({ prepareHeaders: fetchBaseQueryHeaders, }), - tagTypes: ['Projects', 'ProjectRepos', 'ProjectLogs', 'Backends'], + tagTypes: ['Projects', 'NoFleetsProject', 'ProjectRepos', 'ProjectLogs', 'Backends'], endpoints: (builder) => ({ getProjects: builder.query({ @@ -40,6 +40,27 @@ export const projectApi = createApi({ : ['Projects'], }), + getOnlyNoFleetsProjects: builder.query({ + query: (body) => { + return { + url: API.PROJECTS.LIST_ONLY_NO_FLEETS(), + method: 'POST', + body, + }; + }, + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + transformResponse: (response: any[]): IProject[] => response.map(transformProjectResponse), + + providesTags: (result) => + result + ? [ + ...result.map(({ project_name }) => ({ type: 'NoFleetsProject' as const, id: project_name })), + 'NoFleetsProject', + ] + : ['NoFleetsProject'], + }), + getProject: builder.query({ query: ({ name }) => { return { @@ -180,6 +201,7 @@ export const projectApi = createApi({ export const { useGetProjectsQuery, + useGetOnlyNoFleetsProjectsQuery, useLazyGetProjectsQuery, useGetProjectQuery, useCreateProjectMutation, diff --git a/frontend/src/types/project.d.ts b/frontend/src/types/project.d.ts index c4a5cd0a55..cf24c84d03 100644 --- a/frontend/src/types/project.d.ts +++ b/frontend/src/types/project.d.ts @@ -7,6 +7,11 @@ declare type TCreateWizardProjectParams = { }; }; +declare type TGetProjectsParams = { + only_no_fleets?: boolean; + include_not_joined?: boolean; +}; + declare type TProjectBackend = { name: string; config: IBackendAWS | IBackendAzure | IBackendGCP | IBackendLambda | IBackendLocal | IBackendDstack; diff --git a/src/dstack/_internal/server/routers/projects.py b/src/dstack/_internal/server/routers/projects.py index d35b9535e8..b07b7b1c62 100644 --- a/src/dstack/_internal/server/routers/projects.py +++ b/src/dstack/_internal/server/routers/projects.py @@ -23,7 +23,7 @@ ProjectManagerOrSelfLeave, ProjectMemberOrPublicAccess, ) -from dstack._internal.server.services import projects +from dstack._internal.server.services import fleets, projects from dstack._internal.server.utils.routers import ( CustomORJSONResponse, get_base_api_additional_responses, @@ -43,7 +43,10 @@ async def list_projects( user: UserModel = Depends(Authenticated()), ): """ - Returns all projects visible to user sorted by descending `created_at`. + Returns projects visible to the user, sorted by ascending `created_at`. + + Returns all accessible projects (member projects for regular users, all non-deleted + projects for global admins, plus public projects if `include_not_joined` is `True`). `members` and `backends` are always empty - call `/api/projects/{project_name}/get` to retrieve them. """ @@ -57,6 +60,25 @@ async def list_projects( ) +@router.post("/list_only_no_fleets", response_model=List[Project]) +async def list_only_no_fleets( + session: AsyncSession = Depends(get_session), + user: UserModel = Depends(Authenticated()), +): + """ + Returns only projects where the user is a member and that have no active fleets, + sorted by ascending `created_at`. + + Active fleets are those with `deleted == False`. Projects with deleted fleets + (but no active fleets) are included. + + `members` and `backends` are always empty - call `/api/projects/{project_name}/get` to retrieve them. + """ + return CustomORJSONResponse( + await fleets.list_projects_with_no_active_fleets(session=session, user=user) + ) + + @router.post("/create", response_model=Project) async def create_project( body: CreateProjectRequest, diff --git a/src/dstack/_internal/server/services/fleets.py b/src/dstack/_internal/server/services/fleets.py index 16901bdf1a..e347829fa4 100644 --- a/src/dstack/_internal/server/services/fleets.py +++ b/src/dstack/_internal/server/services/fleets.py @@ -6,7 +6,7 @@ from sqlalchemy import and_, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import joinedload, selectinload +from sqlalchemy.orm import aliased, joinedload, selectinload from dstack._internal.core.backends.base.backend import Backend from dstack._internal.core.backends.features import BACKENDS_WITH_CREATE_INSTANCE_SUPPORT @@ -40,6 +40,7 @@ Profile, SpotPolicy, ) +from dstack._internal.core.models.projects import Project from dstack._internal.core.models.resources import ResourcesSpec from dstack._internal.core.models.runs import JobProvisioningData, Requirements, get_policy_map from dstack._internal.core.models.users import GlobalRole @@ -50,6 +51,7 @@ FleetModel, InstanceModel, JobModel, + MemberModel, ProjectModel, UserModel, ) @@ -70,6 +72,7 @@ get_member, get_member_permissions, list_user_project_models, + project_model_to_project, ) from dstack._internal.server.services.resources import set_resources_defaults from dstack._internal.utils import random_names @@ -98,6 +101,53 @@ def switch_fleet_status( events.emit(session, msg, actor=actor, targets=[events.Target.from_model(fleet_model)]) +async def list_projects_with_no_active_fleets( + session: AsyncSession, + user: UserModel, +) -> List[Project]: + """ + Returns all projects where the user is a member that have no active fleets. + + Active fleets are those with `deleted == False`. Projects with only deleted fleets + (or no fleets) are included. Deleted projects are excluded. + + Applies to all users (both regular users and admins require membership). + """ + active_fleet_alias = aliased(FleetModel) + member_alias = aliased(MemberModel) + + query = ( + select(ProjectModel) + .join( + member_alias, + and_( + member_alias.project_id == ProjectModel.id, + member_alias.user_id == user.id, + ), + ) + .outerjoin( + active_fleet_alias, + and_( + active_fleet_alias.project_id == ProjectModel.id, + active_fleet_alias.deleted == False, + ), + ) + .where( + ProjectModel.deleted == False, + active_fleet_alias.id.is_(None), + ) + .order_by(ProjectModel.created_at) + ) + + res = await session.execute(query) + project_models = list(res.scalars().unique().all()) + + return [ + project_model_to_project(p, include_backends=False, include_members=False) + for p in project_models + ] + + async def list_fleets( session: AsyncSession, user: UserModel, diff --git a/src/tests/_internal/server/routers/test_projects.py b/src/tests/_internal/server/routers/test_projects.py index 4b62ac416d..5c9ef42ffb 100644 --- a/src/tests/_internal/server/routers/test_projects.py +++ b/src/tests/_internal/server/routers/test_projects.py @@ -34,6 +34,14 @@ 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): @@ -208,6 +216,495 @@ async def test_member_sees_both_public_and_private_projects( assert "public_project" in project_names assert "private_project" in project_names + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_only_no_fleets_returns_projects_without_active_fleets( + self, test_db, session: AsyncSession, client: AsyncClient + ): + user = await create_user(session=session, global_role=GlobalRole.ADMIN) + + # Create project with no fleets + project_no_fleets = await create_project( + session=session, + owner=user, + name="project_no_fleets", + created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), + ) + await add_project_member( + session=session, project=project_no_fleets, user=user, project_role=ProjectRole.ADMIN + ) + + # Create project with active fleet + project_with_active_fleet = await create_project( + session=session, + owner=user, + name="project_with_active_fleet", + created_at=datetime(2023, 1, 2, 3, 5, tzinfo=timezone.utc), + ) + await add_project_member( + session=session, + project=project_with_active_fleet, + user=user, + project_role=ProjectRole.ADMIN, + ) + await create_fleet( + session=session, + project=project_with_active_fleet, + deleted=False, + ) + + # Create project with deleted fleet (should be included) + project_with_deleted_fleet = await create_project( + session=session, + owner=user, + name="project_with_deleted_fleet", + created_at=datetime(2023, 1, 2, 3, 6, tzinfo=timezone.utc), + ) + await add_project_member( + session=session, + project=project_with_deleted_fleet, + user=user, + project_role=ProjectRole.ADMIN, + ) + deleted_fleet = await create_fleet( + session=session, + project=project_with_deleted_fleet, + deleted=True, + ) + deleted_fleet.status = FleetStatus.TERMINATED + await session.commit() + + # Test with list_only_no_fleets endpoint + response = await client.post( + "/api/projects/list_only_no_fleets", + headers=get_auth_headers(user.token), + ) + assert response.status_code == 200 + projects = response.json() + + # Should only return projects without active fleets + assert len(projects) == 2 + project_names = {p["project_name"] for p in projects} + assert "project_no_fleets" in project_names + assert "project_with_deleted_fleet" in project_names + assert "project_with_active_fleet" not in project_names + + # Test with regular list endpoint (default) + response = await client.post( + "/api/projects/list", + headers=get_auth_headers(user.token), + ) + assert response.status_code == 200 + projects = response.json() + + # Should return all projects + assert len(projects) == 3 + project_names = {p["project_name"] for p in projects} + assert "project_no_fleets" in project_names + assert "project_with_active_fleet" in project_names + assert "project_with_deleted_fleet" in project_names + + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_only_no_fleets_with_multiple_fleets( + self, test_db, session: AsyncSession, client: AsyncClient + ): + """Test project with multiple fleets - some active, some deleted""" + user = await create_user(session=session, global_role=GlobalRole.ADMIN) + + # Create project with both active and deleted fleets + project_mixed = await create_project( + session=session, + owner=user, + name="project_mixed", + created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), + ) + await add_project_member( + session=session, project=project_mixed, user=user, project_role=ProjectRole.ADMIN + ) + # Add active fleet - should exclude project + await create_fleet( + session=session, + project=project_mixed, + deleted=False, + ) + # Add deleted fleet - should not affect exclusion + deleted_fleet = await create_fleet( + session=session, + project=project_mixed, + deleted=True, + ) + deleted_fleet.status = FleetStatus.TERMINATED + await session.commit() + + # Project should NOT be included because it has an active fleet + response = await client.post( + "/api/projects/list_only_no_fleets", + headers=get_auth_headers(user.token), + ) + assert response.status_code == 200 + projects = response.json() + project_names = {p["project_name"] for p in projects} + assert "project_mixed" not in project_names + + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_only_no_fleets_empty_result( + self, test_db, session: AsyncSession, client: AsyncClient + ): + """Test when all projects have active fleets""" + user = await create_user(session=session, global_role=GlobalRole.ADMIN) + + # Create projects, all with active fleets + for i in range(3): + project = await create_project( + session=session, + owner=user, + name=f"project_{i}", + created_at=datetime(2023, 1, 2, 3, 4 + i, tzinfo=timezone.utc), + ) + await add_project_member( + session=session, project=project, user=user, project_role=ProjectRole.ADMIN + ) + await create_fleet( + session=session, + project=project, + deleted=False, + ) + + # Should return empty list + 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) == 0 + + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_only_no_fleets_respects_user_permissions( + self, test_db, session: AsyncSession, client: AsyncClient + ): + # Create regular user (not admin) + user = await create_user(session=session, global_role=GlobalRole.USER) + + # Create another user + owner = await create_user(session=session, name="owner", global_role=GlobalRole.USER) + + # Create project where user is a member (no fleets) + project_member = await create_project( + session=session, + owner=owner, + name="project_member", + created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), + ) + await add_project_member( + session=session, project=project_member, user=user, project_role=ProjectRole.USER + ) + await add_project_member( + session=session, project=project_member, user=owner, project_role=ProjectRole.ADMIN + ) + + # Create public project where user is NOT a member (no fleets) + public_project = await create_project( + session=session, + owner=owner, + name="public_project", + created_at=datetime(2023, 1, 2, 3, 5, tzinfo=timezone.utc), + is_public=True, + ) + await add_project_member( + session=session, project=public_project, user=owner, project_role=ProjectRole.ADMIN + ) + + # Create private project where user is NOT a member (should not see this) + private_project = await create_project( + session=session, + owner=owner, + name="private_project", + created_at=datetime(2023, 1, 2, 3, 6, tzinfo=timezone.utc), + is_public=False, + ) + await add_project_member( + session=session, project=private_project, user=owner, project_role=ProjectRole.ADMIN + ) + + # Test with list_only_no_fleets endpoint + response = await client.post( + "/api/projects/list_only_no_fleets", + headers=get_auth_headers(user.token), + ) + assert response.status_code == 200 + projects = response.json() + + # Should only return member projects without active fleets + # (public projects where user is not a member are no longer included) + assert len(projects) == 1 + project_names = {p["project_name"] for p in projects} + assert "project_member" in project_names + assert "public_project" not in project_names + assert "private_project" not in project_names + + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_only_no_fleets_regular_user_filters_active_fleets( + self, test_db, session: AsyncSession, client: AsyncClient + ): + """Test that regular users correctly filter out projects with active fleets""" + # Create regular user (not admin) + user = await create_user(session=session, global_role=GlobalRole.USER) + + # Create another user + owner = await create_user(session=session, name="owner", global_role=GlobalRole.USER) + + # Create member project with no fleets (should be included) + project_member_no_fleet = await create_project( + session=session, + owner=owner, + name="project_member_no_fleet", + created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), + ) + await add_project_member( + session=session, + project=project_member_no_fleet, + user=user, + project_role=ProjectRole.USER, + ) + + # Create member project with active fleet (should be excluded) + project_member_with_fleet = await create_project( + session=session, + owner=owner, + name="project_member_with_fleet", + created_at=datetime(2023, 1, 2, 3, 5, tzinfo=timezone.utc), + ) + await add_project_member( + session=session, + project=project_member_with_fleet, + user=user, + project_role=ProjectRole.USER, + ) + await create_fleet( + session=session, + project=project_member_with_fleet, + deleted=False, + ) + + # Create public project where user is a member with no fleets (should be included) + public_project_no_fleet = await create_project( + session=session, + owner=owner, + name="public_project_no_fleet", + created_at=datetime(2023, 1, 2, 3, 6, tzinfo=timezone.utc), + is_public=True, + ) + await add_project_member( + session=session, + project=public_project_no_fleet, + user=user, + project_role=ProjectRole.USER, + ) + + # Create public project where user is a member with active fleet (should be excluded) + public_project_with_fleet = await create_project( + session=session, + owner=owner, + name="public_project_with_fleet", + created_at=datetime(2023, 1, 2, 3, 7, tzinfo=timezone.utc), + is_public=True, + ) + await add_project_member( + session=session, + project=public_project_with_fleet, + user=user, + project_role=ProjectRole.USER, + ) + await create_fleet( + session=session, + project=public_project_with_fleet, + deleted=False, + ) + + # Test with list_only_no_fleets endpoint + response = await client.post( + "/api/projects/list_only_no_fleets", + headers=get_auth_headers(user.token), + ) + assert response.status_code == 200 + projects = response.json() + + # Should only return member projects without active fleets + assert len(projects) == 2 + project_names = {p["project_name"] for p in projects} + assert "project_member_no_fleet" in project_names + assert "public_project_no_fleet" in project_names + assert "project_member_with_fleet" not in project_names + assert "public_project_with_fleet" not in project_names + + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_only_no_fleets_filters_active_fleets_correctly( + self, test_db, session: AsyncSession, client: AsyncClient + ): + """Test that projects with active fleets are correctly filtered out""" + user = await create_user(session=session, global_role=GlobalRole.ADMIN) + + # Create project with active fleet + project_with_active = await create_project( + session=session, + owner=user, + name="project_with_active", + created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), + ) + await add_project_member( + session=session, project=project_with_active, user=user, project_role=ProjectRole.ADMIN + ) + active_fleet = await create_fleet( + session=session, + project=project_with_active, + deleted=False, + ) + active_fleet.status = FleetStatus.ACTIVE + await session.commit() + + # Create project with terminated but not deleted fleet (still active) + project_with_terminated = await create_project( + session=session, + owner=user, + name="project_with_terminated", + created_at=datetime(2023, 1, 2, 3, 5, tzinfo=timezone.utc), + ) + await add_project_member( + session=session, + project=project_with_terminated, + user=user, + project_role=ProjectRole.ADMIN, + ) + terminated_fleet = await create_fleet( + session=session, + project=project_with_terminated, + deleted=False, + ) + terminated_fleet.status = FleetStatus.TERMINATED + await session.commit() + + # Both should be excluded + response = await client.post( + "/api/projects/list_only_no_fleets", + headers=get_auth_headers(user.token), + ) + assert response.status_code == 200 + projects = response.json() + project_names = {p["project_name"] for p in projects} + assert "project_with_active" not in project_names + assert "project_with_terminated" not in project_names + + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_only_no_fleets_sorted_by_created_at( + self, test_db, session: AsyncSession, client: AsyncClient + ): + """Test that results are sorted by created_at""" + user = await create_user(session=session, global_role=GlobalRole.ADMIN) + + # Create projects in reverse order + project_3 = await create_project( + session=session, + owner=user, + name="project_3", + created_at=datetime(2023, 1, 2, 3, 6, tzinfo=timezone.utc), + ) + await add_project_member( + session=session, project=project_3, user=user, project_role=ProjectRole.ADMIN + ) + + project_1 = await create_project( + session=session, + owner=user, + name="project_1", + created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), + ) + await add_project_member( + session=session, project=project_1, user=user, project_role=ProjectRole.ADMIN + ) + + project_2 = await create_project( + session=session, + owner=user, + name="project_2", + created_at=datetime(2023, 1, 2, 3, 5, tzinfo=timezone.utc), + ) + await add_project_member( + session=session, project=project_2, user=user, project_role=ProjectRole.ADMIN + ) + + # Results should be sorted by created_at ascending + 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) == 3 + assert projects[0]["project_name"] == "project_1" + assert projects[1]["project_name"] == "project_2" + assert projects[2]["project_name"] == "project_3" + + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_only_no_fleets_admin_requires_membership( + self, test_db, session: AsyncSession, client: AsyncClient + ): + """Test that admins also require membership (unified behavior)""" + # Create admin user + admin = await create_user(session=session, global_role=GlobalRole.ADMIN) + + # Create another user + owner = await create_user(session=session, name="owner", global_role=GlobalRole.USER) + + # Create project where admin is a member (no fleets) - should be included + project_with_membership = await create_project( + session=session, + owner=owner, + name="project_with_membership", + created_at=datetime(2023, 1, 2, 3, 4, tzinfo=timezone.utc), + ) + await add_project_member( + session=session, + project=project_with_membership, + user=admin, + project_role=ProjectRole.ADMIN, + ) + + # Create project where admin is NOT a member (no fleets) - should NOT be included + project_without_membership = await create_project( + session=session, + owner=owner, + name="project_without_membership", + created_at=datetime(2023, 1, 2, 3, 5, tzinfo=timezone.utc), + ) + await add_project_member( + session=session, + project=project_without_membership, + user=owner, + project_role=ProjectRole.ADMIN, + ) + + # Test with list_only_no_fleets endpoint + response = await client.post( + "/api/projects/list_only_no_fleets", + headers=get_auth_headers(admin.token), + ) + assert response.status_code == 200 + projects = response.json() + + # Should only return project where admin is a member + assert len(projects) == 1 + project_names = {p["project_name"] for p in projects} + assert "project_with_membership" in project_names + assert "project_without_membership" not in project_names + class TestCreateProject: @pytest.mark.asyncio