From e663f1baa6957c7037294f06430113b8a24a5c67 Mon Sep 17 00:00:00 2001 From: Andrey Cheptsov Date: Tue, 20 Oct 2026 07:56:26 -0700 Subject: [PATCH 1/2] Show owned and joined projects on user pages --- frontend/src/locale/en.json | 5 + .../src/pages/User/Details/Projects/index.tsx | 74 +++++++----- frontend/src/types/project.d.ts | 1 + .../_internal/server/routers/projects.py | 8 +- .../_internal/server/schemas/projects.py | 6 + .../_internal/server/services/projects.py | 29 ++++- .../_internal/server/routers/test_projects.py | 113 ++++++++++++++++++ .../server/services/test_projects.py | 65 ++++++++++ 8 files changed, 264 insertions(+), 37 deletions(-) create mode 100644 src/tests/_internal/server/services/test_projects.py diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 9e00c1030a..d476ea5737 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -768,6 +768,11 @@ "account_settings": "User settings", "settings": "Settings", "projects": "Projects", + "projects_table": { + "project": "Project", + "role": "Role", + "empty_message": "This user doesn't own or belong to any projects." + }, "events": "Events", "public_keys": { "title": "SSH keys", diff --git a/frontend/src/pages/User/Details/Projects/index.tsx b/frontend/src/pages/User/Details/Projects/index.tsx index 3ce243d978..763d9e56f8 100644 --- a/frontend/src/pages/User/Details/Projects/index.tsx +++ b/frontend/src/pages/User/Details/Projects/index.tsx @@ -1,24 +1,34 @@ -import React, { useMemo } from 'react'; +import React from 'react'; import { useTranslation } from 'react-i18next'; -import { useParams } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; -import { - ListEmptyMessage, - NavigateLink, - // Pagination, - Table, -} from 'components'; +import { Button, ListEmptyMessage, Loader, NavigateLink, Table } from 'components'; -import { useBreadcrumbs, useCollection } from 'hooks'; +import { DEFAULT_TABLE_PAGE_SIZE } from 'consts'; +import { useAppSelector, useBreadcrumbs, useCollection, useInfiniteScroll } from 'hooks'; import { ROUTES } from 'routes'; -import { useGetProjectsQuery } from 'services/project'; +import { useLazyGetProjectsQuery } from 'services/project'; + +import { selectUserData } from 'App/slice'; +import { useCheckAvailableProjectPermission } from 'pages/Project/hooks/useCheckAvailableProjectPermission'; export const UserProjectList: React.FC = () => { const { t } = useTranslation(); const params = useParams(); const paramUserName = params.userName ?? ''; + const navigate = useNavigate(); + const userData = useAppSelector(selectUserData); + const isCurrentUser = userData?.username === paramUserName; + const { isAvailableProjectManaging } = useCheckAvailableProjectPermission(); - const { isLoading, isFetching, data } = useGetProjectsQuery({ limit: 200 }); + const { data, isLoading, isLoadingMore } = useInfiniteScroll({ + useLazyQuery: useLazyGetProjectsQuery, + args: { username: paramUserName, limit: DEFAULT_TABLE_PAGE_SIZE }, + getPaginationParams: (project) => ({ + prev_created_at: project.created_at, + prev_id: project.project_id, + }), + }); useBreadcrumbs([ { @@ -36,38 +46,42 @@ export const UserProjectList: React.FC = () => { ]); const renderEmptyMessage = (): React.ReactNode => { - return ; - }; - - const filteredData = useMemo(() => { - if (!data?.data) return []; + if (isLoading) return null; - // eslint-disable-next-line no-unsafe-optional-chaining - return [...data?.data] - .filter((p) => p.owner.username === paramUserName) - .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); - }, [data]); + return ( + + {isCurrentUser && isAvailableProjectManaging && ( + + )} + + ); + }; - const { - items, - collectionProps, - // paginationProps - } = useCollection(filteredData, { + const { items, collectionProps } = useCollection(data, { filtering: { empty: renderEmptyMessage(), }, - // pagination: { pageSize: 20 }, selection: {}, }); const columns = [ { id: 'project_name', - header: `${t('projects.edit.project_name')}`, + header: t('users.projects_table.project'), cell: (project: IProject) => ( {project.project_name} ), }, + { + id: 'role', + header: t('users.projects_table.role'), + cell: (project: IProject) => { + if (project.owner.username === paramUserName) return t('projects.edit.owner'); + + const member = project.members.find(({ user }) => user.username === paramUserName); + return member ? t(`roles.${member.project_role}`) : '-'; + }, + }, ]; return ( @@ -75,9 +89,9 @@ export const UserProjectList: React.FC = () => { {...collectionProps} columnDefinitions={columns} items={items} - loading={isLoading || isFetching} + loading={isLoading} loadingText={t('common.loading')} - // pagination={} + footer={isLoadingMore ? : undefined} /> ); }; diff --git a/frontend/src/types/project.d.ts b/frontend/src/types/project.d.ts index 85da2d0bb8..825132d0d7 100644 --- a/frontend/src/types/project.d.ts +++ b/frontend/src/types/project.d.ts @@ -9,6 +9,7 @@ declare type TCreateWizardProjectParams = { declare type TGetProjectListParams = TBaseRequestListParams & { include_not_joined?: boolean; + username?: string; return_total_count?: boolean; name_pattern?: string; }; diff --git a/src/dstack/_internal/server/routers/projects.py b/src/dstack/_internal/server/routers/projects.py index a3b8f8ba2f..8d166ebe41 100644 --- a/src/dstack/_internal/server/routers/projects.py +++ b/src/dstack/_internal/server/routers/projects.py @@ -48,7 +48,12 @@ async def list_projects( 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. + If `username` is specified, restrict results to projects that user owns or belongs to, + and include only that user's membership in `members`. + Only projects accessible to the authenticated user are returned. + + Without `username`, `members` are empty. `backends` are always empty - + call `/api/projects/{project_name}/get` to retrieve them. """ if body is None: # For backward compatibility @@ -64,6 +69,7 @@ async def list_projects( prev_id=body.prev_id, limit=body.limit, ascending=body.ascending, + username=body.username, ) ) diff --git a/src/dstack/_internal/server/schemas/projects.py b/src/dstack/_internal/server/schemas/projects.py index f72b20d8b3..e594a4c988 100644 --- a/src/dstack/_internal/server/schemas/projects.py +++ b/src/dstack/_internal/server/schemas/projects.py @@ -12,6 +12,12 @@ class ListProjectsRequest(CoreModel): include_not_joined: Annotated[ bool, Field(description="Include public projects where user is not a member.") ] = True + username: Annotated[ + Optional[str], + Field( + description="Include only projects owned by or joined by this user, with this user's membership." + ), + ] = None return_total_count: Annotated[ bool, Field(description="Return `total_count` with the total number of projects.") ] = False diff --git a/src/dstack/_internal/server/services/projects.py b/src/dstack/_internal/server/services/projects.py index b5379c95a5..afee5796a6 100644 --- a/src/dstack/_internal/server/services/projects.py +++ b/src/dstack/_internal/server/services/projects.py @@ -85,14 +85,31 @@ async def list_user_accessible_projects( prev_id: Optional[uuid.UUID], limit: int, ascending: bool, + username: Optional[str] = None, ) -> ProjectsInfoListOrProjectsList: """ - Returns all projects accessible to the user: + Returns all projects accessible to the authenticated `user`: - All projects for global admins - Projects where user is a member (public or private) - if `include_not_joined`: Public projects where user is NOT a member + + `username` optionally filters to projects owned or joined by the profile user and + includes only their membership. Access is still checked for `user`. """ filters = [ProjectModel.deleted == False] + user_id = None + if username is not None: + user_id = select(UserModel.id).where(UserModel.name == username).scalar_subquery() + # TODO: Add a members.user_id index for this filter and global run/event lists; + # these lookups can scan the membership table as it grows. + filters.append( + or_( + ProjectModel.owner_id == user_id, + ProjectModel.id.in_( + select(MemberModel.project_id).where(MemberModel.user_id == user_id) + ), + ) + ) if name_pattern: name_pattern = name_pattern.replace("_", "/_") filters.append(ProjectModel.name.ilike(f"%{name_pattern}%", escape="/")) @@ -147,15 +164,15 @@ async def list_user_accessible_projects( if return_total_count: res = await session.execute(stmt.with_only_columns(safunc.count(literal_column("1")))) total_count = res.scalar_one() + options = [joinedload(ProjectModel.owner)] + if user_id is not None: + options.append(joinedload(ProjectModel.members.and_(MemberModel.user_id == user_id))) res = await session.execute( - stmt.where(*pagination_filters) - .order_by(*order_by) - .limit(limit) - .options(joinedload(ProjectModel.owner)) + stmt.where(*pagination_filters).order_by(*order_by).limit(limit).options(*options) ) project_models = res.unique().scalars().all() projects = [ - project_model_to_project(p, include_backends=False, include_members=False) + project_model_to_project(p, include_backends=False, include_members=user_id is not None) for p in project_models ] if total_count is None: diff --git a/src/tests/_internal/server/routers/test_projects.py b/src/tests/_internal/server/routers/test_projects.py index 7e7be9d0f6..26b2620f70 100644 --- a/src/tests/_internal/server/routers/test_projects.py +++ b/src/tests/_internal/server/routers/test_projects.py @@ -381,6 +381,119 @@ async def test_returns_total_count(self, test_db, session: AsyncSession, client: ], } + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + @pytest.mark.parametrize("ascending", [False, True]) + async def test_filters_by_owner_or_member_before_pagination( + self, test_db, session: AsyncSession, client: AsyncClient, ascending: bool + ): + admin = await create_user(session=session, name="admin", global_role=GlobalRole.ADMIN) + user = await create_user(session=session, name="member") + owned = await create_project(session=session, owner=user, name="owned") + expected_names = [owned.name] + for minute, role in enumerate(ProjectRole, start=5): + project = await create_project( + session=session, + owner=admin, + name=f"joined_{role.value}", + created_at=datetime(2023, 1, 2, 3, minute, tzinfo=timezone.utc), + ) + await add_project_member( + session=session, project=project, user=user, project_role=role + ) + await add_project_member( + session=session, project=project, user=admin, project_role=ProjectRole.ADMIN + ) + expected_names.append(project.name) + await create_project( + session=session, + owner=admin, + name="unrelated", + created_at=datetime(2023, 1, 2, 3, 8, tzinfo=timezone.utc), + ) + await create_project(session=session, owner=user, name="deleted", deleted=True) + if not ascending: + expected_names.reverse() + + params = { + "username": user.name, + "return_total_count": True, + "limit": 1, + "ascending": ascending, + } + for expected_name in expected_names: + response = await client.post( + "/api/projects/list", headers=get_auth_headers(admin.token), json=params + ) + assert response.status_code == 200 + data = response.json() + assert data["total_count"] == len(expected_names) + assert len(data["projects"]) == 1 + project_data = data["projects"][0] + assert project_data["project_name"] == expected_name + assert project_data["backends"] == [] + if expected_name == owned.name: + assert project_data["owner"]["username"] == user.name + assert project_data["members"] == [] + else: + members = project_data["members"] + assert len(members) == 1 + member = members[0] + assert member["user"]["username"] == user.name + assert member["project_role"] == expected_name.removeprefix("joined_") + params.update( + prev_created_at=project_data["created_at"], prev_id=project_data["project_id"] + ) + + response = await client.post( + "/api/projects/list", headers=get_auth_headers(admin.token), json=params + ) + assert response.status_code == 200 + assert response.json() == {"total_count": len(expected_names), "projects": []} + + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + @pytest.mark.parametrize("include_not_joined", [False, True]) + async def test_username_filter_preserves_visibility( + self, test_db, session: AsyncSession, client: AsyncClient, include_not_joined: bool + ): + viewer = await create_user(session=session, name="viewer", global_role=GlobalRole.USER) + owner = await create_user(session=session, name="owner", global_role=GlobalRole.USER) + await create_project(session=session, owner=owner, name="private") + public = await create_project(session=session, owner=owner, name="public", is_public=True) + shared = await create_project(session=session, owner=owner, name="shared") + await add_project_member( + session=session, project=shared, user=viewer, project_role=ProjectRole.USER + ) + viewer_owned = await create_project(session=session, owner=viewer, name="viewer_owned") + await add_project_member( + session=session, project=viewer_owned, user=viewer, project_role=ProjectRole.ADMIN + ) + await add_project_member( + session=session, project=viewer_owned, user=owner, project_role=ProjectRole.MANAGER + ) + unrelated = await create_project(session=session, owner=viewer, name="unrelated") + await add_project_member( + session=session, project=unrelated, user=viewer, project_role=ProjectRole.ADMIN + ) + + response = await client.post( + "/api/projects/list", + headers=get_auth_headers(viewer.token), + json={ + "username": owner.name, + "include_not_joined": include_not_joined, + "return_total_count": True, + }, + ) + assert response.status_code == 200 + data = response.json() + expected_names = {shared.name, viewer_owned.name} + if include_not_joined: + expected_names.add(public.name) + assert {p["project_name"] for p in data["projects"]} == expected_names + assert data["total_count"] == len(expected_names) + class TestListOnlyNoFleets: @pytest.mark.asyncio diff --git a/src/tests/_internal/server/services/test_projects.py b/src/tests/_internal/server/services/test_projects.py new file mode 100644 index 0000000000..d19135621c --- /dev/null +++ b/src/tests/_internal/server/services/test_projects.py @@ -0,0 +1,65 @@ +import pytest +from sqlalchemy import event +from sqlalchemy.ext.asyncio import AsyncSession + +from dstack._internal.core.models.users import ProjectRole +from dstack._internal.server.models import MemberModel, UserModel +from dstack._internal.server.services import projects as projects_services +from dstack._internal.server.testing.common import create_project, create_user + + +class TestListUserAccessibleProjects: + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_bounds_queries_and_loaded_members_to_page(self, test_db, session: AsyncSession): + owner = await create_user(session=session, name="owner") + member = await create_user(session=session, name="member") + other_member = await create_user(session=session, name="other_member") + for index in range(3): + project = await create_project(session=session, owner=owner, name=f"project{index}") + for user in (owner, member, other_member): + await projects_services.add_project_member( + session=session, project=project, user=user, project_role=ProjectRole.USER + ) + owner_id = owner.id + member_id = member.id + member_name = member.name + session.expunge_all() + owner = await session.get(UserModel, owner_id) + assert owner is not None + statements = [] + loaded_member_user_ids = [] + + def before_cursor_execute(conn, cursor, statement, parameters, context, executemany): + statements.append(statement) + + def loaded_as_persistent(session, instance): + if isinstance(instance, MemberModel): + loaded_member_user_ids.append(instance.user_id) + + event.listen(test_db.engine.sync_engine, "before_cursor_execute", before_cursor_execute) + event.listen(session.sync_session, "loaded_as_persistent", loaded_as_persistent) + try: + result = await projects_services.list_user_accessible_projects( + session=session, + user=owner, + include_not_joined=True, + return_total_count=True, + name_pattern=None, + prev_created_at=None, + prev_id=None, + limit=2, + ascending=False, + username=member_name, + ) + finally: + event.remove( + test_db.engine.sync_engine, "before_cursor_execute", before_cursor_execute + ) + event.remove(session.sync_session, "loaded_as_persistent", loaded_as_persistent) + + projects = result if isinstance(result, list) else result.projects + assert len(projects) == 2 + # No per-project queries or materialization of other users' memberships. + assert len(statements) == 2 + assert loaded_member_user_ids == [member_id] * 2 From 6fb150d132df5ba3ceca1f407c4c14785619a7e3 Mon Sep 17 00:00:00 2001 From: Andrey Cheptsov Date: Tue, 20 Oct 2026 09:31:16 -0700 Subject: [PATCH 2/2] Simplify user project regression tests --- .../_internal/server/routers/test_projects.py | 90 +++++-------------- .../server/services/test_projects.py | 65 -------------- 2 files changed, 22 insertions(+), 133 deletions(-) delete mode 100644 src/tests/_internal/server/services/test_projects.py diff --git a/src/tests/_internal/server/routers/test_projects.py b/src/tests/_internal/server/routers/test_projects.py index 26b2620f70..37ec3ce835 100644 --- a/src/tests/_internal/server/routers/test_projects.py +++ b/src/tests/_internal/server/routers/test_projects.py @@ -383,116 +383,70 @@ async def test_returns_total_count(self, test_db, session: AsyncSession, client: @pytest.mark.asyncio @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) - @pytest.mark.parametrize("ascending", [False, True]) async def test_filters_by_owner_or_member_before_pagination( - self, test_db, session: AsyncSession, client: AsyncClient, ascending: bool + self, test_db, session: AsyncSession, client: AsyncClient ): - admin = await create_user(session=session, name="admin", global_role=GlobalRole.ADMIN) + admin = await create_user(session=session, name="admin") user = await create_user(session=session, name="member") - owned = await create_project(session=session, owner=user, name="owned") - expected_names = [owned.name] - for minute, role in enumerate(ProjectRole, start=5): - project = await create_project( - session=session, - owner=admin, - name=f"joined_{role.value}", - created_at=datetime(2023, 1, 2, 3, minute, tzinfo=timezone.utc), - ) - await add_project_member( - session=session, project=project, user=user, project_role=role - ) + await create_project(session=session, owner=user, name="owned") + joined = await create_project( + session=session, + owner=admin, + name="joined", + created_at=datetime(2023, 1, 2, 3, 5, tzinfo=timezone.utc), + ) + for member in (user, admin): await add_project_member( - session=session, project=project, user=admin, project_role=ProjectRole.ADMIN + session=session, project=joined, user=member, project_role=ProjectRole.MANAGER ) - expected_names.append(project.name) await create_project( session=session, owner=admin, name="unrelated", - created_at=datetime(2023, 1, 2, 3, 8, tzinfo=timezone.utc), + created_at=datetime(2023, 1, 2, 3, 6, tzinfo=timezone.utc), ) - await create_project(session=session, owner=user, name="deleted", deleted=True) - if not ascending: - expected_names.reverse() - params = { - "username": user.name, - "return_total_count": True, - "limit": 1, - "ascending": ascending, - } - for expected_name in expected_names: + params = {"username": user.name, "return_total_count": True, "limit": 1} + for expected_name in ("joined", "owned"): response = await client.post( "/api/projects/list", headers=get_auth_headers(admin.token), json=params ) assert response.status_code == 200 data = response.json() - assert data["total_count"] == len(expected_names) + assert data["total_count"] == 2 assert len(data["projects"]) == 1 project_data = data["projects"][0] assert project_data["project_name"] == expected_name - assert project_data["backends"] == [] - if expected_name == owned.name: - assert project_data["owner"]["username"] == user.name - assert project_data["members"] == [] - else: + if expected_name == "joined": members = project_data["members"] assert len(members) == 1 - member = members[0] - assert member["user"]["username"] == user.name - assert member["project_role"] == expected_name.removeprefix("joined_") + assert members[0]["user"]["username"] == user.name + assert members[0]["project_role"] == "manager" params.update( prev_created_at=project_data["created_at"], prev_id=project_data["project_id"] ) - response = await client.post( - "/api/projects/list", headers=get_auth_headers(admin.token), json=params - ) - assert response.status_code == 200 - assert response.json() == {"total_count": len(expected_names), "projects": []} - @pytest.mark.asyncio @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) - @pytest.mark.parametrize("include_not_joined", [False, True]) async def test_username_filter_preserves_visibility( - self, test_db, session: AsyncSession, client: AsyncClient, include_not_joined: bool + self, test_db, session: AsyncSession, client: AsyncClient ): viewer = await create_user(session=session, name="viewer", global_role=GlobalRole.USER) - owner = await create_user(session=session, name="owner", global_role=GlobalRole.USER) + owner = await create_user(session=session, name="owner") await create_project(session=session, owner=owner, name="private") public = await create_project(session=session, owner=owner, name="public", is_public=True) shared = await create_project(session=session, owner=owner, name="shared") await add_project_member( session=session, project=shared, user=viewer, project_role=ProjectRole.USER ) - viewer_owned = await create_project(session=session, owner=viewer, name="viewer_owned") - await add_project_member( - session=session, project=viewer_owned, user=viewer, project_role=ProjectRole.ADMIN - ) - await add_project_member( - session=session, project=viewer_owned, user=owner, project_role=ProjectRole.MANAGER - ) - unrelated = await create_project(session=session, owner=viewer, name="unrelated") - await add_project_member( - session=session, project=unrelated, user=viewer, project_role=ProjectRole.ADMIN - ) response = await client.post( "/api/projects/list", headers=get_auth_headers(viewer.token), - json={ - "username": owner.name, - "include_not_joined": include_not_joined, - "return_total_count": True, - }, + json={"username": owner.name}, ) assert response.status_code == 200 - data = response.json() - expected_names = {shared.name, viewer_owned.name} - if include_not_joined: - expected_names.add(public.name) - assert {p["project_name"] for p in data["projects"]} == expected_names - assert data["total_count"] == len(expected_names) + assert {p["project_name"] for p in response.json()} == {public.name, shared.name} class TestListOnlyNoFleets: diff --git a/src/tests/_internal/server/services/test_projects.py b/src/tests/_internal/server/services/test_projects.py deleted file mode 100644 index d19135621c..0000000000 --- a/src/tests/_internal/server/services/test_projects.py +++ /dev/null @@ -1,65 +0,0 @@ -import pytest -from sqlalchemy import event -from sqlalchemy.ext.asyncio import AsyncSession - -from dstack._internal.core.models.users import ProjectRole -from dstack._internal.server.models import MemberModel, UserModel -from dstack._internal.server.services import projects as projects_services -from dstack._internal.server.testing.common import create_project, create_user - - -class TestListUserAccessibleProjects: - @pytest.mark.asyncio - @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) - async def test_bounds_queries_and_loaded_members_to_page(self, test_db, session: AsyncSession): - owner = await create_user(session=session, name="owner") - member = await create_user(session=session, name="member") - other_member = await create_user(session=session, name="other_member") - for index in range(3): - project = await create_project(session=session, owner=owner, name=f"project{index}") - for user in (owner, member, other_member): - await projects_services.add_project_member( - session=session, project=project, user=user, project_role=ProjectRole.USER - ) - owner_id = owner.id - member_id = member.id - member_name = member.name - session.expunge_all() - owner = await session.get(UserModel, owner_id) - assert owner is not None - statements = [] - loaded_member_user_ids = [] - - def before_cursor_execute(conn, cursor, statement, parameters, context, executemany): - statements.append(statement) - - def loaded_as_persistent(session, instance): - if isinstance(instance, MemberModel): - loaded_member_user_ids.append(instance.user_id) - - event.listen(test_db.engine.sync_engine, "before_cursor_execute", before_cursor_execute) - event.listen(session.sync_session, "loaded_as_persistent", loaded_as_persistent) - try: - result = await projects_services.list_user_accessible_projects( - session=session, - user=owner, - include_not_joined=True, - return_total_count=True, - name_pattern=None, - prev_created_at=None, - prev_id=None, - limit=2, - ascending=False, - username=member_name, - ) - finally: - event.remove( - test_db.engine.sync_engine, "before_cursor_execute", before_cursor_execute - ) - event.remove(session.sync_session, "loaded_as_persistent", loaded_as_persistent) - - projects = result if isinstance(result, list) else result.projects - assert len(projects) == 2 - # No per-project queries or materialization of other users' memberships. - assert len(statements) == 2 - assert loaded_member_user_ids == [member_id] * 2