diff --git a/packages/models-library/src/models_library/folders.py b/packages/models-library/src/models_library/folders.py index 4d73618750c3..485e74b86c87 100644 --- a/packages/models-library/src/models_library/folders.py +++ b/packages/models-library/src/models_library/folders.py @@ -1,13 +1,41 @@ from datetime import datetime +from enum import auto from typing import TypeAlias -from models_library.users import GroupID, UserID -from models_library.workspaces import WorkspaceID -from pydantic import BaseModel, Field, PositiveInt +from pydantic import BaseModel, Field, PositiveInt, validator + +from .users import GroupID, UserID +from .utils.enums import StrAutoEnum +from .workspaces import WorkspaceID FolderID: TypeAlias = PositiveInt +class FolderScope(StrAutoEnum): + ROOT = auto() + SPECIFIC = auto() + ALL = auto() + + +class FolderQuery(BaseModel): + folder_scope: FolderScope + folder_id: PositiveInt | None = None + + @validator("folder_id", pre=True, always=True) + @classmethod + def validate_folder_id(cls, value, values): + scope = values.get("folder_scope") + if scope == FolderScope.SPECIFIC and value is None: + raise ValueError( + "folder_id must be provided when folder_scope is SPECIFIC." + ) + if scope != FolderScope.SPECIFIC and value is not None: + raise ValueError( + "folder_id should be None when folder_scope is not SPECIFIC." + ) + return value + + # # DB # diff --git a/packages/models-library/src/models_library/workspaces.py b/packages/models-library/src/models_library/workspaces.py index c08e02501cb3..e5b816623fe6 100644 --- a/packages/models-library/src/models_library/workspaces.py +++ b/packages/models-library/src/models_library/workspaces.py @@ -1,13 +1,41 @@ from datetime import datetime +from enum import auto from typing import TypeAlias -from models_library.access_rights import AccessRights -from models_library.users import GroupID -from pydantic import BaseModel, Field, PositiveInt +from pydantic import BaseModel, Field, PositiveInt, validator + +from .access_rights import AccessRights +from .users import GroupID +from .utils.enums import StrAutoEnum WorkspaceID: TypeAlias = PositiveInt +class WorkspaceScope(StrAutoEnum): + PRIVATE = auto() + SHARED = auto() + ALL = auto() + + +class WorkspaceQuery(BaseModel): + workspace_scope: WorkspaceScope + workspace_id: PositiveInt | None = None + + @validator("workspace_id", pre=True, always=True) + @classmethod + def validate_workspace_id(cls, value, values): + scope = values.get("workspace_scope") + if scope == WorkspaceScope.SHARED and value is None: + raise ValueError( + "workspace_id must be provided when workspace_scope is SHARED." + ) + if scope != WorkspaceScope.SHARED and value is not None: + raise ValueError( + "workspace_id should be None when workspace_scope is not SHARED." + ) + return value + + # # DB # diff --git a/services/web/server/src/simcore_service_webserver/projects/_crud_api_read.py b/services/web/server/src/simcore_service_webserver/projects/_crud_api_read.py index f8b6aee4ff93..4d4352d52294 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_crud_api_read.py +++ b/services/web/server/src/simcore_service_webserver/projects/_crud_api_read.py @@ -6,16 +6,16 @@ """ from aiohttp import web -from models_library.access_rights import AccessRights from models_library.api_schemas_webserver._base import OutputSchema from models_library.api_schemas_webserver.projects import ProjectListItem -from models_library.folders import FolderID +from models_library.folders import FolderID, FolderQuery, FolderScope from models_library.projects import ProjectID from models_library.rest_ordering import OrderBy -from models_library.users import GroupID, UserID -from models_library.workspaces import WorkspaceID +from models_library.users import UserID +from models_library.workspaces import WorkspaceID, WorkspaceQuery, WorkspaceScope from pydantic import NonNegativeInt from servicelib.utils import logged_gather +from simcore_postgres_database.models.projects import ProjectType from simcore_postgres_database.webserver_models import ProjectType as ProjectTypeDB from simcore_service_webserver.workspaces._workspaces_api import ( check_user_workspace_access, @@ -23,7 +23,6 @@ from ..catalog.client import get_services_for_user_in_product from ..folders import _folders_db as folders_db -from ..workspaces import _workspaces_db as workspaces_db from . import projects_api from ._permalink_api import update_or_pop_permalink_in_project from .db import ProjectDBAPI @@ -36,7 +35,6 @@ async def _append_fields( user_id: UserID, project: ProjectDict, is_template: bool, - workspace_access_rights: dict[GroupID, AccessRights] | None, model_schema_cls: type[OutputSchema], ): # state @@ -50,12 +48,6 @@ async def _append_fields( # permalink await update_or_pop_permalink_in_project(request, project) - # replace project access rights (if project is in workspace) - if workspace_access_rights: - project["accessRights"] = { - gid: access.dict() for gid, access in workspace_access_rights.items() - } - # validate return model_schema_cls.parse_obj(project).data(exclude_unset=True) @@ -110,15 +102,25 @@ async def list_projects( # pylint: disable=too-many-arguments db_projects, db_project_types, total_number_projects = await db.list_projects( product_name=product_name, user_id=user_id, - workspace_id=workspace_id, - folder_id=folder_id, + workspace_query=( + WorkspaceQuery( + workspace_scope=WorkspaceScope.SHARED, workspace_id=workspace_id + ) + if workspace_id + else WorkspaceQuery(workspace_scope=WorkspaceScope.PRIVATE) + ), + folder_query=( + FolderQuery(folder_scope=FolderScope.SPECIFIC, folder_id=folder_id) + if folder_id + else FolderQuery(folder_scope=FolderScope.ROOT) + ), # attrs filter_by_project_type=ProjectTypeAPI.to_project_type_db(project_type), filter_by_services=user_available_services, filter_trashed=trashed, filter_hidden=show_hidden, # composed attrs - search=search, + filter_by_text=search, # pagination offset=offset, limit=limit, @@ -126,14 +128,6 @@ async def list_projects( # pylint: disable=too-many-arguments order_by=order_by, ) - # If workspace, override project access rights - workspace_access_rights = None - if workspace_id: - workspace_db = await workspaces_db.get_workspace_for_user( - app, user_id=user_id, workspace_id=workspace_id, product_name=product_name - ) - workspace_access_rights = workspace_db.access_rights - projects: list[ProjectDict] = await logged_gather( *( _append_fields( @@ -141,7 +135,6 @@ async def list_projects( # pylint: disable=too-many-arguments user_id=user_id, project=prj, is_template=prj_type == ProjectTypeDB.TEMPLATE, - workspace_access_rights=workspace_access_rights, model_schema_cls=ProjectListItem, ) for prj, prj_type in zip(db_projects, db_project_types) @@ -170,19 +163,18 @@ async def list_projects_full_search( request.app, user_id, product_name, only_key_versions=True ) - ( - db_projects, - db_project_types, - total_number_projects, - ) = await db.list_projects_full_search( - user_id=user_id, + (db_projects, db_project_types, total_number_projects,) = await db.list_projects( product_name=product_name, + user_id=user_id, + workspace_query=WorkspaceQuery(workspace_scope=WorkspaceScope.ALL), + folder_query=FolderQuery(folder_scope=FolderScope.ALL), filter_by_services=user_available_services, - text=text, + filter_by_text=text, + filter_tag_ids_list=tag_ids_list, + filter_by_project_type=ProjectType.STANDARD, offset=offset, limit=limit, order_by=order_by, - tag_ids_list=tag_ids_list, ) projects: list[ProjectDict] = await logged_gather( @@ -192,7 +184,6 @@ async def list_projects_full_search( user_id=user_id, project=prj, is_template=prj_type == ProjectTypeDB.TEMPLATE, - workspace_access_rights=None, model_schema_cls=ProjectListItem, ) for prj, prj_type in zip(db_projects, db_project_types) diff --git a/services/web/server/src/simcore_service_webserver/projects/db.py b/services/web/server/src/simcore_service_webserver/projects/db.py index 5e0c216f77eb..2281b807a719 100644 --- a/services/web/server/src/simcore_service_webserver/projects/db.py +++ b/services/web/server/src/simcore_service_webserver/projects/db.py @@ -16,7 +16,7 @@ from aiopg.sa.connection import SAConnection from aiopg.sa.result import ResultProxy, RowProxy from models_library.basic_types import IDStr -from models_library.folders import FolderID +from models_library.folders import FolderQuery, FolderScope from models_library.products import ProductName from models_library.projects import ProjectID, ProjectIDStr from models_library.projects_comments import CommentID, ProjectsCommentsDB @@ -31,7 +31,7 @@ from models_library.users import UserID from models_library.utils.fastapi_encoders import jsonable_encoder from models_library.wallets import WalletDB, WalletID -from models_library.workspaces import WorkspaceID +from models_library.workspaces import WorkspaceQuery, WorkspaceScope from pydantic import parse_obj_as from pydantic.types import PositiveInt from servicelib.aiohttp.application_keys import APP_AIOPG_ENGINE_KEY @@ -59,7 +59,7 @@ from sqlalchemy import func, literal_column from sqlalchemy.dialects.postgresql import BOOLEAN, INTEGER from sqlalchemy.dialects.postgresql import insert as pg_insert -from sqlalchemy.sql import and_ +from sqlalchemy.sql import ColumnElement, CompoundSelect, Select, and_ from tenacity import TryAgain from tenacity.asyncio import AsyncRetrying from tenacity.retry import retry_if_exception_type @@ -350,21 +350,22 @@ async def upsert_project_linked_product( ).group_by(project_to_groups.c.project_uuid) ).subquery("access_rights_subquery") - async def list_projects( # pylint: disable=too-many-arguments + async def list_projects( # pylint: disable=too-many-arguments,too-many-statements,too-many-branches self, *, - # hierarchy filters - product_name: str, + product_name: ProductName, user_id: PositiveInt, - workspace_id: WorkspaceID | None, - folder_id: FolderID | None = None, + # hierarchy filters + workspace_query: WorkspaceQuery, + folder_query: FolderQuery, # attribute filters - search: str | None = None, filter_by_project_type: ProjectType | None = None, filter_by_services: list[dict] | None = None, filter_published: bool | None = False, filter_hidden: bool | None = False, filter_trashed: bool | None = False, + filter_by_text: str | None = None, + filter_tag_ids_list: list[int] | None = None, # pagination offset: int | None = 0, limit: int | None = None, @@ -373,156 +374,9 @@ async def list_projects( # pylint: disable=too-many-arguments field=IDStr("last_change_date"), direction=OrderDirection.DESC ), ) -> tuple[list[dict[str, Any]], list[ProjectType], int]: - """ - If workspace_id is provided, then listing in workspace is considered/preffered - """ - assert ( - order_by.field in projects.columns - ), "Guaranteed by ProjectListWithJsonStrParams" # nosec + if filter_tag_ids_list is None: + filter_tag_ids_list = [] - # helper - private_workspace_user_id_or_none: UserID | None = ( - None if workspace_id else user_id - ) - - async with self.engine.acquire() as conn: - - _join_query = ( - projects.join(projects_to_products, isouter=True) - .join(self.access_rights_subquery, isouter=True) - .join( - projects_to_folders, - ( - (projects_to_folders.c.project_uuid == projects.c.uuid) - & ( - projects_to_folders.c.user_id - == private_workspace_user_id_or_none - ) - ), - isouter=True, - ) - ) - - query = ( - sa.select( - *[ - col - for col in projects.columns - if col.name not in ["access_rights"] - ], - self.access_rights_subquery.c.access_rights, - projects_to_products.c.product_name, - projects_to_folders.c.folder_id, - ) - .select_from(_join_query) - .where( - ( - (projects_to_products.c.product_name == product_name) - # This was added for backward compatibility, including old projects not in the projects_to_products table. - | (projects_to_products.c.product_name.is_(None)) - ) - & ( - projects_to_folders.c.folder_id == folder_id - if folder_id - else projects_to_folders.c.folder_id.is_(None) - ) - & ( - projects.c.workspace_id == workspace_id # <-- Shared workspace - if workspace_id - else projects.c.workspace_id.is_(None) # <-- Private workspace - ) - ) - ) - - # attributes filters - # None, true, false = all, attribute, !attribute - attributes_filters = [] - if filter_by_project_type is not None: - attributes_filters.append( - projects.c.type == filter_by_project_type.value - ) - - if filter_hidden is not None: - attributes_filters.append(projects.c.hidden.is_(filter_hidden)) - - if filter_published is not None: - attributes_filters.append(projects.c.published.is_(filter_published)) - - if filter_trashed is not None: - attributes_filters.append( - # marked explicitly as trashed - ( - projects.c.trashed_at.is_not(None) - & projects.c.trashed_explicitly.is_(True) - ) - if filter_trashed - # not marked as trashed - else projects.c.trashed_at.is_(None) - ) - query = query.where(sa.and_(*attributes_filters)) - - if private_workspace_user_id_or_none: - # If Private workspace we check to which projects user has access - user_groups: list[RowProxy] = await self._list_user_groups( - conn, user_id - ) - query = query.where( - (projects.c.prj_owner == user_id) - | sa.text( - f"jsonb_exists_any(access_rights_subquery.access_rights, {assemble_array_groups(user_groups)})" - ) - ) - - if search: - query = query.join( - users, users.c.id == projects.c.prj_owner, isouter=True - ) - query = query.where( - (projects.c.name.ilike(f"%{search}%")) - | (projects.c.description.ilike(f"%{search}%")) - | (projects.c.uuid.ilike(f"%{search}%")) - | (users.c.name.ilike(f"%{search}%")) - ) - - if order_by.direction == OrderDirection.ASC: - query = query.order_by(sa.asc(getattr(projects.c, order_by.field))) - else: - query = query.order_by(sa.desc(getattr(projects.c, order_by.field))) - - # page meta - total_number_of_projects = await conn.scalar( - query.with_only_columns(func.count()).order_by(None) - ) - assert total_number_of_projects is not None # nosec - - # page data - prjs, prj_types = await self._execute_without_permission_check( - conn, - user_id=user_id, - select_projects_query=query.offset(offset).limit(limit), - filter_by_services=filter_by_services, - ) - - return ( - prjs, - prj_types, - total_number_of_projects, - ) - - async def list_projects_full_search( - self, - *, - user_id: PositiveInt, - product_name: ProductName, - filter_by_services: list[dict] | None = None, - text: str | None = None, - offset: int | None = 0, - limit: int | None = None, - tag_ids_list: list[int], - order_by: OrderBy = OrderBy( - field=IDStr("last_change_date"), direction=OrderDirection.DESC - ), - ) -> tuple[list[dict[str, Any]], list[ProjectType], int]: async with self.engine.acquire() as conn: user_groups: list[RowProxy] = await self._list_user_groups(conn, user_id) @@ -552,124 +406,212 @@ async def list_projects_full_search( ).group_by(projects_tags.c.project_id) ).subquery("project_tags_subquery") - private_workspace_query = ( - sa.select( - *[ - col - for col in projects.columns - if col.name not in ["access_rights"] - ], - self.access_rights_subquery.c.access_rights, - projects_to_products.c.product_name, - projects_to_folders.c.folder_id, - sa.func.coalesce( - project_tags_subquery.c.tags, - sa.cast(sa.text("'{}'"), sa.ARRAY(sa.Integer)), - ).label("tags"), + ### + # Private workspace query + ### + + if workspace_query.workspace_scope is not WorkspaceScope.SHARED: + assert workspace_query.workspace_scope in ( # nosec + WorkspaceScope.PRIVATE, + WorkspaceScope.ALL, ) - .select_from( - projects.join(self.access_rights_subquery, isouter=True) - .join(projects_to_products) - .join( - projects_to_folders, + + private_workspace_query = ( + sa.select( + *[ + col + for col in projects.columns + if col.name not in ["access_rights"] + ], + self.access_rights_subquery.c.access_rights, + projects_to_products.c.product_name, + projects_to_folders.c.folder_id, + sa.func.coalesce( + project_tags_subquery.c.tags, + sa.cast(sa.text("'{}'"), sa.ARRAY(sa.Integer)), + ).label("tags"), + ) + .select_from( + projects.join(self.access_rights_subquery, isouter=True) + .join(projects_to_products) + .join( + projects_to_folders, + ( + (projects_to_folders.c.project_uuid == projects.c.uuid) + & (projects_to_folders.c.user_id == user_id) + ), + isouter=True, + ) + .join(project_tags_subquery, isouter=True) + ) + .where( ( - (projects_to_folders.c.project_uuid == projects.c.uuid) - & (projects_to_folders.c.user_id == user_id) - ), - isouter=True, + (projects.c.prj_owner == user_id) + | sa.text( + f"jsonb_exists_any(access_rights_subquery.access_rights, {assemble_array_groups(user_groups)})" + ) + ) + & (projects.c.workspace_id.is_(None)) # <-- Private workspace + & (projects_to_products.c.product_name == product_name) ) - .join(project_tags_subquery, isouter=True) ) - .where( - ( - (projects.c.prj_owner == user_id) - | sa.text( - f"jsonb_exists_any(access_rights_subquery.access_rights, {assemble_array_groups(user_groups)})" + if filter_by_text is not None: + private_workspace_query = private_workspace_query.join( + users, users.c.id == projects.c.prj_owner, isouter=True + ) + else: + private_workspace_query = None + + ### + # Shared workspace query + ### + + if workspace_query.workspace_scope is not WorkspaceScope.PRIVATE: + assert workspace_query.workspace_scope in ( + WorkspaceScope.SHARED, + WorkspaceScope.ALL, + ) # nosec + + shared_workspace_query = ( + sa.select( + *[ + col + for col in projects.columns + if col.name not in ["access_rights"] + ], + workspace_access_rights_subquery.c.access_rights, + projects_to_products.c.product_name, + projects_to_folders.c.folder_id, + sa.func.coalesce( + project_tags_subquery.c.tags, + sa.cast(sa.text("'{}'"), sa.ARRAY(sa.Integer)), + ).label("tags"), + ) + .select_from( + projects.join( + workspace_access_rights_subquery, + projects.c.workspace_id + == workspace_access_rights_subquery.c.workspace_id, ) + .join(projects_to_products) + .join( + projects_to_folders, + ( + (projects_to_folders.c.project_uuid == projects.c.uuid) + & (projects_to_folders.c.user_id.is_(None)) + ), + isouter=True, + ) + .join(project_tags_subquery, isouter=True) ) - & (projects.c.workspace_id.is_(None)) - & (projects_to_products.c.product_name == product_name) - & (projects.c.hidden.is_(False)) - & (projects.c.type == ProjectType.STANDARD) - & ( - (projects.c.name.ilike(f"%{text}%")) - | (projects.c.description.ilike(f"%{text}%")) - | (projects.c.uuid.ilike(f"%{text}%")) + .where( + ( + sa.text( + f"jsonb_exists_any(workspace_access_rights_subquery.access_rights, {assemble_array_groups(user_groups)})" + ) + ) + & (projects_to_products.c.product_name == product_name) ) ) - ) - - if tag_ids_list: - private_workspace_query = private_workspace_query.where( - sa.func.coalesce( - project_tags_subquery.c.tags, - sa.cast(sa.text("'{}'"), sa.ARRAY(sa.Integer)), - ).op("@>")(tag_ids_list) - ) + if workspace_query.workspace_scope == WorkspaceScope.ALL: + shared_workspace_query = shared_workspace_query.where( + projects.c.workspace_id.is_not( + None + ) # <-- All shared workspaces + ) + if filter_by_text is not None: + shared_workspace_query = shared_workspace_query.join( + users, users.c.id == projects.c.prj_owner, isouter=True + ) - shared_workspace_query = ( - sa.select( - *[ - col - for col in projects.columns - if col.name not in ["access_rights"] - ], - workspace_access_rights_subquery.c.access_rights, - projects_to_products.c.product_name, - projects_to_folders.c.folder_id, - sa.func.coalesce( - project_tags_subquery.c.tags, - sa.cast(sa.text("'{}'"), sa.ARRAY(sa.Integer)), - ).label("tags"), - ) - .select_from( - projects.join( - workspace_access_rights_subquery, + else: + assert ( + workspace_query.workspace_scope == WorkspaceScope.SHARED + ) # nosec + shared_workspace_query = shared_workspace_query.where( projects.c.workspace_id - == workspace_access_rights_subquery.c.workspace_id, - ) - .join(projects_to_products) - .join( - projects_to_folders, - ( - (projects_to_folders.c.project_uuid == projects.c.uuid) - & (projects_to_folders.c.user_id.is_(None)) - ), - isouter=True, + == workspace_query.workspace_id # <-- Specific shared workspace ) - .join(project_tags_subquery, isouter=True) + + else: + shared_workspace_query = None + + ### + # Attributes Filters + ### + + attributes_filters: list[ColumnElement] = [] + if filter_by_project_type is not None: + attributes_filters.append( + projects.c.type == filter_by_project_type.value ) - .where( + + if filter_hidden is not None: + attributes_filters.append(projects.c.hidden.is_(filter_hidden)) + + if filter_published is not None: + attributes_filters.append(projects.c.published.is_(filter_published)) + + if filter_trashed is not None: + attributes_filters.append( + # marked explicitly as trashed ( - sa.text( - f"jsonb_exists_any(workspace_access_rights_subquery.access_rights, {assemble_array_groups(user_groups)})" - ) - ) - & (projects.c.workspace_id.is_not(None)) - & (projects_to_products.c.product_name == product_name) - & (projects.c.hidden.is_(False)) - & (projects.c.type == ProjectType.STANDARD) - & ( - (projects.c.name.ilike(f"%{text}%")) - | (projects.c.description.ilike(f"%{text}%")) - | (projects.c.uuid.ilike(f"%{text}%")) + projects.c.trashed_at.is_not(None) + & projects.c.trashed_explicitly.is_(True) ) + if filter_trashed + # not marked as trashed + else projects.c.trashed_at.is_(None) ) - ) - - if tag_ids_list: - shared_workspace_query = shared_workspace_query.where( + if filter_by_text is not None: + attributes_filters.append( + (projects.c.name.ilike(f"%{filter_by_text}%")) + | (projects.c.description.ilike(f"%{filter_by_text}%")) + | (projects.c.uuid.ilike(f"%{filter_by_text}%")) + | (users.c.name.ilike(f"%{filter_by_text}%")) + ) + if filter_tag_ids_list: + attributes_filters.append( sa.func.coalesce( project_tags_subquery.c.tags, sa.cast(sa.text("'{}'"), sa.ARRAY(sa.Integer)), - ).op("@>")(tag_ids_list) + ).op("@>")(filter_tag_ids_list) + ) + 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) ) - combined_query = sa.union_all( - private_workspace_query, shared_workspace_query - ) - - count_query = sa.select(func.count()).select_from(combined_query) + if combined_query is None: + msg = f"No valid queries were provided to combine. Workspace scope: {workspace_query.workspace_scope}" + raise ValueError(msg) + count_query = sa.select(func.count()).select_from(combined_query.subquery()) total_count = await conn.scalar(count_query) if order_by.direction == OrderDirection.ASC: diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py b/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py index 8904cead4bf4..3cda68047977 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py @@ -364,9 +364,14 @@ async def test_list_projects_with_innaccessible_services( data, *_ = await _list_and_assert_projects( client, expected, headers=s4l_product_headers ) - assert len(data) == 2 + # UPDATE (use-case 4): 11.11.2024 - This test was checking backwards compatibility for listing + # projects that were not in the projects_to_products table. After refactoring the project listing, + # we no longer support this. MD double-checked the last_modified_timestamp on projects + # that do not have any product assigned (all of them were before 01-11-2022 with the exception of two + # `4b001ad2-8450-11ec-b105-02420a0b02c7` and `d952cbf4-d838-11ec-af92-02420a0bdad4` which were added to osparc product). + assert len(data) == 0 data, *_ = await _list_and_assert_projects(client, expected) - assert len(data) == 2 + assert len(data) == 0 @pytest.mark.parametrize(