diff --git a/services/api-server/src/simcore_service_api_server/services_http/webserver.py b/services/api-server/src/simcore_service_api_server/services_http/webserver.py index a66bd7ff3d3e..3f9d93c7455c 100644 --- a/services/api-server/src/simcore_service_api_server/services_http/webserver.py +++ b/services/api-server/src/simcore_service_api_server/services_http/webserver.py @@ -1,5 +1,6 @@ # pylint: disable=too-many-public-methods +import json import logging import urllib.parse from collections.abc import Mapping @@ -185,14 +186,16 @@ async def _page_projects( limit: int, offset: int, show_hidden: bool, - search: str | None = None, + search_by_project_name: str | None = None, ) -> Page[ProjectGet]: assert 1 <= limit <= MAXIMUM_NUMBER_OF_ITEMS_PER_PAGE # nosec assert offset >= 0 # nosec optional: dict[str, Any] = {} - if search is not None: - optional["search"] = search + if search_by_project_name is not None: + filters_dict = {"search_by_project_name": search_by_project_name} + filters_json = json.dumps(filters_dict) + optional["filters"] = filters_json with service_exception_handler( service_name="Webserver", @@ -353,9 +356,7 @@ async def get_projects_w_solver_page( limit=limit, offset=offset, show_hidden=True, - # WARNING: better way to match jobs with projects (Next PR if this works fine!) - # WARNING: search text has a limit that I needed to increase for the example! - search=solver_name, + search_by_project_name=solver_name, ) async def get_projects_page(self, *, limit: int, offset: int): 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 9055442a4533..2c96a8d4cc06 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 @@ -60,12 +60,14 @@ async def list_projects( # pylint: disable=too-many-arguments project_type: ProjectTypeAPI, show_hidden: bool, trashed: bool | None, + # search + search_by_multi_columns: str | None = None, + search_by_project_name: str | None = None, # pagination offset: NonNegativeInt, limit: int, + # ordering order_by: OrderBy, - # search - search: str | None, ) -> tuple[list[ProjectDict], int]: app = request.app db = ProjectDBAPI.get_from_app_context(app) @@ -116,7 +118,8 @@ async def list_projects( # pylint: disable=too-many-arguments filter_trashed=trashed, filter_hidden=show_hidden, # composed attrs - filter_by_text=search, + search_by_multi_columns=search_by_multi_columns, + search_by_project_name=search_by_project_name, # pagination offset=offset, limit=limit, @@ -154,7 +157,8 @@ async def list_projects_full_depth( limit: int, order_by: OrderBy, # search - text: str | None, + search_by_multi_columns: str | None, + search_by_project_name: str | None, ) -> tuple[list[ProjectDict], int]: db = ProjectDBAPI.get_from_app_context(request.app) @@ -169,9 +173,10 @@ async def list_projects_full_depth( 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, filter_by_project_type=ProjectType.STANDARD, + search_by_multi_columns=search_by_multi_columns, + search_by_project_name=search_by_project_name, offset=offset, limit=limit, order_by=order_by, 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 a46c61c8b73e..57c0876fea6e 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 @@ -203,12 +203,13 @@ async def list_projects(request: web.Request): project_type=query_params.project_type, show_hidden=query_params.show_hidden, trashed=query_params.filters.trashed, - limit=query_params.limit, - offset=query_params.offset, - search=query_params.search, - order_by=OrderBy.model_construct(**query_params.order_by.model_dump()), folder_id=query_params.folder_id, workspace_id=query_params.workspace_id, + search_by_multi_columns=query_params.search, + search_by_project_name=query_params.filters.search_by_project_name, + offset=query_params.offset, + limit=query_params.limit, + order_by=OrderBy.model_construct(**query_params.order_by.model_dump()), ) page = Page[ProjectDict].model_validate( @@ -246,10 +247,11 @@ async def list_projects_full_search(request: web.Request): product_name=req_ctx.product_name, trashed=query_params.filters.trashed, tag_ids_list=tag_ids_list, + search_by_multi_columns=query_params.text, + search_by_project_name=query_params.filters.search_by_project_name, offset=query_params.offset, limit=query_params.limit, order_by=OrderBy.model_construct(**query_params.order_by.model_dump()), - 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 a10f66778acb..188b3cc960b5 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 @@ -98,6 +98,10 @@ class ProjectFilters(Filters): default=False, description="Set to true to list trashed, false to list non-trashed (default), None to list all", ) + search_by_project_name: str | None = Field( + default=None, + description="A search query to filter projects by their name. This field performs a case-insensitive partial match against the project name field.", + ) ProjectsListOrderParams = create_ordering_query_model_class( 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 8ca96d3a7c59..995f699466c9 100644 --- a/services/web/server/src/simcore_service_webserver/projects/db.py +++ b/services/web/server/src/simcore_service_webserver/projects/db.py @@ -380,8 +380,10 @@ async def list_projects( # pylint: disable=too-many-arguments,too-many-statemen filter_published: bool | None = None, filter_hidden: bool | None = False, filter_trashed: bool | None = False, - filter_by_text: str | None = None, filter_tag_ids_list: list[int] | None = None, + # search + search_by_multi_columns: str | None = None, + search_by_project_name: str | None = None, # pagination offset: int | None = 0, limit: int | None = None, @@ -472,7 +474,7 @@ async def list_projects( # pylint: disable=too-many-arguments,too-many-statemen & (projects_to_products.c.product_name == product_name) ) ) - if filter_by_text is not None: + if search_by_multi_columns is not None: private_workspace_query = private_workspace_query.join( users, users.c.id == projects.c.prj_owner, isouter=True ) @@ -546,7 +548,7 @@ async def list_projects( # pylint: disable=too-many-arguments,too-many-statemen == workspace_query.workspace_id # <-- Specific shared workspace ) - if filter_by_text is not None: + if search_by_multi_columns 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 @@ -582,12 +584,16 @@ async def list_projects( # pylint: disable=too-many-arguments,too-many-statemen # not marked as trashed else projects.c.trashed.is_(None) ) - if filter_by_text is not None: + if search_by_multi_columns 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}%")) + (projects.c.name.ilike(f"%{search_by_multi_columns}%")) + | (projects.c.description.ilike(f"%{search_by_multi_columns}%")) + | (projects.c.uuid.ilike(f"%{search_by_multi_columns}%")) + | (users.c.name.ilike(f"%{search_by_multi_columns}%")) + ) + if search_by_project_name is not None: + attributes_filters.append( + projects.c.name.like(f"%{search_by_project_name}%") ) if filter_tag_ids_list: attributes_filters.append( diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers__list_with_query_params.py b/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers__list_with_query_params.py index 16610b9c774c..8324aff33a2d 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers__list_with_query_params.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers__list_with_query_params.py @@ -184,6 +184,27 @@ async def test_list_projects_with_search_parameter( data, 1, 0, 1, "/v0/projects?search=nAmE+5&offset=0&limit=20", 1 ) + # Now we will test specific project name search (used by the API server) + query_parameters = {"filters": '{"search_by_project_name": "Yoda"}'} + url = base_url.with_query(**query_parameters) + assert ( + f"{url}" + == f"/{api_version_prefix}/projects?filters=%7B%22search_by_project_name%22:+%22Yoda%22%7D" + ) + + resp = await client.get(f"{url}") + data = await resp.json() + + assert resp.status == 200 + _assert_response_data( + data, + 1, + 0, + 1, + "/v0/projects?filters=%7B%22search_by_project_name%22:+%22Yoda%22%7D&offset=0&limit=20", + 1, + ) + # Now we will test part of uuid search query_parameters = {"search": "2-fe1b-11ed-b038-cdb1"} url = base_url.with_query(**query_parameters)