From 4d9d1cef3890e09bd4d515e96ca62d8d2824264f Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Mon, 11 Nov 2024 15:15:52 +0100 Subject: [PATCH 01/11] refactor listing folders --- .../src/simcore_postgres_database/utils.py | 8 + .../db_access_layer.py | 9 +- .../folders/_folders_api.py | 692 +++++++++--------- .../folders/_folders_db.py | 155 +++- 4 files changed, 485 insertions(+), 379 deletions(-) diff --git a/packages/postgres-database/src/simcore_postgres_database/utils.py b/packages/postgres-database/src/simcore_postgres_database/utils.py index 4d8a52cdf40..39ebbd29d6c 100644 --- a/packages/postgres-database/src/simcore_postgres_database/utils.py +++ b/packages/postgres-database/src/simcore_postgres_database/utils.py @@ -81,3 +81,11 @@ def as_postgres_sql_query_str(statement) -> str: dialect=postgresql.dialect(), # type: ignore[misc] ) return f"{compiled}" + + +def assemble_array_groups(user_group_ids: list[int]) -> str: + return ( + "array[]::text[]" + if len(user_group_ids) == 0 + else f"""array[{', '.join(f"'{group_id}'" for group_id in user_group_ids)}]""" + ) diff --git a/services/storage/src/simcore_service_storage/db_access_layer.py b/services/storage/src/simcore_service_storage/db_access_layer.py index 19452862de5..85500b9965d 100644 --- a/services/storage/src/simcore_service_storage/db_access_layer.py +++ b/services/storage/src/simcore_service_storage/db_access_layer.py @@ -51,6 +51,7 @@ workspaces_access_rights, ) from simcore_postgres_database.storage_models import file_meta_data, user_to_groups +from simcore_postgres_database.utils import assemble_array_groups logger = logging.getLogger(__name__) @@ -117,14 +118,6 @@ def _aggregate_access_rights( return AccessRights.none() -def assemble_array_groups(user_group_ids: list[GroupID]) -> str: - return ( - "array[]::text[]" - if len(user_group_ids) == 0 - else f"""array[{', '.join(f"'{group_id}'" for group_id in user_group_ids)}]""" - ) - - access_rights_subquery = ( sa.select( project_to_groups.c.project_uuid, diff --git a/services/web/server/src/simcore_service_webserver/folders/_folders_api.py b/services/web/server/src/simcore_service_webserver/folders/_folders_api.py index 0344124abb6..85a5db09459 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_folders_api.py +++ b/services/web/server/src/simcore_service_webserver/folders/_folders_api.py @@ -1,341 +1,351 @@ -# pylint: disable=unused-argument - -import logging - -from aiohttp import web -from models_library.access_rights import AccessRights -from models_library.api_schemas_webserver.folders_v2 import FolderGet, FolderGetPage -from models_library.folders import FolderID -from models_library.products import ProductName -from models_library.projects import ProjectID -from models_library.rest_ordering import OrderBy -from models_library.users import UserID -from models_library.workspaces import WorkspaceID -from pydantic import NonNegativeInt -from servicelib.aiohttp.application_keys import APP_FIRE_AND_FORGET_TASKS_KEY -from servicelib.common_headers import UNDEFINED_DEFAULT_SIMCORE_USER_AGENT_VALUE -from servicelib.utils import fire_and_forget_task - -from ..folders.errors import FolderValueNotPermittedError -from ..projects.projects_api import submit_delete_project_task -from ..users.api import get_user -from ..workspaces.api import check_user_workspace_access -from ..workspaces.errors import ( - WorkspaceAccessForbiddenError, - WorkspaceFolderInconsistencyError, -) -from . import _folders_db as folders_db - -_logger = logging.getLogger(__name__) - - -async def create_folder( - app: web.Application, - user_id: UserID, - name: str, - parent_folder_id: FolderID | None, - product_name: ProductName, - workspace_id: WorkspaceID | None, -) -> FolderGet: - user = await get_user(app, user_id=user_id) - - workspace_is_private = True - user_folder_access_rights = AccessRights(read=True, write=True, delete=True) - if workspace_id: - user_workspace_access_rights = await check_user_workspace_access( - app, - user_id=user_id, - workspace_id=workspace_id, - product_name=product_name, - permission="write", - ) - workspace_is_private = False - user_folder_access_rights = user_workspace_access_rights.my_access_rights - - # Check parent_folder_id lives in the workspace - if parent_folder_id: - parent_folder_db = await folders_db.get( - app, folder_id=parent_folder_id, product_name=product_name - ) - if parent_folder_db.workspace_id != workspace_id: - raise WorkspaceFolderInconsistencyError( - folder_id=parent_folder_id, workspace_id=workspace_id - ) - - if parent_folder_id: - # Check user has access to the parent folder - parent_folder_db = await folders_db.get_for_user_or_workspace( - app, - folder_id=parent_folder_id, - product_name=product_name, - user_id=user_id if workspace_is_private else None, - workspace_id=workspace_id, - ) - if workspace_id and parent_folder_db.workspace_id != workspace_id: - # Check parent folder id exists inside the same workspace - raise WorkspaceAccessForbiddenError( - reason=f"Folder {parent_folder_id} does not exists in workspace {workspace_id}." - ) - - folder_db = await folders_db.create( - app, - product_name=product_name, - created_by_gid=user["primary_gid"], - folder_name=name, - parent_folder_id=parent_folder_id, - user_id=user_id if workspace_is_private else None, - workspace_id=workspace_id, - ) - return FolderGet( - folder_id=folder_db.folder_id, - parent_folder_id=folder_db.parent_folder_id, - name=folder_db.name, - created_at=folder_db.created, - modified_at=folder_db.modified, - trashed_at=folder_db.trashed_at, - owner=folder_db.created_by_gid, - workspace_id=workspace_id, - my_access_rights=user_folder_access_rights, - ) - - -async def get_folder( - app: web.Application, - user_id: UserID, - folder_id: FolderID, - product_name: ProductName, -) -> FolderGet: - folder_db = await folders_db.get( - app, folder_id=folder_id, product_name=product_name - ) - - workspace_is_private = True - user_folder_access_rights = AccessRights(read=True, write=True, delete=True) - if folder_db.workspace_id: - user_workspace_access_rights = await check_user_workspace_access( - app, - user_id=user_id, - workspace_id=folder_db.workspace_id, - product_name=product_name, - permission="read", - ) - workspace_is_private = False - user_folder_access_rights = user_workspace_access_rights.my_access_rights - - folder_db = await folders_db.get_for_user_or_workspace( - app, - folder_id=folder_id, - product_name=product_name, - user_id=user_id if workspace_is_private else None, - workspace_id=folder_db.workspace_id, - ) - return FolderGet( - folder_id=folder_db.folder_id, - parent_folder_id=folder_db.parent_folder_id, - name=folder_db.name, - created_at=folder_db.created, - modified_at=folder_db.modified, - trashed_at=folder_db.trashed_at, - owner=folder_db.created_by_gid, - workspace_id=folder_db.workspace_id, - my_access_rights=user_folder_access_rights, - ) - - -async def list_folders( - app: web.Application, - user_id: UserID, - product_name: ProductName, - folder_id: FolderID | None, - workspace_id: WorkspaceID | None, - trashed: bool | None, - offset: NonNegativeInt, - limit: int, - order_by: OrderBy, -) -> FolderGetPage: - workspace_is_private = True - user_folder_access_rights = AccessRights(read=True, write=True, delete=True) - - if workspace_id: - user_workspace_access_rights = await check_user_workspace_access( - app, - user_id=user_id, - workspace_id=workspace_id, - product_name=product_name, - permission="read", - ) - workspace_is_private = False - user_folder_access_rights = user_workspace_access_rights.my_access_rights - - if folder_id: - # Check user access to folder - await folders_db.get_for_user_or_workspace( - app, - folder_id=folder_id, - product_name=product_name, - user_id=user_id if workspace_is_private else None, - workspace_id=workspace_id, - ) - - total_count, folders = await folders_db.list_( - app, - content_of_folder_id=folder_id, - user_id=user_id if workspace_is_private else None, - workspace_id=workspace_id, - product_name=product_name, - trashed=trashed, - offset=offset, - limit=limit, - order_by=order_by, - ) - return FolderGetPage( - items=[ - FolderGet( - folder_id=folder.folder_id, - parent_folder_id=folder.parent_folder_id, - name=folder.name, - created_at=folder.created, - modified_at=folder.modified, - trashed_at=folder.trashed_at, - owner=folder.created_by_gid, - workspace_id=folder.workspace_id, - my_access_rights=user_folder_access_rights, - ) - for folder in folders - ], - total=total_count, - ) - - -async def update_folder( - app: web.Application, - user_id: UserID, - folder_id: FolderID, - *, - name: str, - parent_folder_id: FolderID | None, - product_name: ProductName, -) -> FolderGet: - folder_db = await folders_db.get( - app, folder_id=folder_id, product_name=product_name - ) - - workspace_is_private = True - user_folder_access_rights = AccessRights(read=True, write=True, delete=True) - if folder_db.workspace_id: - user_workspace_access_rights = await check_user_workspace_access( - app, - user_id=user_id, - workspace_id=folder_db.workspace_id, - product_name=product_name, - permission="write", - ) - workspace_is_private = False - user_folder_access_rights = user_workspace_access_rights.my_access_rights - - # Check user has access to the folder - await folders_db.get_for_user_or_workspace( - app, - folder_id=folder_id, - product_name=product_name, - user_id=user_id if workspace_is_private else None, - workspace_id=folder_db.workspace_id, - ) - - if folder_db.parent_folder_id != parent_folder_id and parent_folder_id is not None: - # Check user has access to the parent folder - await folders_db.get_for_user_or_workspace( - app, - folder_id=parent_folder_id, - product_name=product_name, - user_id=user_id if workspace_is_private else None, - workspace_id=folder_db.workspace_id, - ) - # Do not allow to move to a child folder id - _child_folders = await folders_db.get_folders_recursively( - app, folder_id=folder_id, product_name=product_name - ) - if parent_folder_id in _child_folders: - raise FolderValueNotPermittedError( - reason="Parent folder id should not be one of children" - ) - - folder_db = await folders_db.update( - app, - folder_id=folder_id, - name=name, - parent_folder_id=parent_folder_id, - product_name=product_name, - ) - return FolderGet( - folder_id=folder_db.folder_id, - parent_folder_id=folder_db.parent_folder_id, - name=folder_db.name, - created_at=folder_db.created, - modified_at=folder_db.modified, - trashed_at=folder_db.trashed_at, - owner=folder_db.created_by_gid, - workspace_id=folder_db.workspace_id, - my_access_rights=user_folder_access_rights, - ) - - -async def delete_folder( - app: web.Application, - user_id: UserID, - folder_id: FolderID, - product_name: ProductName, -) -> None: - folder_db = await folders_db.get( - app, folder_id=folder_id, product_name=product_name - ) - - workspace_is_private = True - if folder_db.workspace_id: - await check_user_workspace_access( - app, - user_id=user_id, - workspace_id=folder_db.workspace_id, - product_name=product_name, - permission="delete", - ) - workspace_is_private = False - - # Check user has access to the folder - await folders_db.get_for_user_or_workspace( - app, - folder_id=folder_id, - product_name=product_name, - user_id=user_id if workspace_is_private else None, - workspace_id=folder_db.workspace_id, - ) - - # 1. Delete folder content - # 1.1 Delete all child projects that I am an owner - project_id_list: list[ - ProjectID - ] = await folders_db.get_projects_recursively_only_if_user_is_owner( - app, - folder_id=folder_id, - private_workspace_user_id_or_none=user_id if workspace_is_private else None, - user_id=user_id, - product_name=product_name, - ) - - # fire and forget task for project deletion - for project_id in project_id_list: - fire_and_forget_task( - submit_delete_project_task( - app, - project_uuid=project_id, - user_id=user_id, - simcore_user_agent=UNDEFINED_DEFAULT_SIMCORE_USER_AGENT_VALUE, - ), - task_suffix_name=f"delete_project_task_{project_id}", - fire_and_forget_tasks_collection=app[APP_FIRE_AND_FORGET_TASKS_KEY], - ) - - # 1.2 Delete all child folders - await folders_db.delete_recursively( - app, folder_id=folder_id, product_name=product_name - ) +# pylint: disable=unused-argument + +import logging + +from aiohttp import web +from models_library.access_rights import AccessRights +from models_library.api_schemas_webserver.folders_v2 import FolderGet, FolderGetPage +from models_library.folders import FolderID, FolderQuery, FolderScope +from models_library.products import ProductName +from models_library.projects import ProjectID +from models_library.rest_ordering import OrderBy +from models_library.users import UserID +from models_library.workspaces import WorkspaceID, WorkspaceQuery, WorkspaceScope +from pydantic import NonNegativeInt +from servicelib.aiohttp.application_keys import APP_FIRE_AND_FORGET_TASKS_KEY +from servicelib.common_headers import UNDEFINED_DEFAULT_SIMCORE_USER_AGENT_VALUE +from servicelib.utils import fire_and_forget_task + +from ..folders.errors import FolderValueNotPermittedError +from ..projects.projects_api import submit_delete_project_task +from ..users.api import get_user +from ..workspaces.api import check_user_workspace_access +from ..workspaces.errors import ( + WorkspaceAccessForbiddenError, + WorkspaceFolderInconsistencyError, +) +from . import _folders_db as folders_db + +_logger = logging.getLogger(__name__) + + +async def create_folder( + app: web.Application, + user_id: UserID, + name: str, + parent_folder_id: FolderID | None, + product_name: ProductName, + workspace_id: WorkspaceID | None, +) -> FolderGet: + user = await get_user(app, user_id=user_id) + + workspace_is_private = True + user_folder_access_rights = AccessRights(read=True, write=True, delete=True) + if workspace_id: + user_workspace_access_rights = await check_user_workspace_access( + app, + user_id=user_id, + workspace_id=workspace_id, + product_name=product_name, + permission="write", + ) + workspace_is_private = False + user_folder_access_rights = user_workspace_access_rights.my_access_rights + + # Check parent_folder_id lives in the workspace + if parent_folder_id: + parent_folder_db = await folders_db.get( + app, folder_id=parent_folder_id, product_name=product_name + ) + if parent_folder_db.workspace_id != workspace_id: + raise WorkspaceFolderInconsistencyError( + folder_id=parent_folder_id, workspace_id=workspace_id + ) + + if parent_folder_id: + # Check user has access to the parent folder + parent_folder_db = await folders_db.get_for_user_or_workspace( + app, + folder_id=parent_folder_id, + product_name=product_name, + user_id=user_id if workspace_is_private else None, + workspace_id=workspace_id, + ) + if workspace_id and parent_folder_db.workspace_id != workspace_id: + # Check parent folder id exists inside the same workspace + raise WorkspaceAccessForbiddenError( + reason=f"Folder {parent_folder_id} does not exists in workspace {workspace_id}." + ) + + folder_db = await folders_db.create( + app, + product_name=product_name, + created_by_gid=user["primary_gid"], + folder_name=name, + parent_folder_id=parent_folder_id, + user_id=user_id if workspace_is_private else None, + workspace_id=workspace_id, + ) + return FolderGet( + folder_id=folder_db.folder_id, + parent_folder_id=folder_db.parent_folder_id, + name=folder_db.name, + created_at=folder_db.created, + modified_at=folder_db.modified, + trashed_at=folder_db.trashed_at, + owner=folder_db.created_by_gid, + workspace_id=workspace_id, + my_access_rights=user_folder_access_rights, + ) + + +async def get_folder( + app: web.Application, + user_id: UserID, + folder_id: FolderID, + product_name: ProductName, +) -> FolderGet: + folder_db = await folders_db.get( + app, folder_id=folder_id, product_name=product_name + ) + + workspace_is_private = True + user_folder_access_rights = AccessRights(read=True, write=True, delete=True) + if folder_db.workspace_id: + user_workspace_access_rights = await check_user_workspace_access( + app, + user_id=user_id, + workspace_id=folder_db.workspace_id, + product_name=product_name, + permission="read", + ) + workspace_is_private = False + user_folder_access_rights = user_workspace_access_rights.my_access_rights + + folder_db = await folders_db.get_for_user_or_workspace( + app, + folder_id=folder_id, + product_name=product_name, + user_id=user_id if workspace_is_private else None, + workspace_id=folder_db.workspace_id, + ) + return FolderGet( + folder_id=folder_db.folder_id, + parent_folder_id=folder_db.parent_folder_id, + name=folder_db.name, + created_at=folder_db.created, + modified_at=folder_db.modified, + trashed_at=folder_db.trashed_at, + owner=folder_db.created_by_gid, + workspace_id=folder_db.workspace_id, + my_access_rights=user_folder_access_rights, + ) + + +async def list_folders( + app: web.Application, + user_id: UserID, + product_name: ProductName, + folder_id: FolderID | None, + workspace_id: WorkspaceID | None, + trashed: bool | None, + offset: NonNegativeInt, + limit: int, + order_by: OrderBy, +) -> FolderGetPage: + workspace_is_private = True + user_folder_access_rights = AccessRights(read=True, write=True, delete=True) + + if workspace_id: + user_workspace_access_rights = await check_user_workspace_access( + app, + user_id=user_id, + workspace_id=workspace_id, + product_name=product_name, + permission="read", + ) + workspace_is_private = False + user_folder_access_rights = user_workspace_access_rights.my_access_rights + _workspace_query = WorkspaceQuery( + workspace_scope=WorkspaceScope.SHARED, workspace_id=workspace_id + ) + else: + _workspace_query = WorkspaceQuery(workspace_scope=WorkspaceScope.PRIVATE) + + if folder_id: + # Check user access to folder + await folders_db.get_for_user_or_workspace( + app, + folder_id=folder_id, + product_name=product_name, + user_id=user_id if workspace_is_private else None, + workspace_id=workspace_id, + ) + _folder_query = FolderQuery( + folder_scope=FolderScope.SPECIFIC, folder_id=folder_id + ) + else: + _folder_query = FolderQuery(folder_scope=FolderScope.ROOT) + + total_count, folders = await folders_db.list_( + app, + product_name=product_name, + user_id=user_id, + folder_query=_folder_query, + workspace_query=_workspace_query, + filter_trashed=trashed, + offset=offset, + limit=limit, + order_by=order_by, + ) + return FolderGetPage( + items=[ + FolderGet( + folder_id=folder.folder_id, + parent_folder_id=folder.parent_folder_id, + name=folder.name, + created_at=folder.created, + modified_at=folder.modified, + trashed_at=folder.trashed_at, + owner=folder.created_by_gid, + workspace_id=folder.workspace_id, + my_access_rights=user_folder_access_rights, + ) + for folder in folders + ], + total=total_count, + ) + + +async def update_folder( + app: web.Application, + user_id: UserID, + folder_id: FolderID, + *, + name: str, + parent_folder_id: FolderID | None, + product_name: ProductName, +) -> FolderGet: + folder_db = await folders_db.get( + app, folder_id=folder_id, product_name=product_name + ) + + workspace_is_private = True + user_folder_access_rights = AccessRights(read=True, write=True, delete=True) + if folder_db.workspace_id: + user_workspace_access_rights = await check_user_workspace_access( + app, + user_id=user_id, + workspace_id=folder_db.workspace_id, + product_name=product_name, + permission="write", + ) + workspace_is_private = False + user_folder_access_rights = user_workspace_access_rights.my_access_rights + + # Check user has access to the folder + await folders_db.get_for_user_or_workspace( + app, + folder_id=folder_id, + product_name=product_name, + user_id=user_id if workspace_is_private else None, + workspace_id=folder_db.workspace_id, + ) + + if folder_db.parent_folder_id != parent_folder_id and parent_folder_id is not None: + # Check user has access to the parent folder + await folders_db.get_for_user_or_workspace( + app, + folder_id=parent_folder_id, + product_name=product_name, + user_id=user_id if workspace_is_private else None, + workspace_id=folder_db.workspace_id, + ) + # Do not allow to move to a child folder id + _child_folders = await folders_db.get_folders_recursively( + app, folder_id=folder_id, product_name=product_name + ) + if parent_folder_id in _child_folders: + raise FolderValueNotPermittedError( + reason="Parent folder id should not be one of children" + ) + + folder_db = await folders_db.update( + app, + folder_id=folder_id, + name=name, + parent_folder_id=parent_folder_id, + product_name=product_name, + ) + return FolderGet( + folder_id=folder_db.folder_id, + parent_folder_id=folder_db.parent_folder_id, + name=folder_db.name, + created_at=folder_db.created, + modified_at=folder_db.modified, + trashed_at=folder_db.trashed_at, + owner=folder_db.created_by_gid, + workspace_id=folder_db.workspace_id, + my_access_rights=user_folder_access_rights, + ) + + +async def delete_folder( + app: web.Application, + user_id: UserID, + folder_id: FolderID, + product_name: ProductName, +) -> None: + folder_db = await folders_db.get( + app, folder_id=folder_id, product_name=product_name + ) + + workspace_is_private = True + if folder_db.workspace_id: + await check_user_workspace_access( + app, + user_id=user_id, + workspace_id=folder_db.workspace_id, + product_name=product_name, + permission="delete", + ) + workspace_is_private = False + + # Check user has access to the folder + await folders_db.get_for_user_or_workspace( + app, + folder_id=folder_id, + product_name=product_name, + user_id=user_id if workspace_is_private else None, + workspace_id=folder_db.workspace_id, + ) + + # 1. Delete folder content + # 1.1 Delete all child projects that I am an owner + project_id_list: list[ + ProjectID + ] = await folders_db.get_projects_recursively_only_if_user_is_owner( + app, + folder_id=folder_id, + private_workspace_user_id_or_none=user_id if workspace_is_private else None, + user_id=user_id, + product_name=product_name, + ) + + # fire and forget task for project deletion + for project_id in project_id_list: + fire_and_forget_task( + submit_delete_project_task( + app, + project_uuid=project_id, + user_id=user_id, + simcore_user_agent=UNDEFINED_DEFAULT_SIMCORE_USER_AGENT_VALUE, + ), + task_suffix_name=f"delete_project_task_{project_id}", + fire_and_forget_tasks_collection=app[APP_FIRE_AND_FORGET_TASKS_KEY], + ) + + # 1.2 Delete all child folders + await folders_db.delete_recursively( + app, folder_id=folder_id, product_name=product_name + ) diff --git a/services/web/server/src/simcore_service_webserver/folders/_folders_db.py b/services/web/server/src/simcore_service_webserver/folders/_folders_db.py index 0ee44c17199..23f82781b3a 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_folders_db.py +++ b/services/web/server/src/simcore_service_webserver/folders/_folders_db.py @@ -8,22 +8,28 @@ from datetime import datetime from typing import Any, Final, cast +import sqlalchemy as sa from aiohttp import web -from models_library.folders import FolderDB, FolderID +from models_library.folders import FolderDB, FolderID, FolderQuery, FolderScope from models_library.products import ProductName from models_library.projects import ProjectID from models_library.rest_ordering import OrderBy, OrderDirection from models_library.users import GroupID, UserID -from models_library.workspaces import WorkspaceID +from models_library.workspaces import WorkspaceID, WorkspaceQuery, WorkspaceScope from pydantic import NonNegativeInt from simcore_postgres_database.models.folders_v2 import folders_v2 from simcore_postgres_database.models.projects import projects from simcore_postgres_database.models.projects_to_folders import projects_to_folders +from simcore_postgres_database.models.workspaces_access_rights import ( + workspaces_access_rights, +) +from simcore_postgres_database.utils import assemble_array_groups from sqlalchemy import func from sqlalchemy.orm import aliased -from sqlalchemy.sql import asc, desc, select +from sqlalchemy.sql import ColumnElement, CompoundSelect, Select, asc, desc, select from ..db.plugin import get_database_engine +from ..groups.api import list_all_user_groups from .errors import FolderAccessForbiddenError, FolderNotFoundError _logger = logging.getLogger(__name__) @@ -86,60 +92,149 @@ async def create( return FolderDB.from_orm(row) -async def list_( +async def list_( # pylint: disable=too-many-arguments,too-many-statements,too-many-branches app: web.Application, *, - content_of_folder_id: FolderID | None, - user_id: UserID | None, - workspace_id: WorkspaceID | None, product_name: ProductName, - trashed: bool | None, + user_id: UserID, + # hierarchy filters + folder_query: FolderQuery, + workspace_query: WorkspaceQuery, + # attribute filters + filter_trashed: bool | None, + # pagination offset: NonNegativeInt, limit: int, + # order order_by: OrderBy, ) -> tuple[int, list[FolderDB]]: """ - content_of_folder_id - Used to filter in which folder we want to list folders. None means root folder. + Assumptions - + + folder_query - Used to filter in which folder we want to list folders. None means root folder. trashed - If set to true, it returns folders **explicitly** trashed, if false then non-trashed folders. """ - assert not ( # nosec - user_id is not None and workspace_id is not None - ), "Both user_id and workspace_id cannot be provided at the same time. Please provide only one." + # assert not ( # nosec + # user_id is not None and workspace_id is not None + # ), "Both user_id and workspace_id cannot be provided at the same time. Please provide only one." + + workspace_access_rights_subquery = ( + sa.select( + workspaces_access_rights.c.workspace_id, + sa.func.jsonb_object_agg( + workspaces_access_rights.c.gid, + sa.func.jsonb_build_object( + "read", + workspaces_access_rights.c.read, + "write", + workspaces_access_rights.c.write, + "delete", + workspaces_access_rights.c.delete, + ), + ) + .filter(workspaces_access_rights.c.read) + .label("access_rights"), + ).group_by(workspaces_access_rights.c.workspace_id) + ).subquery("workspace_access_rights_subquery") + + if workspace_query.workspace_scope is not WorkspaceScope.SHARED: + assert workspace_query.workspace_scope in ( # nosec + WorkspaceScope.PRIVATE, + WorkspaceScope.ALL, + ) - base_query = ( - select(*_SELECTION_ARGS) - .select_from(folders_v2) - .where( - (folders_v2.c.product_name == product_name) - & (folders_v2.c.parent_folder_id == content_of_folder_id) + private_workspace_query = ( + select(*_SELECTION_ARGS) + .select_from(folders_v2) + .where( + (folders_v2.c.product_name == product_name) + & (folders_v2.c.user_id == user_id) + ) ) - ) + else: + private_workspace_query = None - if user_id: - base_query = base_query.where(folders_v2.c.user_id == user_id) + if workspace_query.workspace_scope is not WorkspaceScope.PRIVATE: + assert workspace_query.workspace_scope in ( # nosec + WorkspaceScope.SHARED, + WorkspaceScope.ALL, + ) + _user_groups = await list_all_user_groups(app, user_id=user_id) + _user_groups_ids = [group.gid for group in _user_groups] + + shared_workspace_query = ( + select(*_SELECTION_ARGS) + .select_from( + folders_v2.join( + workspace_access_rights_subquery, + folders_v2.c.workspace_id + == workspace_access_rights_subquery.c.workspace_id, + ) + ) + .where( + (folders_v2.c.product_name == product_name) + & ( + folders_v2.c.user_id.is_(None) + & ( + sa.text( + f"jsonb_exists_any(workspace_access_rights_subquery.access_rights, {assemble_array_groups(_user_groups_ids)})" + ) + ) + ) + ) + ) else: - assert workspace_id # nosec - base_query = base_query.where(folders_v2.c.workspace_id == workspace_id) + shared_workspace_query = None - if trashed is not None: - base_query = base_query.where( + attributes_filters: list[ColumnElement] = [] + + if filter_trashed is not None: + attributes_filters.append( ( (folders_v2.c.trashed_at.is_not(None)) & (folders_v2.c.trashed_explicitly.is_(True)) ) - if trashed + if filter_trashed else folders_v2.c.trashed_at.is_(None) ) + if folder_query.folder_scope is not FolderScope.ALL: + if folder_query.folder_scope == FolderScope.SPECIFIC: + attributes_filters.append( + projects_to_folders.c.folder_id == folder_query.folder_id + ) + else: + assert folder_query.folder_scope == FolderScope.ROOT # nosec + attributes_filters.append(projects_to_folders.c.folder_id.is_(None)) + + ### + # Combined + ### + + combined_query: CompoundSelect | Select | None = None + if private_workspace_query is not None and shared_workspace_query is not None: + combined_query = sa.union_all( + private_workspace_query.where(sa.and_(*attributes_filters)), + shared_workspace_query.where(sa.and_(*attributes_filters)), + ) + elif private_workspace_query is not None: + combined_query = private_workspace_query.where(sa.and_(*attributes_filters)) + elif shared_workspace_query is not None: + combined_query = shared_workspace_query.where(sa.and_(*attributes_filters)) + + if combined_query is None: + msg = f"No valid queries were provided to combine. Workspace scope: {workspace_query.workspace_scope}" + raise ValueError(msg) # Select total count from base_query - subquery = base_query.subquery() - count_query = select(func.count()).select_from(subquery) + count_query = select(func.count()).select_from(combined_query.subquery()) # Ordering and pagination if order_by.direction == OrderDirection.ASC: - list_query = base_query.order_by(asc(getattr(folders_v2.c, order_by.field))) + list_query = combined_query.order_by(asc(getattr(folders_v2.c, order_by.field))) else: - list_query = base_query.order_by(desc(getattr(folders_v2.c, order_by.field))) + list_query = combined_query.order_by( + desc(getattr(folders_v2.c, order_by.field)) + ) list_query = list_query.offset(offset).limit(limit) async with get_database_engine(app).acquire() as conn: From 948201496874b587aaff10e43cbb2f9c745ca51f Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Mon, 11 Nov 2024 15:19:21 +0100 Subject: [PATCH 02/11] fix --- .../server/src/simcore_service_webserver/folders/_folders_db.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/server/src/simcore_service_webserver/folders/_folders_db.py b/services/web/server/src/simcore_service_webserver/folders/_folders_db.py index 23f82781b3a..52f3ef7147f 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_folders_db.py +++ b/services/web/server/src/simcore_service_webserver/folders/_folders_db.py @@ -92,7 +92,7 @@ async def create( return FolderDB.from_orm(row) -async def list_( # pylint: disable=too-many-arguments,too-many-statements,too-many-branches +async def list_( # pylint: disable=too-many-arguments,too-many-branches app: web.Application, *, product_name: ProductName, From 827b884275d0429441763fb359696646d43d5acc Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Mon, 11 Nov 2024 15:21:23 +0100 Subject: [PATCH 03/11] fix --- .../src/simcore_service_webserver/folders/_folders_db.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/folders/_folders_db.py b/services/web/server/src/simcore_service_webserver/folders/_folders_db.py index 52f3ef7147f..314ee8e7f97 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_folders_db.py +++ b/services/web/server/src/simcore_service_webserver/folders/_folders_db.py @@ -111,12 +111,9 @@ async def list_( # pylint: disable=too-many-arguments,too-many-branches """ Assumptions - - folder_query - Used to filter in which folder we want to list folders. None means root folder. + folder_query - Used to filter in which folder we want to list folders. trashed - If set to true, it returns folders **explicitly** trashed, if false then non-trashed folders. """ - # assert not ( # nosec - # user_id is not None and workspace_id is not None - # ), "Both user_id and workspace_id cannot be provided at the same time. Please provide only one." workspace_access_rights_subquery = ( sa.select( From e225f04a445faf076fff128483216e5888f0a88f Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Mon, 11 Nov 2024 15:51:37 +0100 Subject: [PATCH 04/11] fix query --- .../src/simcore_service_webserver/folders/_folders_db.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/folders/_folders_db.py b/services/web/server/src/simcore_service_webserver/folders/_folders_db.py index 314ee8e7f97..348c06dfe60 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_folders_db.py +++ b/services/web/server/src/simcore_service_webserver/folders/_folders_db.py @@ -197,11 +197,11 @@ async def list_( # pylint: disable=too-many-arguments,too-many-branches if folder_query.folder_scope is not FolderScope.ALL: if folder_query.folder_scope == FolderScope.SPECIFIC: attributes_filters.append( - projects_to_folders.c.folder_id == folder_query.folder_id + folders_v2.c.parent_folder_id == folder_query.folder_id ) else: assert folder_query.folder_scope == FolderScope.ROOT # nosec - attributes_filters.append(projects_to_folders.c.folder_id.is_(None)) + attributes_filters.append(folders_v2.c.parent_folder_id.is_(None)) ### # Combined From 24ed180c746fe8d6f87802ae428f9a38b405c99f Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Mon, 11 Nov 2024 16:28:58 +0100 Subject: [PATCH 05/11] fix strange github behaviour --- .../folders/_folders_api.py | 22 +++++-------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/folders/_folders_api.py b/services/web/server/src/simcore_service_webserver/folders/_folders_api.py index 85a5db09459..91661b5af9b 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_folders_api.py +++ b/services/web/server/src/simcore_service_webserver/folders/_folders_api.py @@ -5,12 +5,12 @@ from aiohttp import web from models_library.access_rights import AccessRights from models_library.api_schemas_webserver.folders_v2 import FolderGet, FolderGetPage -from models_library.folders import FolderID, FolderQuery, FolderScope +from models_library.folders import FolderID from models_library.products import ProductName from models_library.projects import ProjectID from models_library.rest_ordering import OrderBy from models_library.users import UserID -from models_library.workspaces import WorkspaceID, WorkspaceQuery, WorkspaceScope +from models_library.workspaces import WorkspaceID from pydantic import NonNegativeInt from servicelib.aiohttp.application_keys import APP_FIRE_AND_FORGET_TASKS_KEY from servicelib.common_headers import UNDEFINED_DEFAULT_SIMCORE_USER_AGENT_VALUE @@ -166,11 +166,6 @@ async def list_folders( ) workspace_is_private = False user_folder_access_rights = user_workspace_access_rights.my_access_rights - _workspace_query = WorkspaceQuery( - workspace_scope=WorkspaceScope.SHARED, workspace_id=workspace_id - ) - else: - _workspace_query = WorkspaceQuery(workspace_scope=WorkspaceScope.PRIVATE) if folder_id: # Check user access to folder @@ -181,19 +176,14 @@ async def list_folders( user_id=user_id if workspace_is_private else None, workspace_id=workspace_id, ) - _folder_query = FolderQuery( - folder_scope=FolderScope.SPECIFIC, folder_id=folder_id - ) - else: - _folder_query = FolderQuery(folder_scope=FolderScope.ROOT) total_count, folders = await folders_db.list_( app, + content_of_folder_id=folder_id, + user_id=user_id if workspace_is_private else None, + workspace_id=workspace_id, product_name=product_name, - user_id=user_id, - folder_query=_folder_query, - workspace_query=_workspace_query, - filter_trashed=trashed, + trashed=trashed, offset=offset, limit=limit, order_by=order_by, From e96e865fd98076e6809720e2a280aaab51815837 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Mon, 11 Nov 2024 16:30:46 +0100 Subject: [PATCH 06/11] fix strange github behaviour --- .../folders/_folders_api.py | 22 +++++-------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/folders/_folders_api.py b/services/web/server/src/simcore_service_webserver/folders/_folders_api.py index 85a5db09459..91661b5af9b 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_folders_api.py +++ b/services/web/server/src/simcore_service_webserver/folders/_folders_api.py @@ -5,12 +5,12 @@ from aiohttp import web from models_library.access_rights import AccessRights from models_library.api_schemas_webserver.folders_v2 import FolderGet, FolderGetPage -from models_library.folders import FolderID, FolderQuery, FolderScope +from models_library.folders import FolderID from models_library.products import ProductName from models_library.projects import ProjectID from models_library.rest_ordering import OrderBy from models_library.users import UserID -from models_library.workspaces import WorkspaceID, WorkspaceQuery, WorkspaceScope +from models_library.workspaces import WorkspaceID from pydantic import NonNegativeInt from servicelib.aiohttp.application_keys import APP_FIRE_AND_FORGET_TASKS_KEY from servicelib.common_headers import UNDEFINED_DEFAULT_SIMCORE_USER_AGENT_VALUE @@ -166,11 +166,6 @@ async def list_folders( ) workspace_is_private = False user_folder_access_rights = user_workspace_access_rights.my_access_rights - _workspace_query = WorkspaceQuery( - workspace_scope=WorkspaceScope.SHARED, workspace_id=workspace_id - ) - else: - _workspace_query = WorkspaceQuery(workspace_scope=WorkspaceScope.PRIVATE) if folder_id: # Check user access to folder @@ -181,19 +176,14 @@ async def list_folders( user_id=user_id if workspace_is_private else None, workspace_id=workspace_id, ) - _folder_query = FolderQuery( - folder_scope=FolderScope.SPECIFIC, folder_id=folder_id - ) - else: - _folder_query = FolderQuery(folder_scope=FolderScope.ROOT) total_count, folders = await folders_db.list_( app, + content_of_folder_id=folder_id, + user_id=user_id if workspace_is_private else None, + workspace_id=workspace_id, product_name=product_name, - user_id=user_id, - folder_query=_folder_query, - workspace_query=_workspace_query, - filter_trashed=trashed, + trashed=trashed, offset=offset, limit=limit, order_by=order_by, From 28eef1555c351ffc3a9317e1f44a3c57d5e3d995 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Mon, 11 Nov 2024 16:31:58 +0100 Subject: [PATCH 07/11] fix strange github behaviour --- .../folders/_folders_api.py | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/folders/_folders_api.py b/services/web/server/src/simcore_service_webserver/folders/_folders_api.py index 91661b5af9b..85a5db09459 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_folders_api.py +++ b/services/web/server/src/simcore_service_webserver/folders/_folders_api.py @@ -5,12 +5,12 @@ from aiohttp import web from models_library.access_rights import AccessRights from models_library.api_schemas_webserver.folders_v2 import FolderGet, FolderGetPage -from models_library.folders import FolderID +from models_library.folders import FolderID, FolderQuery, FolderScope from models_library.products import ProductName from models_library.projects import ProjectID from models_library.rest_ordering import OrderBy from models_library.users import UserID -from models_library.workspaces import WorkspaceID +from models_library.workspaces import WorkspaceID, WorkspaceQuery, WorkspaceScope from pydantic import NonNegativeInt from servicelib.aiohttp.application_keys import APP_FIRE_AND_FORGET_TASKS_KEY from servicelib.common_headers import UNDEFINED_DEFAULT_SIMCORE_USER_AGENT_VALUE @@ -166,6 +166,11 @@ async def list_folders( ) workspace_is_private = False user_folder_access_rights = user_workspace_access_rights.my_access_rights + _workspace_query = WorkspaceQuery( + workspace_scope=WorkspaceScope.SHARED, workspace_id=workspace_id + ) + else: + _workspace_query = WorkspaceQuery(workspace_scope=WorkspaceScope.PRIVATE) if folder_id: # Check user access to folder @@ -176,14 +181,19 @@ async def list_folders( user_id=user_id if workspace_is_private else None, workspace_id=workspace_id, ) + _folder_query = FolderQuery( + folder_scope=FolderScope.SPECIFIC, folder_id=folder_id + ) + else: + _folder_query = FolderQuery(folder_scope=FolderScope.ROOT) total_count, folders = await folders_db.list_( app, - content_of_folder_id=folder_id, - user_id=user_id if workspace_is_private else None, - workspace_id=workspace_id, product_name=product_name, - trashed=trashed, + user_id=user_id, + folder_query=_folder_query, + workspace_query=_workspace_query, + filter_trashed=trashed, offset=offset, limit=limit, order_by=order_by, From d4ed8741181514a196527d2d97d88f460a9f6de0 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Tue, 12 Nov 2024 10:25:16 +0100 Subject: [PATCH 08/11] openapi specs --- api/specs/web-server/_folders.py | 21 +++++ .../src/models_library/folders.py | 8 ++ .../api/v0/openapi.yaml | 64 ++++++++++++++ .../folders/_folders_api.py | 87 +++++++++++-------- .../folders/_folders_db.py | 76 ++++++++-------- .../folders/_folders_handlers.py | 41 +++++++++ .../folders/_models.py | 35 +++++--- 7 files changed, 247 insertions(+), 85 deletions(-) diff --git a/api/specs/web-server/_folders.py b/api/specs/web-server/_folders.py index 90f1ad3beb1..25eecea5cd0 100644 --- a/api/specs/web-server/_folders.py +++ b/api/specs/web-server/_folders.py @@ -63,6 +63,27 @@ async def list_folders( ... +@router.get( + "/folders:search", + response_model=Envelope[list[FolderGet]], +) +async def list_folders_full_search( + params: Annotated[PageQueryParameters, Depends()], + order_by: Annotated[ + Json, + Query( + description="Order by field (modified_at|name|description) and direction (asc|desc). The default sorting order is ascending.", + example='{"field": "name", "direction": "desc"}', + ), + ] = '{"field": "modified_at", "direction": "desc"}', + filters: Annotated[ + Json | None, + Query(description=FolderFilters.schema_json(indent=1)), + ] = None, +): + ... + + @router.get( "/folders/{folder_id}", response_model=Envelope[FolderGet], diff --git a/packages/models-library/src/models_library/folders.py b/packages/models-library/src/models_library/folders.py index 485e74b86c8..1d2b9622943 100644 --- a/packages/models-library/src/models_library/folders.py +++ b/packages/models-library/src/models_library/folders.py @@ -4,6 +4,7 @@ from pydantic import BaseModel, Field, PositiveInt, validator +from .access_rights import AccessRights from .users import GroupID, UserID from .utils.enums import StrAutoEnum from .workspaces import WorkspaceID @@ -66,3 +67,10 @@ class FolderDB(BaseModel): class Config: orm_mode = True + + +class UserFolderAccessRightsDB(FolderDB): + my_access_rights: AccessRights + + class Config: + orm_mode = True diff --git a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml index dafb3f8fb08..40d0841c65a 100644 --- a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml +++ b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml @@ -2690,6 +2690,70 @@ paths: application/json: schema: $ref: '#/components/schemas/Envelope_FolderGet_' + /v0/folders:search: + get: + tags: + - folders + summary: List Folders Full Search + operationId: list_folders_full_search + parameters: + - description: Order by field (modified_at|name|description) and direction (asc|desc). + The default sorting order is ascending. + required: false + schema: + title: Order By + description: Order by field (modified_at|name|description) and direction + (asc|desc). The default sorting order is ascending. + default: '{"field": "modified_at", "direction": "desc"}' + example: '{"field": "name", "direction": "desc"}' + name: order_by + in: query + - description: "{\n \"title\": \"FolderFilters\",\n \"description\": \"Encoded\ + \ as JSON. Each available filter can have its own logic (should be well\ + \ documented)\\nInspired by Docker API https://docs.docker.com/engine/api/v1.43/#tag/Container/operation/ContainerList.\"\ + ,\n \"type\": \"object\",\n \"properties\": {\n \"trashed\": {\n \"title\"\ + : \"Trashed\",\n \"description\": \"Set to true to list trashed, false\ + \ to list non-trashed (default), None to list all\",\n \"default\": false,\n\ + \ \"type\": \"boolean\"\n }\n }\n}" + required: false + schema: + title: Filters + type: string + description: "{\n \"title\": \"FolderFilters\",\n \"description\": \"Encoded\ + \ as JSON. Each available filter can have its own logic (should be well\ + \ documented)\\nInspired by Docker API https://docs.docker.com/engine/api/v1.43/#tag/Container/operation/ContainerList.\"\ + ,\n \"type\": \"object\",\n \"properties\": {\n \"trashed\": {\n \"\ + title\": \"Trashed\",\n \"description\": \"Set to true to list trashed,\ + \ false to list non-trashed (default), None to list all\",\n \"default\"\ + : false,\n \"type\": \"boolean\"\n }\n }\n}" + format: json-string + name: filters + in: query + - required: false + schema: + title: Limit + exclusiveMaximum: true + minimum: 1 + type: integer + default: 20 + maximum: 50 + name: limit + in: query + - required: false + schema: + title: Offset + minimum: 0 + type: integer + default: 0 + name: offset + in: query + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope_list_models_library.api_schemas_webserver.folders_v2.FolderGet__' /v0/folders/{folder_id}: get: tags: diff --git a/services/web/server/src/simcore_service_webserver/folders/_folders_api.py b/services/web/server/src/simcore_service_webserver/folders/_folders_api.py index a35909f7741..a791a65c715 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_folders_api.py +++ b/services/web/server/src/simcore_service_webserver/folders/_folders_api.py @@ -153,46 +153,65 @@ async def list_folders( limit: int, order_by: OrderBy, ) -> FolderGetPage: - workspace_is_private = True - user_folder_access_rights = AccessRights(read=True, write=True, delete=True) + # NOTE: Folder access rights for listing are checked within the listing DB function. - if workspace_id: - user_workspace_access_rights = await check_user_workspace_access( - app, - user_id=user_id, - workspace_id=workspace_id, - product_name=product_name, - permission="read", - ) - workspace_is_private = False - user_folder_access_rights = user_workspace_access_rights.my_access_rights - _workspace_query = WorkspaceQuery( - workspace_scope=WorkspaceScope.SHARED, workspace_id=workspace_id - ) - else: - _workspace_query = WorkspaceQuery(workspace_scope=WorkspaceScope.PRIVATE) + total_count, folders = await folders_db.list_( + app, + product_name=product_name, + user_id=user_id, + folder_query=( + FolderQuery(folder_scope=FolderScope.SPECIFIC, folder_id=folder_id) + if folder_id + else FolderQuery(folder_scope=FolderScope.ROOT) + ), + workspace_query=( + WorkspaceQuery( + workspace_scope=WorkspaceScope.SHARED, workspace_id=workspace_id + ) + if workspace_id + else WorkspaceQuery(workspace_scope=WorkspaceScope.PRIVATE) + ), + filter_trashed=trashed, + offset=offset, + limit=limit, + order_by=order_by, + ) + return FolderGetPage( + items=[ + FolderGet( + folder_id=folder.folder_id, + parent_folder_id=folder.parent_folder_id, + name=folder.name, + created_at=folder.created, + modified_at=folder.modified, + trashed_at=folder.trashed_at, + owner=folder.created_by_gid, + workspace_id=folder.workspace_id, + my_access_rights=folder.my_access_rights, + ) + for folder in folders + ], + total=total_count, + ) - if folder_id: - # Check user access to folder - await folders_db.get_for_user_or_workspace( - app, - folder_id=folder_id, - product_name=product_name, - user_id=user_id if workspace_is_private else None, - workspace_id=workspace_id, - ) - _folder_query = FolderQuery( - folder_scope=FolderScope.SPECIFIC, folder_id=folder_id - ) - else: - _folder_query = FolderQuery(folder_scope=FolderScope.ROOT) + +async def list_folders_full_search( + app: web.Application, + user_id: UserID, + product_name: ProductName, + trashed: bool | None, + offset: NonNegativeInt, + limit: int, + order_by: OrderBy, +) -> FolderGetPage: + # NOTE: Folder access rights for listing are checked within the listing DB function. total_count, folders = await folders_db.list_( app, product_name=product_name, user_id=user_id, - folder_query=_folder_query, - workspace_query=_workspace_query, + folder_query=FolderQuery(folder_scope=FolderScope.ALL), + workspace_query=WorkspaceQuery(workspace_scope=WorkspaceScope.ALL), filter_trashed=trashed, offset=offset, limit=limit, @@ -209,7 +228,7 @@ async def list_folders( trashed_at=folder.trashed_at, owner=folder.created_by_gid, workspace_id=folder.workspace_id, - my_access_rights=user_folder_access_rights, + my_access_rights=folder.my_access_rights, ) for folder in folders ], diff --git a/services/web/server/src/simcore_service_webserver/folders/_folders_db.py b/services/web/server/src/simcore_service_webserver/folders/_folders_db.py index 6e7cfc920da..5d6081a8664 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_folders_db.py +++ b/services/web/server/src/simcore_service_webserver/folders/_folders_db.py @@ -10,7 +10,13 @@ import sqlalchemy as sa from aiohttp import web -from models_library.folders import FolderDB, FolderID, FolderQuery, FolderScope +from models_library.folders import ( + FolderDB, + FolderID, + FolderQuery, + FolderScope, + UserFolderAccessRightsDB, +) from models_library.products import ProductName from models_library.projects import ProjectID from models_library.rest_ordering import OrderBy, OrderDirection @@ -20,21 +26,17 @@ from simcore_postgres_database.models.folders_v2 import folders_v2 from simcore_postgres_database.models.projects import projects from simcore_postgres_database.models.projects_to_folders import projects_to_folders -from simcore_postgres_database.models.workspaces_access_rights import ( - workspaces_access_rights, -) from simcore_postgres_database.utils_repos import ( pass_or_acquire_connection, transaction_context, ) -from simcore_postgres_database.utils_sql import assemble_array_groups from sqlalchemy import func from sqlalchemy.ext.asyncio import AsyncConnection from sqlalchemy.orm import aliased from sqlalchemy.sql import ColumnElement, CompoundSelect, Select, asc, desc, select from ..db.plugin import get_asyncpg_engine -from ..groups.api import list_all_user_groups +from ..workspaces._workspaces_db import _create_my_access_rights_subquery from .errors import FolderAccessForbiddenError, FolderNotFoundError _logger = logging.getLogger(__name__) @@ -63,6 +65,10 @@ def as_dict_exclude_unset(**params) -> dict[str, Any]: folders_v2.c.workspace_id, ) +# _SELECTION_ARGS_WITH_MY_ACCESS_RIGHTS = ( +# _SELECTION_ARGS, +# ) + async def create( app: web.Application, @@ -114,32 +120,15 @@ async def list_( # pylint: disable=too-many-arguments,too-many-branches limit: int, # order order_by: OrderBy, -) -> tuple[int, list[FolderDB]]: +) -> tuple[int, list[UserFolderAccessRightsDB]]: """ - Assumptions - - folder_query - Used to filter in which folder we want to list folders. trashed - If set to true, it returns folders **explicitly** trashed, if false then non-trashed folders. """ - workspace_access_rights_subquery = ( - sa.select( - workspaces_access_rights.c.workspace_id, - sa.func.jsonb_object_agg( - workspaces_access_rights.c.gid, - sa.func.jsonb_build_object( - "read", - workspaces_access_rights.c.read, - "write", - workspaces_access_rights.c.write, - "delete", - workspaces_access_rights.c.delete, - ), - ) - .filter(workspaces_access_rights.c.read) - .label("access_rights"), - ).group_by(workspaces_access_rights.c.workspace_id) - ).subquery("workspace_access_rights_subquery") + workspace_access_rights_subquery = _create_my_access_rights_subquery( + user_id=user_id + ) if workspace_query.workspace_scope is not WorkspaceScope.SHARED: assert workspace_query.workspace_scope in ( # nosec @@ -148,7 +137,17 @@ async def list_( # pylint: disable=too-many-arguments,too-many-branches ) private_workspace_query = ( - select(*_SELECTION_ARGS) + select( + *_SELECTION_ARGS, + func.json_build_object( + "read", + sa.text("true"), + "write", + sa.text("true"), + "delete", + sa.text("true"), + ).label("my_access_rights"), + ) .select_from(folders_v2) .where( (folders_v2.c.product_name == product_name) @@ -163,11 +162,13 @@ async def list_( # pylint: disable=too-many-arguments,too-many-branches WorkspaceScope.SHARED, WorkspaceScope.ALL, ) - _user_groups = await list_all_user_groups(app, user_id=user_id) - _user_groups_ids = [group.gid for group in _user_groups] + # _user_groups = await list_all_user_groups(app, user_id=user_id) + # _user_groups_ids = [group.gid for group in _user_groups] shared_workspace_query = ( - select(*_SELECTION_ARGS) + select( + *_SELECTION_ARGS, workspace_access_rights_subquery.c.my_access_rights + ) .select_from( folders_v2.join( workspace_access_rights_subquery, @@ -177,14 +178,7 @@ async def list_( # pylint: disable=too-many-arguments,too-many-branches ) .where( (folders_v2.c.product_name == product_name) - & ( - folders_v2.c.user_id.is_(None) - & ( - sa.text( - f"jsonb_exists_any(workspace_access_rights_subquery.access_rights, {assemble_array_groups(_user_groups_ids)})" - ) - ) - ) + & (folders_v2.c.user_id.is_(None)) ) ) else: @@ -245,7 +239,9 @@ async def list_( # pylint: disable=too-many-arguments,too-many-branches total_count = await conn.scalar(count_query) result = await conn.stream(list_query) - folders: list[FolderDB] = [FolderDB.from_orm(row) async for row in result] + folders: list[UserFolderAccessRightsDB] = [ + UserFolderAccessRightsDB.from_orm(row) async for row in result + ] return cast(int, total_count), folders diff --git a/services/web/server/src/simcore_service_webserver/folders/_folders_handlers.py b/services/web/server/src/simcore_service_webserver/folders/_folders_handlers.py index e4fffd82fc6..7050205bd7d 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_folders_handlers.py +++ b/services/web/server/src/simcore_service_webserver/folders/_folders_handlers.py @@ -28,6 +28,7 @@ from ._exceptions_handlers import handle_plugin_requests_exceptions from ._models import ( FolderFilters, + FolderListFullSearchWithJsonStrQueryParams, FolderListWithJsonStrQueryParams, FoldersPathParams, FoldersRequestContext, @@ -99,6 +100,46 @@ async def list_folders(request: web.Request): ) +@routes.get(f"/{VTAG}/folders:search", name="list_folders_full_search") +@login_required +@permission_required("folder.read") +@handle_plugin_requests_exceptions +async def list_folders_full_search(request: web.Request): + req_ctx = FoldersRequestContext.parse_obj(request) + query_params: FolderListFullSearchWithJsonStrQueryParams = ( + parse_request_query_parameters_as( + FolderListFullSearchWithJsonStrQueryParams, request + ) + ) + + if not query_params.filters: + query_params.filters = FolderFilters() + + folders: FolderGetPage = await _folders_api.list_folders_full_search( + app=request.app, + user_id=req_ctx.user_id, + product_name=req_ctx.product_name, + trashed=query_params.filters.trashed, + offset=query_params.offset, + limit=query_params.limit, + order_by=parse_obj_as(OrderBy, query_params.order_by), + ) + + page = Page[FolderGet].parse_obj( + paginate_data( + chunk=folders.items, + request_url=request.url, + total=folders.total, + limit=query_params.limit, + offset=query_params.offset, + ) + ) + return web.Response( + text=page.json(**RESPONSE_MODEL_POLICY), + content_type=MIMETYPE_APPLICATION_JSON, + ) + + @routes.get(f"/{VTAG}/folders/{{folder_id}}", name="get_folder") @login_required @permission_required("folder.read") diff --git a/services/web/server/src/simcore_service_webserver/folders/_models.py b/services/web/server/src/simcore_service_webserver/folders/_models.py index fb337b5b199..5e48f46fa37 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_models.py +++ b/services/web/server/src/simcore_service_webserver/folders/_models.py @@ -33,9 +33,7 @@ class FolderFilters(Filters): ) -class FolderListWithJsonStrQueryParams( - PageQueryParameters, FiltersQueryParameters[FolderFilters] -): +class FolderListSortParams(BaseModel): # pylint: disable=unsubscriptable-object order_by: Json[OrderBy] = Field( default=OrderBy(field=IDStr("modified"), direction=OrderDirection.DESC), @@ -43,14 +41,6 @@ class FolderListWithJsonStrQueryParams( example='{"field": "name", "direction": "desc"}', alias="order_by", ) - folder_id: FolderID | None = Field( - default=None, - description="List the subfolders of this folder. By default, list the subfolders of the root directory (Folder ID is None).", - ) - workspace_id: WorkspaceID | None = Field( - default=None, - description="List folders in specific workspace. By default, list in the user private workspace", - ) @validator("order_by", check_fields=False) @classmethod @@ -69,6 +59,22 @@ def _validate_order_by_field(cls, v): class Config: extra = Extra.forbid + +class FolderListWithJsonStrQueryParams( + PageQueryParameters, FolderListSortParams, FiltersQueryParameters[FolderFilters] +): + folder_id: FolderID | None = Field( + default=None, + description="List the subfolders of this folder. By default, list the subfolders of the root directory (Folder ID is None).", + ) + workspace_id: WorkspaceID | None = Field( + default=None, + description="List folders in specific workspace. By default, list in the user private workspace", + ) + + class Config: + extra = Extra.forbid + # validators _null_or_none_str_to_none_validator = validator( "folder_id", allow_reuse=True, pre=True @@ -79,6 +85,13 @@ class Config: )(null_or_none_str_to_none_validator) +class FolderListFullSearchWithJsonStrQueryParams( + PageQueryParameters, FolderListSortParams, FiltersQueryParameters[FolderFilters] +): + class Config: + extra = Extra.forbid + + class RemoveQueryParams(BaseModel): force: bool = Field( default=False, description="Force removal (even if resource is active)" From 215bc2c7d56f69d49016fe42145ee02c5721d039 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Tue, 12 Nov 2024 10:31:11 +0100 Subject: [PATCH 09/11] review @pcrespov --- .../utils_workspaces_sql.py | 30 +++++++++++++++ .../folders/_folders_db.py | 6 ++- .../workspaces/_workspaces_db.py | 38 +++++-------------- 3 files changed, 44 insertions(+), 30 deletions(-) create mode 100644 packages/postgres-database/src/simcore_postgres_database/utils_workspaces_sql.py diff --git a/packages/postgres-database/src/simcore_postgres_database/utils_workspaces_sql.py b/packages/postgres-database/src/simcore_postgres_database/utils_workspaces_sql.py new file mode 100644 index 00000000000..05b24d969bd --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/utils_workspaces_sql.py @@ -0,0 +1,30 @@ +from simcore_postgres_database.models.groups import user_to_groups +from simcore_postgres_database.models.workspaces_access_rights import ( + workspaces_access_rights, +) +from sqlalchemy import func +from sqlalchemy.dialects.postgresql import BOOLEAN, INTEGER +from sqlalchemy.sql import Subquery, select + + +def create_my_workspace_access_rights_subquery(user_id: int) -> Subquery: + return ( + select( + workspaces_access_rights.c.workspace_id, + func.json_build_object( + "read", + func.max(workspaces_access_rights.c.read.cast(INTEGER)).cast(BOOLEAN), + "write", + func.max(workspaces_access_rights.c.write.cast(INTEGER)).cast(BOOLEAN), + "delete", + func.max(workspaces_access_rights.c.delete.cast(INTEGER)).cast(BOOLEAN), + ).label("my_access_rights"), + ) + .select_from( + workspaces_access_rights.join( + user_to_groups, user_to_groups.c.gid == workspaces_access_rights.c.gid + ) + ) + .where(user_to_groups.c.uid == user_id) + .group_by(workspaces_access_rights.c.workspace_id) + ).subquery("my_workspace_access_rights_subquery") diff --git a/services/web/server/src/simcore_service_webserver/folders/_folders_db.py b/services/web/server/src/simcore_service_webserver/folders/_folders_db.py index 5d6081a8664..6b4cb1f8490 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_folders_db.py +++ b/services/web/server/src/simcore_service_webserver/folders/_folders_db.py @@ -30,13 +30,15 @@ pass_or_acquire_connection, transaction_context, ) +from simcore_postgres_database.utils_workspaces_sql import ( + create_my_workspace_access_rights_subquery, +) from sqlalchemy import func from sqlalchemy.ext.asyncio import AsyncConnection from sqlalchemy.orm import aliased from sqlalchemy.sql import ColumnElement, CompoundSelect, Select, asc, desc, select from ..db.plugin import get_asyncpg_engine -from ..workspaces._workspaces_db import _create_my_access_rights_subquery from .errors import FolderAccessForbiddenError, FolderNotFoundError _logger = logging.getLogger(__name__) @@ -126,7 +128,7 @@ async def list_( # pylint: disable=too-many-arguments,too-many-branches trashed - If set to true, it returns folders **explicitly** trashed, if false then non-trashed folders. """ - workspace_access_rights_subquery = _create_my_access_rights_subquery( + workspace_access_rights_subquery = create_my_workspace_access_rights_subquery( user_id=user_id ) diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_db.py b/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_db.py index a959843a969..fa0ab9dbab6 100644 --- a/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_db.py +++ b/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_db.py @@ -17,7 +17,6 @@ WorkspaceID, ) from pydantic import NonNegativeInt -from simcore_postgres_database.models.groups import user_to_groups from simcore_postgres_database.models.workspaces import workspaces from simcore_postgres_database.models.workspaces_access_rights import ( workspaces_access_rights, @@ -26,10 +25,12 @@ pass_or_acquire_connection, transaction_context, ) +from simcore_postgres_database.utils_workspaces_sql import ( + create_my_workspace_access_rights_subquery, +) from sqlalchemy import asc, desc, func -from sqlalchemy.dialects.postgresql import BOOLEAN, INTEGER from sqlalchemy.ext.asyncio import AsyncConnection -from sqlalchemy.sql import Subquery, select +from sqlalchemy.sql import select from ..db.plugin import get_asyncpg_engine from .errors import WorkspaceAccessForbiddenError, WorkspaceNotFoundError @@ -98,29 +99,6 @@ async def create_workspace( ).subquery("access_rights_subquery") -def _create_my_access_rights_subquery(user_id: UserID) -> Subquery: - return ( - select( - workspaces_access_rights.c.workspace_id, - func.json_build_object( - "read", - func.max(workspaces_access_rights.c.read.cast(INTEGER)).cast(BOOLEAN), - "write", - func.max(workspaces_access_rights.c.write.cast(INTEGER)).cast(BOOLEAN), - "delete", - func.max(workspaces_access_rights.c.delete.cast(INTEGER)).cast(BOOLEAN), - ).label("my_access_rights"), - ) - .select_from( - workspaces_access_rights.join( - user_to_groups, user_to_groups.c.gid == workspaces_access_rights.c.gid - ) - ) - .where(user_to_groups.c.uid == user_id) - .group_by(workspaces_access_rights.c.workspace_id) - ).subquery("my_access_rights_subquery") - - async def list_workspaces_for_user( app: web.Application, connection: AsyncConnection | None = None, @@ -131,7 +109,9 @@ async def list_workspaces_for_user( limit: NonNegativeInt, order_by: OrderBy, ) -> tuple[int, list[UserWorkspaceAccessRightsDB]]: - my_access_rights_subquery = _create_my_access_rights_subquery(user_id=user_id) + my_access_rights_subquery = create_my_workspace_access_rights_subquery( + user_id=user_id + ) base_query = ( select( @@ -175,7 +155,9 @@ async def get_workspace_for_user( workspace_id: WorkspaceID, product_name: ProductName, ) -> UserWorkspaceAccessRightsDB: - my_access_rights_subquery = _create_my_access_rights_subquery(user_id=user_id) + my_access_rights_subquery = create_my_workspace_access_rights_subquery( + user_id=user_id + ) base_query = ( select( From 3cc47430a95ad6aa10bbd02d1fe251c14435c14a Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Tue, 12 Nov 2024 10:46:58 +0100 Subject: [PATCH 10/11] review @pcrespov - adding unit tests --- .../04/folders/test_folders__full_search.py | 123 ++++++++++++++++++ ...st_workspaces__list_folders_full_search.py | 65 +++++++++ 2 files changed, 188 insertions(+) create mode 100644 services/web/server/tests/unit/with_dbs/04/folders/test_folders__full_search.py create mode 100644 services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__list_folders_full_search.py diff --git a/services/web/server/tests/unit/with_dbs/04/folders/test_folders__full_search.py b/services/web/server/tests/unit/with_dbs/04/folders/test_folders__full_search.py new file mode 100644 index 00000000000..b9da926543e --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/04/folders/test_folders__full_search.py @@ -0,0 +1,123 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments +# pylint: disable=too-many-statements + + +from http import HTTPStatus + +import pytest +from aiohttp.test_utils import TestClient +from models_library.api_schemas_webserver.folders_v2 import FolderGet +from pytest_simcore.helpers.assert_checks import assert_status +from pytest_simcore.helpers.webserver_login import LoggedUser, UserInfoDict +from pytest_simcore.helpers.webserver_parametrizations import ( + ExpectedResponse, + standard_role_response, +) +from servicelib.aiohttp import status +from simcore_service_webserver.db.models import UserRole +from simcore_service_webserver.projects.models import ProjectDict + + +@pytest.mark.parametrize(*standard_role_response(), ids=str) +async def test_folders_user_role_permissions( + client: TestClient, + logged_user: UserInfoDict, + user_project: ProjectDict, + expected: ExpectedResponse, +): + assert client.app + + url = client.app.router["list_folders_full_search"].url_for() + resp = await client.get(f"{url}") + await assert_status(resp, expected.ok) + + +@pytest.mark.parametrize("user_role,expected", [(UserRole.USER, status.HTTP_200_OK)]) +async def test_folders_full_search( + client: TestClient, + logged_user: UserInfoDict, + user_project: ProjectDict, + expected: HTTPStatus, +): + assert client.app + + # list full folder search + url = client.app.router["list_folders_full_search"].url_for() + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert data == [] + + # create a new folder + url = client.app.router["create_folder"].url_for() + resp = await client.post(f"{url}", json={"name": "My first folder"}) + root_folder, _ = await assert_status(resp, status.HTTP_201_CREATED) + + # create a subfolder folder + url = client.app.router["create_folder"].url_for() + resp = await client.post( + f"{url}", + json={ + "name": "My subfolder", + "parentFolderId": root_folder["folderId"], + }, + ) + subfolder_folder, _ = await assert_status(resp, status.HTTP_201_CREATED) + + # list full folder search + url = client.app.router["list_folders_full_search"].url_for() + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(data) == 2 + + # create a sub sub folder + url = client.app.router["create_folder"].url_for() + resp = await client.post( + f"{url}", + json={ + "name": "My sub sub folder", + "parentFolderId": subfolder_folder["folderId"], + }, + ) + subsubfolder_folder, _ = await assert_status(resp, status.HTTP_201_CREATED) + + # move sub sub folder to root folder + url = client.app.router["replace_folder"].url_for( + folder_id=f"{subsubfolder_folder['folderId']}" + ) + resp = await client.put( + f"{url}", + json={ + "name": "My Updated Folder", + "parentFolderId": None, + }, + ) + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert FolderGet.parse_obj(data) + + # list full folder search + url = client.app.router["list_folders_full_search"].url_for() + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(data) == 3 + + # Create new user + async with LoggedUser(client) as new_logged_user: + # list full folder search + url = client.app.router["list_folders_full_search"].url_for() + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert data == [] + + # create a new folder + url = client.app.router["create_folder"].url_for() + resp = await client.post(f"{url}", json={"name": "New user folder"}) + new_user_folder, _ = await assert_status(resp, status.HTTP_201_CREATED) + + # list full folder search + url = client.app.router["list_folders_full_search"].url_for() + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(data) == 1 diff --git a/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__list_folders_full_search.py b/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__list_folders_full_search.py new file mode 100644 index 00000000000..3cfc1a78842 --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__list_folders_full_search.py @@ -0,0 +1,65 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments +# pylint: disable=too-many-statements + + +from http import HTTPStatus + +import pytest +from aiohttp.test_utils import TestClient +from pytest_simcore.helpers.assert_checks import assert_status +from pytest_simcore.helpers.webserver_login import UserInfoDict +from servicelib.aiohttp import status +from simcore_service_webserver.db.models import UserRole + + +@pytest.mark.parametrize("user_role,expected", [(UserRole.USER, status.HTTP_200_OK)]) +async def test_workspaces__list_folders_full_search( + client: TestClient, + logged_user: UserInfoDict, + expected: HTTPStatus, + workspaces_clean_db: None, +): + assert client.app + + # list full folder search + url = client.app.router["list_folders_full_search"].url_for() + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert data == [] + + # create a new folder + url = client.app.router["create_folder"].url_for() + resp = await client.post(f"{url}", json={"name": "My first folder"}) + root_folder, _ = await assert_status(resp, status.HTTP_201_CREATED) + + # list full folder search + url = client.app.router["list_folders_full_search"].url_for() + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(data) == 1 + + # create a new workspace + url = client.app.router["create_workspace"].url_for() + resp = await client.post( + url.path, + json={ + "name": "My first workspace", + "description": "Custom description", + "thumbnail": None, + }, + ) + added_workspace, _ = await assert_status(resp, status.HTTP_201_CREATED) + + # create a folder + url = client.app.router["create_folder"].url_for() + resp = await client.post(url.path, json={"name": "My first folder"}) + root_folder, _ = await assert_status(resp, status.HTTP_201_CREATED) + + # list full folder search + url = client.app.router["list_folders_full_search"].url_for() + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(data) == 2 From 1ac1bd295fb3d3a7fd08133e02f4be645150bccb Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Tue, 12 Nov 2024 10:48:27 +0100 Subject: [PATCH 11/11] final cleanup --- .../src/simcore_service_webserver/folders/_folders_db.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/folders/_folders_db.py b/services/web/server/src/simcore_service_webserver/folders/_folders_db.py index 6b4cb1f8490..0af9d36dadf 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_folders_db.py +++ b/services/web/server/src/simcore_service_webserver/folders/_folders_db.py @@ -67,10 +67,6 @@ def as_dict_exclude_unset(**params) -> dict[str, Any]: folders_v2.c.workspace_id, ) -# _SELECTION_ARGS_WITH_MY_ACCESS_RIGHTS = ( -# _SELECTION_ARGS, -# ) - async def create( app: web.Application, @@ -164,8 +160,6 @@ async def list_( # pylint: disable=too-many-arguments,too-many-branches WorkspaceScope.SHARED, WorkspaceScope.ALL, ) - # _user_groups = await list_all_user_groups(app, user_id=user_id) - # _user_groups_ids = [group.gid for group in _user_groups] shared_workspace_query = ( select(