diff --git a/packages/models-library/src/models_library/workspaces.py b/packages/models-library/src/models_library/workspaces.py index db4ff387404..f6f33061bfe 100644 --- a/packages/models-library/src/models_library/workspaces.py +++ b/packages/models-library/src/models_library/workspaces.py @@ -36,6 +36,7 @@ def validate_workspace_id(cls, value, info: ValidationInfo): if scope == WorkspaceScope.SHARED and value is None: msg = f"workspace_id must be provided when workspace_scope is SHARED. Got {scope=}, {value=}" raise ValueError(msg) + if scope != WorkspaceScope.SHARED and value is not None: msg = f"workspace_id should be None when workspace_scope is not SHARED. Got {scope=}, {value=}" raise ValueError(msg) 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 ca1c7b5be67..70e29c830d5 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 @@ -3964,6 +3964,16 @@ paths: summary: List Projects Full Search operationId: list_projects_full_search parameters: + - name: filters + in: query + required: false + schema: + anyOf: + - type: string + contentMediaType: application/json + contentSchema: {} + - type: 'null' + title: Filters - name: order_by in: query required: false 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 2ef9818f431..6cd65316b05 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 @@ -196,8 +196,9 @@ async def list_folders( ) -async def list_folders_full_search( +async def list_folders_full_depth( app: web.Application, + *, user_id: UserID, product_name: ProductName, text: str | None, 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 becf8af0054..e1dea38ecae 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 @@ -112,7 +112,7 @@ async def list_folders_full_search(request: web.Request): if not query_params.filters: query_params.filters = FolderFilters() - folders: FolderGetPage = await _folders_api.list_folders_full_search( + folders: FolderGetPage = await _folders_api.list_folders_full_depth( app=request.app, user_id=req_ctx.user_id, product_name=req_ctx.product_name, 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 a18157242ad..55a9b7c6429 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 @@ -144,16 +144,20 @@ async def list_projects( # pylint: disable=too-many-arguments return projects, total_number_projects -async def list_projects_full_search( +async def list_projects_full_depth( request, *, user_id: UserID, product_name: str, + # attrs filter + trashed: bool | None, + tag_ids_list: list[int], + # pagination offset: NonNegativeInt, limit: int, - text: str | None, order_by: OrderBy, - tag_ids_list: list[int], + # search + text: str | None, ) -> tuple[list[ProjectDict], int]: db = ProjectDBAPI.get_from_app_context(request.app) @@ -161,11 +165,12 @@ 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( + 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_trashed=trashed, filter_by_services=user_available_services, filter_by_text=text, filter_tag_ids_list=tag_ids_list, diff --git a/services/web/server/src/simcore_service_webserver/projects/_crud_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_crud_handlers.py index 91f43f8a94c..b730358320b 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_crud_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_crud_handlers.py @@ -232,17 +232,21 @@ async def list_projects_full_search(request: web.Request): query_params: ProjectsSearchQueryParams = parse_request_query_parameters_as( ProjectsSearchQueryParams, request ) + if not query_params.filters: + query_params.filters = ProjectFilters() + tag_ids_list = query_params.tag_ids_list() - projects, total_number_of_projects = await _crud_api_read.list_projects_full_search( + projects, total_number_of_projects = await _crud_api_read.list_projects_full_depth( request, user_id=req_ctx.user_id, product_name=req_ctx.product_name, - limit=query_params.limit, + trashed=query_params.filters.trashed, + tag_ids_list=tag_ids_list, offset=query_params.offset, - text=query_params.text, + limit=query_params.limit, order_by=OrderBy.model_construct(**query_params.order_by.model_dump()), - tag_ids_list=tag_ids_list, + text=query_params.text, ) page = Page[ProjectDict].model_validate( diff --git a/services/web/server/src/simcore_service_webserver/projects/_crud_handlers_models.py b/services/web/server/src/simcore_service_webserver/projects/_crud_handlers_models.py index 5d10df1ffe1..a10f66778ac 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_crud_handlers_models.py +++ b/services/web/server/src/simcore_service_webserver/projects/_crud_handlers_models.py @@ -163,7 +163,10 @@ class ProjectActiveQueryParams(BaseModel): client_session_id: str -class ProjectSearchExtraQueryParams(PageQueryParameters): +class ProjectSearchExtraQueryParams( + PageQueryParameters, + FiltersQueryParameters[ProjectFilters], +): text: str | None = Field( default=None, description="Multi column full text search, across all folders and workspaces", 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 b0fc7c5551a..7890537a984 100644 --- a/services/web/server/src/simcore_service_webserver/projects/db.py +++ b/services/web/server/src/simcore_service_webserver/projects/db.py @@ -7,7 +7,7 @@ import logging from contextlib import AsyncExitStack -from typing import Any, cast +from typing import Any, Self, cast from uuid import uuid1 import sqlalchemy as sa @@ -43,6 +43,7 @@ from simcore_postgres_database.models.projects_tags import projects_tags from simcore_postgres_database.models.projects_to_folders import projects_to_folders from simcore_postgres_database.models.projects_to_products import projects_to_products +from simcore_postgres_database.models.projects_to_wallet import projects_to_wallet from simcore_postgres_database.models.wallets import wallets from simcore_postgres_database.models.workspaces_access_rights import ( workspaces_access_rights, @@ -64,7 +65,6 @@ from tenacity.asyncio import AsyncRetrying from tenacity.retry import retry_if_exception_type -from ..db.models import projects_tags, projects_to_wallet from ..utils import now_str from ._comments_db import ( create_project_comment, @@ -104,6 +104,10 @@ APP_PROJECT_DBAPI = __name__ + ".ProjectDBAPI" ANY_USER = ANY_USER_ID_SENTINEL +DEFAULT_ORDER_BY = OrderBy( + field=IDStr("last_change_date"), direction=OrderDirection.DESC +) + # pylint: disable=too-many-public-methods # NOTE: https://github.com/ITISFoundation/osparc-simcore/issues/3516 @@ -121,16 +125,16 @@ def _init_engine(self) -> None: raise ValueError(msg) @classmethod - def get_from_app_context(cls, app: web.Application) -> "ProjectDBAPI": - db: "ProjectDBAPI" = app[APP_PROJECT_DBAPI] + def get_from_app_context(cls, app: web.Application) -> Self: + db = app[APP_PROJECT_DBAPI] + assert isinstance(db, cls) # nosec return db @classmethod - def set_once_in_app_context(cls, app: web.Application) -> "ProjectDBAPI": + def set_once_in_app_context(cls, app: web.Application) -> Self: if app.get(APP_PROJECT_DBAPI) is None: - app[APP_PROJECT_DBAPI] = ProjectDBAPI(app) - db: ProjectDBAPI = app[APP_PROJECT_DBAPI] - return db + app[APP_PROJECT_DBAPI] = cls(app) + return cls.get_from_app_context(app) @property def engine(self) -> Engine: @@ -374,10 +378,9 @@ async def list_projects( # pylint: disable=too-many-arguments,too-many-statemen offset: int | None = 0, limit: int | None = None, # order - order_by: OrderBy = OrderBy( - field=IDStr("last_change_date"), direction=OrderDirection.DESC - ), + order_by: OrderBy = DEFAULT_ORDER_BY, ) -> tuple[list[dict[str, Any]], list[ProjectType], int]: + if filter_tag_ids_list is None: filter_tag_ids_list = [] @@ -473,10 +476,11 @@ async def list_projects( # pylint: disable=too-many-arguments,too-many-statemen ### if workspace_query.workspace_scope is not WorkspaceScope.PRIVATE: - assert workspace_query.workspace_scope in ( + + assert workspace_query.workspace_scope in ( # nosec WorkspaceScope.SHARED, WorkspaceScope.ALL, - ) # nosec + ) shared_workspace_query = ( sa.select( @@ -525,20 +529,21 @@ async def list_projects( # pylint: disable=too-many-arguments,too-many-statemen 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 - ) - else: - assert ( + assert ( # nosec workspace_query.workspace_scope == WorkspaceScope.SHARED - ) # nosec + ) shared_workspace_query = shared_workspace_query.where( projects.c.workspace_id == workspace_query.workspace_id # <-- Specific shared workspace ) + if filter_by_text is not None: + # NOTE: fields searched with text include user's email + shared_workspace_query = shared_workspace_query.join( + users, users.c.id == projects.c.prj_owner, isouter=True + ) + else: shared_workspace_query = None diff --git a/services/web/server/tests/unit/with_dbs/03/test_trash.py b/services/web/server/tests/unit/with_dbs/03/test_trash.py index f582747ef95..9080eb74fd8 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_trash.py +++ b/services/web/server/tests/unit/with_dbs/03/test_trash.py @@ -498,3 +498,190 @@ async def test_trash_empty_workspace( page = Page[WorkspaceGet].model_validate(await resp.json()) assert page.meta.total == 0 + + +async def test_trash_subfolder( + client: TestClient, + logged_user: UserInfoDict, + user_project: ProjectDict, + mocked_catalog: None, + mocked_dynamic_services_interface: dict[str, MagicMock], +): + assert client.app + + # setup -------------------------------- + # + # - /Folder + # - /SubFolder + + # CREATE a folder + resp = await client.post("/v0/folders", json={"name": "Folder"}) + data, _ = await assert_status(resp, status.HTTP_201_CREATED) + folder = FolderGet.model_validate(data) + + # CREATE a SUB-folder + resp = await client.post( + "/v0/folders", + json={"name": "SubFolder1", "parentFolderId": folder.folder_id}, + ) + data, _ = await assert_status(resp, status.HTTP_201_CREATED) + subfolder = FolderGet.model_validate(data) + + # ------------------------------------- + + # TRASH subfolder + resp = await client.post(f"/v0/folders/{subfolder.folder_id}:trash") + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # LIST BIN (i.e. use full-depth search) + url = client.app.router["list_folders_full_search"].url_for() + assert f"{url}" == "/v0/folders:search" + + resp = await client.get( + "/v0/folders:search", params={"filters": '{"trashed": true}'} + ) + await assert_status(resp, status.HTTP_200_OK) + page = Page[FolderGet].model_validate(await resp.json()) + assert page.meta.total == 1 + assert page.data[0].folder_id == subfolder.folder_id + + # LIST (NOT full-depth) + resp = await client.get( + "/v0/folders", + params={"filters": '{"trashed": true}'}, + ) + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(data) == 0 + + resp = await client.get( + "/v0/folders", + params={"filters": '{"trashed": true}', "folder_id": f"{folder.folder_id}"}, + ) + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(data) == 1 + assert data[0]["folderId"] == subfolder.folder_id + + # UNTRASH + resp = await client.post(f"/v0/folders/{subfolder.folder_id}:untrash") + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # check not in the bin + resp = await client.get( + "/v0/folders:search", params={"filters": '{"trashed": true}'} + ) + await assert_status(resp, status.HTTP_200_OK) + page = Page[FolderGet].model_validate(await resp.json()) + assert page.meta.total == 0 + + # check "back in place" + resp = await client.get( + "/v0/folders:search", params={"filters": '{"trashed": false}'} + ) + await assert_status(resp, status.HTTP_200_OK) + page = Page[FolderGet].model_validate(await resp.json()) + assert page.meta.total == 2 + + resp = await client.get( + "/v0/folders", + params={"filters": '{"trashed": false}', "folder_id": f"{folder.folder_id}"}, + ) + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(data) == 1 + assert data[0]["folderId"] == subfolder.folder_id + + expected = data + resp = await client.get( + "/v0/folders", + params={"folder_id": f"{folder.folder_id}"}, + ) + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert data == expected + + +async def test_trash_project_in_subfolder( + client: TestClient, + logged_user: UserInfoDict, + user_project: ProjectDict, + mocked_catalog: None, + mocked_dynamic_services_interface: dict[str, MagicMock], +): + assert client.app + + # setup -------------------------------- + # + # - /Folder + # - /SubFolder + # - user_project <-- NOTE: this is a project! + # + + # CREATE a folder + resp = await client.post("/v0/folders", json={"name": "Folder"}) + data, _ = await assert_status(resp, status.HTTP_201_CREATED) + folder = FolderGet.model_validate(data) + + # CREATE a SUB-folder + resp = await client.post( + "/v0/folders", + json={"name": "SubFolder1", "parentFolderId": folder.folder_id}, + ) + data, _ = await assert_status(resp, status.HTTP_201_CREATED) + subfolder = FolderGet.model_validate(data) + + # MOVE project to SUB-folder + project_uuid = UUID(user_project["uuid"]) + resp = await client.put( + f"/v0/projects/{project_uuid}/folders/{subfolder.folder_id}" + ) + await assert_status(resp, status.HTTP_204_NO_CONTENT) + # ------------------------------------- + + # TRASH project + resp = await client.post(f"/v0/projects/{project_uuid}:trash") + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # LIST BIN (i.e. use full-depth search) + url = client.app.router["list_projects_full_search"].url_for() + assert f"{url}" == "/v0/projects:search" + + resp = await client.get( + "/v0/projects:search", params={"filters": '{"trashed": true}'} + ) + await assert_status(resp, status.HTTP_200_OK) + page = Page[ProjectGet].model_validate(await resp.json()) + assert page.meta.total == 1 + assert page.data[0].folder_id == subfolder.folder_id + + # LIST (NOT full-depth) + resp = await client.get( + "/v0/projects", + params={"filters": '{"trashed": true}'}, + ) + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(data) == 0 + + resp = await client.get( + "/v0/projects", + params={"filters": '{"trashed": true}', "folder_id": f"{subfolder.folder_id}"}, + ) + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(data) == 1 + assert data[0]["uuid"] == f"{project_uuid}" + + # UNTRASH + resp = await client.post(f"/v0/projects/{project_uuid}:untrash") + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + resp = await client.get( + "/v0/projects:search", params={"filters": '{"trashed": true}'} + ) + await assert_status(resp, status.HTTP_200_OK) + page = Page[ProjectGet].model_validate(await resp.json()) + assert page.meta.total == 0 + + resp = await client.get( + "/v0/projects:search", params={"filters": '{"trashed": false}'} + ) + await assert_status(resp, status.HTTP_200_OK) + page = Page[ProjectGet].model_validate(await resp.json()) + assert page.meta.total == 1 + assert page.data[0].uuid == project_uuid