diff --git a/api/specs/web-server/_projects_crud.py b/api/specs/web-server/_projects_crud.py index 1b6030cc061b..1c76ff4230d7 100644 --- a/api/specs/web-server/_projects_crud.py +++ b/api/specs/web-server/_projects_crud.py @@ -32,7 +32,10 @@ from simcore_service_webserver._meta import API_VTAG from simcore_service_webserver.projects._common_models import ProjectPathParams from simcore_service_webserver.projects._crud_handlers import ProjectCreateParams -from simcore_service_webserver.projects._crud_handlers_models import ProjectListParams +from simcore_service_webserver.projects._crud_handlers_models import ( + ProjectListFullSearchParams, + ProjectListParams, +) router = APIRouter( prefix=f"/{API_VTAG}", @@ -137,6 +140,16 @@ async def clone_project( ... +@router.get( + "/projects:search", + response_model=Page[ProjectListItem], +) +async def list_projects_full_search( + _params: Annotated[ProjectListFullSearchParams, Depends()], +): + ... + + @router.get( "/projects/{project_id}/inactivity", response_model=Envelope[GetProjectInactivityResponse], 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 8af414d41fa8..90aeff0748b6 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 @@ -3291,6 +3291,45 @@ paths: application/json: schema: $ref: '#/components/schemas/Envelope_TaskGet_' + /v0/projects:search: + get: + tags: + - projects + summary: List Projects Full Search + operationId: list_projects_full_search + parameters: + - 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 + - required: false + schema: + title: Text + maxLength: 100 + type: string + name: text + in: query + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Page_ProjectListItem_' /v0/projects/{project_id}/inactivity: get: tags: @@ -9148,18 +9187,6 @@ components: title: Userid type: string description: the user that started the service - example: - published_port: 30000 - entrypoint: /the/entry/point/is/here - service_uuid: 3fa85f64-5717-4562-b3fc-2c963f66afa6 - service_key: simcore/services/comp/itis/sleeper - service_version: 1.2.3 - service_host: jupyter_E1O2E-LAH - service_port: 8081 - service_basepath: /x/E1O2E-LAH - service_state: pending - service_message: no suitable node (insufficient resources on 1 node) - user_id: 123 NodeGetIdle: title: NodeGetIdle required: @@ -11601,13 +11628,14 @@ components: ServiceState: title: ServiceState enum: + - failed - pending - pulling - starting - running - - complete - - failed - stopping + - complete + - idle description: An enumeration. ServiceType: title: ServiceType 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 7efd1dd600c8..b3f2f928329a 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 @@ -5,7 +5,6 @@ """ - from aiohttp import web from models_library.access_rights import AccessRights from models_library.api_schemas_webserver._base import OutputSchema @@ -143,6 +142,28 @@ async def list_projects( # pylint: disable=too-many-arguments return projects, total_number_projects +async def list_projects_full_search( + app, + *, + user_id: UserID, + product_name: str, + offset: NonNegativeInt, + limit: int, + text: str | None, +) -> tuple[list[ProjectDict], int]: + db = ProjectDBAPI.get_from_app_context(app) + + total_number_projects, db_projects = await db.list_projects_full_search( + user_id=user_id, + product_name=product_name, + text=text, + offset=offset, + limit=limit, + ) + + return db_projects, total_number_projects + + async def get_project( request: web.Request, user_id: UserID, 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 969658a05817..b56a1764f7d4 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 @@ -58,6 +58,7 @@ ProjectActiveParams, ProjectCreateHeaders, ProjectCreateParams, + ProjectListFullSearchParams, ProjectListWithJsonStrParams, ) from ._permalink_api import update_or_pop_permalink_in_project @@ -226,6 +227,40 @@ async def list_projects(request: web.Request): ) +@routes.get(f"/{VTAG}/projects:search", name="list_projects_full_search") +@login_required +@permission_required("project.read") +@_handle_projects_exceptions +async def list_projects_full_search(request: web.Request): + req_ctx = RequestContext.parse_obj(request) + query_params: ProjectListFullSearchParams = parse_request_query_parameters_as( + ProjectListFullSearchParams, request + ) + + projects, total_number_of_projects = await _crud_api_read.list_projects_full_search( + request.app, + user_id=req_ctx.user_id, + product_name=req_ctx.product_name, + limit=query_params.limit, + offset=query_params.offset, + text=query_params.text, + ) + + page = Page[ProjectDict].parse_obj( + paginate_data( + chunk=projects, + request_url=request.url, + total=total_number_of_projects, + limit=query_params.limit, + offset=query_params.offset, + ) + ) + return web.Response( + text=page.json(**RESPONSE_MODEL_POLICY), + content_type=MIMETYPE_APPLICATION_JSON, + ) + + # # - Get https://google.aip.dev/131 # - Get active project: Singleton per-session resources https://google.aip.dev/156 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 6e89c6eb7c8c..967ca8fe0907 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 @@ -12,7 +12,10 @@ from models_library.projects_nodes_io import NodeID from models_library.rest_ordering import OrderBy, OrderDirection from models_library.rest_pagination import PageQueryParameters -from models_library.utils.common_validators import null_or_none_str_to_none_validator +from models_library.utils.common_validators import ( + empty_str_to_none_pre_validator, + null_or_none_str_to_none_validator, +) from models_library.workspaces import WorkspaceID from pydantic import BaseModel, Extra, Field, Json, root_validator, validator from servicelib.common_headers import ( @@ -150,3 +153,16 @@ class Config: class ProjectActiveParams(BaseModel): client_session_id: str + + +class ProjectListFullSearchParams(PageQueryParameters): + text: str | None = Field( + default=None, + description="Multi column full text search, across all folders and workspaces", + max_length=100, + example="My Project", + ) + + _empty_is_none = validator("text", allow_reuse=True, pre=True)( + empty_str_to_none_pre_validator + ) 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 89f07163890c..0e8b8c242faf 100644 --- a/services/web/server/src/simcore_service_webserver/projects/db.py +++ b/services/web/server/src/simcore_service_webserver/projects/db.py @@ -43,6 +43,9 @@ 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.wallets import wallets +from simcore_postgres_database.models.workspaces_access_rights import ( + workspaces_access_rights, +) from simcore_postgres_database.utils_groups_extra_properties import ( GroupExtraPropertiesRepo, ) @@ -325,6 +328,27 @@ async def upsert_project_linked_product( .on_conflict_do_nothing() ) + access_rights_subquery = ( + sa.select( + project_to_groups.c.project_uuid, + sa.func.jsonb_object_agg( + project_to_groups.c.gid, + sa.func.jsonb_build_object( + "read", + project_to_groups.c.read, + "write", + project_to_groups.c.write, + "delete", + project_to_groups.c.delete, + ), + ) + .filter( + project_to_groups.c.read # Filters out entries where "read" is False + ) + .label("access_rights"), + ).group_by(project_to_groups.c.project_uuid) + ).subquery("access_rights_subquery") + async def list_projects( # pylint: disable=too-many-arguments self, *, @@ -357,30 +381,9 @@ async def list_projects( # pylint: disable=too-many-arguments async with self.engine.acquire() as conn: - access_rights_subquery = ( - sa.select( - project_to_groups.c.project_uuid, - sa.func.jsonb_object_agg( - project_to_groups.c.gid, - sa.func.jsonb_build_object( - "read", - project_to_groups.c.read, - "write", - project_to_groups.c.write, - "delete", - project_to_groups.c.delete, - ), - ) - .filter( - project_to_groups.c.read # Filters out entries where "read" is False - ) - .label("access_rights"), - ).group_by(project_to_groups.c.project_uuid) - ).subquery("access_rights_subquery") - _join_query = ( projects.join(projects_to_products, isouter=True) - .join(access_rights_subquery, isouter=True) + .join(self.access_rights_subquery, isouter=True) .join( projects_to_folders, ( @@ -401,7 +404,7 @@ async def list_projects( # pylint: disable=too-many-arguments for col in projects.columns if col.name not in ["access_rights"] ], - access_rights_subquery.c.access_rights, + self.access_rights_subquery.c.access_rights, projects_to_products.c.product_name, projects_to_folders.c.folder_id, ) @@ -486,6 +489,149 @@ async def list_projects( # pylint: disable=too-many-arguments total_number_of_projects, ) + async def list_projects_full_search( + self, + *, + user_id: PositiveInt, + product_name: ProductName, + text: str | None = None, + offset: int | None = 0, + limit: int | None = None, + ) -> tuple[int, list[ProjectDict]]: + async with self.engine.acquire() as conn: + user_groups: list[RowProxy] = await self._list_user_groups(conn, user_id) + + 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") + + 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, + ) + .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, + ) + ) + .where( + ( + (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)) + & (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}%")) + ) + ) + ) + + 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, + ) + .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, + ) + ) + .where( + ( + 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}%")) + ) + ) + ) + + combined_query = sa.union_all( + private_workspace_query, shared_workspace_query + ) + + count_query = sa.select(func.count()).select_from(combined_query) + total_count = await conn.scalar(count_query) + + list_query = combined_query.offset(offset).limit(limit) + result = await conn.execute(list_query) + rows = await result.fetchall() or [] + results: list[UserSpecificProjectDataDB] = [ + UserSpecificProjectDataDB.from_orm(row) for row in rows + ] + + # NOTE: Additional adjustments to make it back compatible + list_of_converted_projects: list[ProjectDict] = [] + for project in results: + user_email = await self._get_user_email(conn, project.prj_owner) + converted_project = convert_to_schema_names(project.dict(), user_email) + converted_project["tags"] = [] # <-- Tags not needed for now + converted_project["state"] = None + list_of_converted_projects.append(converted_project) + + return cast(int, total_count), list_of_converted_projects + async def list_projects_uuids(self, user_id: int) -> list[str]: async with self.engine.acquire() as conn: return [ diff --git a/services/web/server/tests/unit/with_dbs/03/workspaces/test_workspaces__list_projects_full_search.py b/services/web/server/tests/unit/with_dbs/03/workspaces/test_workspaces__list_projects_full_search.py new file mode 100644 index 000000000000..1340630e4898 --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/03/workspaces/test_workspaces__list_projects_full_search.py @@ -0,0 +1,162 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments +# pylint: disable=too-many-statements + + +from copy import deepcopy +from http import HTTPStatus + +import pytest +from aiohttp.test_utils import TestClient +from pytest_mock import MockerFixture +from pytest_simcore.helpers.assert_checks import assert_status +from pytest_simcore.helpers.webserver_login import UserInfoDict +from pytest_simcore.helpers.webserver_projects import create_project +from servicelib.aiohttp import status +from simcore_service_webserver.db.models import UserRole +from simcore_service_webserver.projects.models import ProjectDict + + +@pytest.fixture +def mock_catalog_api_get_services_for_user_in_product(mocker: MockerFixture): + mocker.patch( + "simcore_service_webserver.projects._crud_api_read.get_services_for_user_in_product", + spec=True, + return_value=[], + ) + mocker.patch( + "simcore_service_webserver.projects._crud_handlers.get_services_for_user_in_product", + spec=True, + return_value=[], + ) + mocker.patch( + "simcore_service_webserver.projects._crud_handlers.project_uses_available_services", + spec=True, + return_value=True, + ) + + +_SEARCH_NAME_1 = "Quantum Solutions" +_SEARCH_NAME_2 = "Orion solution" +_SEARCH_NAME_3 = "Skyline solutions" + + +@pytest.mark.parametrize("user_role,expected", [(UserRole.USER, status.HTTP_200_OK)]) +async def test_workspaces__list_projects_full_search( + client: TestClient, + logged_user: UserInfoDict, + user_project: ProjectDict, + expected: HTTPStatus, + mock_catalog_api_get_services_for_user_in_product: MockerFixture, + fake_project: ProjectDict, + workspaces_clean_db: None, +): + assert client.app + + # 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 project in shared workspace + project_data = deepcopy(fake_project) + project_data["workspace_id"] = f"{added_workspace['workspaceId']}" + project_data["name"] = _SEARCH_NAME_1 + project_1 = await create_project( + client.app, + project_data, + user_id=logged_user["id"], + product_name="osparc", + ) + + # List project with full search + base_url = client.app.router["list_projects_full_search"].url_for() + url = base_url.with_query({"text": "solution"}) + resp = await client.get(url) + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(data) == 1 + assert data[0]["uuid"] == project_1["uuid"] + assert data[0]["workspaceId"] == added_workspace["workspaceId"] + assert data[0]["folderId"] is None + + # Create projects in private workspace + project_data = deepcopy(fake_project) + project_data["name"] = _SEARCH_NAME_2 + project_2 = await create_project( + client.app, + project_data, + user_id=logged_user["id"], + product_name="osparc", + ) + + # List project with full search + base_url = client.app.router["list_projects_full_search"].url_for() + url = base_url.with_query({"text": "Orion"}) + resp = await client.get(url) + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(data) == 1 + assert data[0]["uuid"] == project_2["uuid"] + assert data[0]["workspaceId"] is None + assert data[0]["folderId"] is None + + # Create projects in private workspace and move it to a folder + project_data = deepcopy(fake_project) + project_data["description"] = _SEARCH_NAME_3 + project_3 = await create_project( + client.app, + project_data, + user_id=logged_user["id"], + product_name="osparc", + ) + + # 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) + + # add project to the folder + url = client.app.router["replace_project_folder"].url_for( + folder_id=f"{root_folder['folderId']}", + project_id=f"{project_3['uuid']}", + ) + resp = await client.put(url.path) + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # List project with full search + base_url = client.app.router["list_projects_full_search"].url_for() + url = base_url.with_query({"text": "Skyline"}) + resp = await client.get(url) + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(data) == 1 + assert data[0]["uuid"] == project_3["uuid"] + assert data[0]["workspaceId"] is None + assert data[0]["folderId"] == root_folder["folderId"] + + # List project with full search (it should return data across all workspaces/folders) + base_url = client.app.router["list_projects_full_search"].url_for() + url = base_url.with_query({"text": "solution"}) + resp = await client.get(url) + data, _ = await assert_status(resp, status.HTTP_200_OK) + sorted_data = sorted(data, key=lambda x: x["uuid"]) + assert len(sorted_data) == 3 + + assert sorted_data[0]["uuid"] == project_1["uuid"] + assert sorted_data[0]["workspaceId"] == added_workspace["workspaceId"] + assert sorted_data[0]["folderId"] is None + + assert sorted_data[1]["uuid"] == project_2["uuid"] + assert sorted_data[1]["workspaceId"] is None + assert sorted_data[1]["folderId"] is None + + assert sorted_data[2]["uuid"] == project_3["uuid"] + assert sorted_data[2]["workspaceId"] is None + assert sorted_data[2]["folderId"] == root_folder["folderId"]