diff --git a/services/static-webserver/client/source/class/osparc/data/Resources.js b/services/static-webserver/client/source/class/osparc/data/Resources.js index 43bfa27e892..e4b1567da1c 100644 --- a/services/static-webserver/client/source/class/osparc/data/Resources.js +++ b/services/static-webserver/client/source/class/osparc/data/Resources.js @@ -142,12 +142,12 @@ qx.Class.define("osparc.data.Resources", { getPageSearch: { useCache: false, method: "GET", - url: statics.API + "/projects:search?offset={offset}&limit={limit}&text={text}&tag_ids={tagIds}&order_by={orderBy}" + url: statics.API + "/projects:search?offset={offset}&limit={limit}&text={text}&tag_ids={tagIds}&order_by={orderBy}&type=user" }, getPageTrashed: { useCache: false, method: "GET", - url: statics.API + "/projects:search?filters={%22trashed%22:%22true%22}&offset={offset}&limit={limit}&order_by={orderBy}" + url: statics.API + "/projects:search?filters={%22trashed%22:%22true%22}&offset={offset}&limit={limit}&order_by={orderBy}&type=user" }, postToTemplate: { method: "POST", 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 52ad898b1d2..23dbe53f649 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 @@ -4608,6 +4608,20 @@ paths: type: integer default: 0 title: Offset + - name: type + in: query + required: false + schema: + $ref: '#/components/schemas/ProjectTypeAPI' + default: all + - name: template_type + in: query + required: false + schema: + anyOf: + - $ref: '#/components/schemas/ProjectTemplateType' + - type: 'null' + title: Template Type - name: text in: query required: false diff --git a/services/web/server/src/simcore_service_webserver/projects/_controller/projects_rest.py b/services/web/server/src/simcore_service_webserver/projects/_controller/projects_rest.py index 277d235dbb1..479cb96a269 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_controller/projects_rest.py +++ b/services/web/server/src/simcore_service_webserver/projects/_controller/projects_rest.py @@ -187,6 +187,8 @@ async def list_projects_full_search(request: web.Request): user_id=req_ctx.user_id, product_name=req_ctx.product_name, trashed=query_params.filters.trashed, + filter_by_project_type=query_params.project_type, + filter_by_template_type=query_params.template_type, search_by_multi_columns=query_params.text, search_by_project_name=query_params.filters.search_by_project_name, offset=query_params.offset, diff --git a/services/web/server/src/simcore_service_webserver/projects/_controller/projects_rest_schemas.py b/services/web/server/src/simcore_service_webserver/projects/_controller/projects_rest_schemas.py index 4b7bbff3237..4695e8b602d 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_controller/projects_rest_schemas.py +++ b/services/web/server/src/simcore_service_webserver/projects/_controller/projects_rest_schemas.py @@ -207,6 +207,8 @@ class ProjectSearchExtraQueryParams( PageQueryParameters, FiltersQueryParameters[ProjectFilters], ): + project_type: Annotated[ProjectTypeAPI, Field(alias="type")] = ProjectTypeAPI.all + template_type: ProjectTemplateType | None = None text: Annotated[ str | None, Field( @@ -227,6 +229,20 @@ class ProjectSearchExtraQueryParams( empty_str_to_none_pre_validator ) + _template_type_null_or_none_str_to_none_validator = field_validator( + "template_type", mode="before" + )(null_or_none_str_to_none_validator) + + @model_validator(mode="after") + def _check_template_type_compatibility(self): + if ( + self.project_type in [ProjectTypeAPI.all, ProjectTypeAPI.user] + and self.template_type is not None + ): + msg = f"When {self.project_type=} is `all` or `user` the {self.template_type=} should be None" + raise ValueError(msg) + return self + class ProjectsSearchQueryParams( ProjectSearchExtraQueryParams, ProjectsListOrderParams # type: ignore[misc, valid-type] 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 a4a533b542c..9ac6efd730f 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 @@ -10,17 +10,15 @@ from aiohttp import web from models_library.folders import FolderID, FolderQuery, FolderScope -from models_library.projects import ProjectTemplateType +from models_library.projects import ProjectTemplateType, ProjectType 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.utils import logged_gather -from simcore_postgres_database.models.projects import ProjectType from simcore_postgres_database.webserver_models import ( ProjectTemplateType as ProjectTemplateTypeDB, ) -from simcore_postgres_database.webserver_models import ProjectType as ProjectTypeDB from ..folders import _folders_repository from ..users.api import get_user_email_legacy @@ -80,7 +78,7 @@ async def _aggregate_data_to_projects_from_other_sources( _projects_service.add_project_states_for_user( user_id=user_id, project=prj, - is_template=prj["type"] == ProjectTypeDB.TEMPLATE.value, + is_template=prj["type"] == ProjectType.TEMPLATE.value, app=app, ) for prj in db_projects @@ -202,13 +200,15 @@ async def list_projects( # pylint: disable=too-many-arguments return final_projects, total_number_projects -async def list_projects_full_depth( +async def list_projects_full_depth( # pylint: disable=too-many-arguments app: web.Application, *, user_id: UserID, product_name: str, # attrs filter trashed: bool | None, + filter_by_project_type: ProjectTypeAPI, + filter_by_template_type: ProjectTemplateType | None, # pagination offset: NonNegativeInt, limit: int, @@ -225,7 +225,14 @@ async def list_projects_full_depth( workspace_query=WorkspaceQuery(workspace_scope=WorkspaceScope.ALL), folder_query=FolderQuery(folder_scope=FolderScope.ALL), filter_trashed=trashed, - filter_by_project_type=ProjectType.STANDARD, + filter_by_project_type=ProjectTypeAPI.to_project_type_db( + filter_by_project_type + ), + filter_by_template_type=( + ProjectTemplateTypeDB(filter_by_template_type) + if filter_by_template_type + else None + ), search_by_multi_columns=search_by_multi_columns, search_by_project_name=search_by_project_name, offset=offset, diff --git a/services/web/server/src/simcore_service_webserver/projects/_trash_service.py b/services/web/server/src/simcore_service_webserver/projects/_trash_service.py index 926345a3a35..cacc98597ff 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_trash_service.py +++ b/services/web/server/src/simcore_service_webserver/projects/_trash_service.py @@ -26,7 +26,7 @@ ProjectRunningConflictError, ProjectsBatchDeleteError, ) -from .models import ProjectDict, ProjectPatchInternalExtended +from .models import ProjectDict, ProjectPatchInternalExtended, ProjectTypeAPI _logger = logging.getLogger(__name__) @@ -172,6 +172,8 @@ async def list_explicitly_trashed_projects( user_id=user_id, product_name=product_name, trashed=True, + filter_by_project_type=ProjectTypeAPI.user, + filter_by_template_type=None, offset=page_params.offset, limit=page_params.limit, order_by=OrderBy(field=IDStr("trashed"), direction=OrderDirection.ASC), diff --git a/services/web/server/tests/unit/with_dbs/03/trash/test_trash_rest.py b/services/web/server/tests/unit/with_dbs/03/trash/test_trash_rest.py index a1690ccbb4d..efdd44ed32a 100644 --- a/services/web/server/tests/unit/with_dbs/03/trash/test_trash_rest.py +++ b/services/web/server/tests/unit/with_dbs/03/trash/test_trash_rest.py @@ -980,7 +980,7 @@ async def test_trash_project_in_subfolder( assert f"{url}" == "/v0/projects:search" resp = await client.get( - "/v0/projects:search", params={"filters": '{"trashed": true}'} + "/v0/projects:search", params={"filters": '{"trashed": true}', "type": "user"} ) await assert_status(resp, status.HTTP_200_OK) page = Page[ProjectGet].model_validate(await resp.json()) @@ -1008,14 +1008,14 @@ async def test_trash_project_in_subfolder( await assert_status(resp, status.HTTP_204_NO_CONTENT) resp = await client.get( - "/v0/projects:search", params={"filters": '{"trashed": true}'} + "/v0/projects:search", params={"filters": '{"trashed": true}', "type": "user"} ) 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}'} + "/v0/projects:search", params={"filters": '{"trashed": false}', "type": "user"} ) await assert_status(resp, status.HTTP_200_OK) page = Page[ProjectGet].model_validate(await resp.json()) @@ -1043,7 +1043,9 @@ async def test_trash_project_explitictly_and_empty_trash_bin( await assert_status(resp, status.HTTP_204_NO_CONTENT) # LIST trashed projects - resp = await client.get("/v0/projects", params={"filters": '{"trashed": true}'}) + resp = await client.get( + "/v0/projects", params={"filters": '{"trashed": true}', "type": "user"} + ) await assert_status(resp, status.HTTP_200_OK) page = Page[ProjectListItem].model_validate(await resp.json()) @@ -1126,7 +1128,7 @@ async def test_trash_folder_with_subfolder_and_project_and_empty_bin( # - LIST trashed projects (will show only explicit!) resp = await client.get( - "/v0/projects:search", params={"filters": '{"trashed": true}'} + "/v0/projects:search", params={"filters": '{"trashed": true}', "type": "user"} ) await assert_status(resp, status.HTTP_200_OK) page = Page[ProjectListItem].model_validate(await resp.json()) @@ -1186,7 +1188,7 @@ async def test_trash_folder_with_subfolder_and_project_and_empty_bin( # - LIST trashed projects (will show only explicit!) resp = await client.get( - "/v0/projects:search", params={"filters": '{"trashed": true}'} + "/v0/projects:search", params={"filters": '{"trashed": true}', "type": "user"} ) await assert_status(resp, status.HTTP_200_OK) page = Page[ProjectListItem].model_validate(await resp.json()) diff --git a/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__list_projects_full_search.py b/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__list_projects_full_search.py index e6ff586bf23..a5915ed978c 100644 --- a/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__list_projects_full_search.py +++ b/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__list_projects_full_search.py @@ -16,6 +16,9 @@ from pytest_simcore.helpers.webserver_users import UserInfoDict from servicelib.aiohttp import status from simcore_service_webserver.db.models import UserRole +from simcore_service_webserver.projects import ( + _projects_repository as projects_service_repository, +) from simcore_service_webserver.projects.models import ProjectDict _SEARCH_NAME_1 = "Quantum Solutions" @@ -215,3 +218,217 @@ async def test__list_projects_full_search_with_query_parameters( # data, _ = await assert_status(resp, status.HTTP_200_OK) # assert len(data) == 1 # assert data[0]["uuid"] == project["uuid"] + + +@pytest.mark.parametrize("user_role", [UserRole.USER]) +async def test__list_projects_full_search_with_type_filter( + client: TestClient, + logged_user: UserInfoDict, + user_project: ProjectDict, + fake_project: ProjectDict, + workspaces_clean_db: None, +): + """Test the list_projects_full_search endpoint with type query parameter.""" + assert client.app + + # Create a regular user project + user_project_data = deepcopy(fake_project) + user_project_data["name"] = "User Project Test" + user_project_created = await create_project( + client.app, + user_project_data, + user_id=logged_user["id"], + product_name="osparc", + ) + + # Create a template project + template_project_data = deepcopy(fake_project) + template_project_data["name"] = "Template Project Test" + template_project_created = await create_project( + client.app, + template_project_data, + user_id=logged_user["id"], + product_name="osparc", + as_template=True, + ) + + base_url = client.app.router["list_projects_full_search"].url_for() + + # Test: Filter by type="user" + url = base_url.with_query({"text": "Project Test", "type": "user"}) + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + user_project_uuids = [p["uuid"] for p in data] + assert user_project_created["uuid"] in user_project_uuids + assert template_project_created["uuid"] not in user_project_uuids + + # Test: Filter by type="template" + url = base_url.with_query({"text": "Project Test", "type": "template"}) + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + template_project_uuids = [p["uuid"] for p in data] + assert user_project_created["uuid"] not in template_project_uuids + assert template_project_created["uuid"] in template_project_uuids + + # Test: Filter by type="all" + url = base_url.with_query({"text": "Project Test", "type": "all"}) + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + all_project_uuids = [p["uuid"] for p in data] + assert user_project_created["uuid"] in all_project_uuids + assert template_project_created["uuid"] in all_project_uuids + + # Test: Default behavior (no type parameter) + url = base_url.with_query({"text": "Project Test"}) + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + default_project_uuids = [p["uuid"] for p in data] + assert user_project_created["uuid"] in default_project_uuids + assert template_project_created["uuid"] in default_project_uuids + + +@pytest.mark.parametrize("user_role", [UserRole.USER]) +async def test__list_projects_full_search_with_template_type_hypertool_and_tutorial( + client: TestClient, + logged_user: UserInfoDict, + user_project: ProjectDict, + fake_project: ProjectDict, + workspaces_clean_db: None, +): + """Test the list_projects_full_search endpoint with template_type hypertool and tutorial.""" + assert client.app + + # Create a hypertool template project + hypertool_project_data = deepcopy(fake_project) + hypertool_project_data["name"] = "Hypertool Project Test" + hypertool_project_created = await create_project( + client.app, + hypertool_project_data, + user_id=logged_user["id"], + product_name="osparc", + as_template=True, + ) + # Patch the hypertool project to set template_type to "HYPERTOOL" + await projects_service_repository.patch_project( + client.app, + project_uuid=hypertool_project_created["uuid"], + new_partial_project_data={"template_type": "HYPERTOOL"}, + ) + # Create a tutorial template project + tutorial_project_data = deepcopy(fake_project) + tutorial_project_data["name"] = "Tutorial Project Test" + tutorial_project_created = await create_project( + client.app, + tutorial_project_data, + user_id=logged_user["id"], + product_name="osparc", + as_template=True, + ) + # Patch the tutorial project to set template_type to "TUTORIAL" + await projects_service_repository.patch_project( + client.app, + project_uuid=tutorial_project_created["uuid"], + new_partial_project_data={"template_type": "TUTORIAL"}, + ) + + base_url = client.app.router["list_projects_full_search"].url_for() + + # Test: Filter by template_type="hypertool" + url = base_url.with_query( + {"text": "Project Test", "type": "template", "template_type": "HYPERTOOL"} + ) + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + hypertool_uuids = [p["uuid"] for p in data] + assert hypertool_project_created["uuid"] in hypertool_uuids + assert tutorial_project_created["uuid"] not in hypertool_uuids + + # Test: Filter by template_type="tutorial" + url = base_url.with_query( + {"text": "Project Test", "type": "template", "template_type": "TUTORIAL"} + ) + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + tutorial_uuids = [p["uuid"] for p in data] + assert hypertool_project_created["uuid"] not in tutorial_uuids + assert tutorial_project_created["uuid"] in tutorial_uuids + + +@pytest.mark.parametrize("user_role", [UserRole.USER]) +async def test__list_projects_full_search_with_template_type_regular_and_none( + client: TestClient, + logged_user: UserInfoDict, + user_project: ProjectDict, + fake_project: ProjectDict, + workspaces_clean_db: None, +): + """Test the list_projects_full_search endpoint with template_type template and None.""" + assert client.app + + # Create a regular user project + user_project_data = deepcopy(fake_project) + user_project_data["name"] = "User Project Test" + user_project_created = await create_project( + client.app, + user_project_data, + user_id=logged_user["id"], + product_name="osparc", + ) + + # Create a regular template project + template_project_data = deepcopy(fake_project) + template_project_data["name"] = "Template Project Test" + template_project_created = await create_project( + client.app, + template_project_data, + user_id=logged_user["id"], + product_name="osparc", + as_template=True, + ) + + # Create a hypertool template project for comparison + hypertool_project_data = deepcopy(fake_project) + hypertool_project_data["name"] = "Hypertool Project Test" + hypertool_project_created = await create_project( + client.app, + hypertool_project_data, + user_id=logged_user["id"], + product_name="osparc", + as_template=True, + ) + # Patch the tutorial project to set template_type to "TUTORIAL" + await projects_service_repository.patch_project( + client.app, + project_uuid=hypertool_project_created["uuid"], + new_partial_project_data={"template_type": "HYPERTOOL"}, + ) + + base_url = client.app.router["list_projects_full_search"].url_for() + + # Test: Filter by template_type="template" --> Default type is "all" + url = base_url.with_query({"text": "Project Test", "template_type": "TEMPLATE"}) + resp = await client.get(f"{url}") + await assert_status(resp, status.HTTP_422_UNPROCESSABLE_ENTITY) + + # Test: Filter by type= template_type="null" + url = base_url.with_query( + {"text": "Project Test", "type": "all", "template_type": "null"} + ) + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + none_template_uuids = [p["uuid"] for p in data] + # NOTE: type "all" takes precedence over template_type "null" (practically is not used) + assert user_project_created["uuid"] in none_template_uuids + assert template_project_created["uuid"] in none_template_uuids + assert hypertool_project_created["uuid"] in none_template_uuids + + # Test: Filter by type="user" & template_type="None" + url = base_url.with_query( + {"text": "Project Test", "type": "user", "template_type": "None"} + ) + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + none_template_uuids = [p["uuid"] for p in data] + assert user_project_created["uuid"] in none_template_uuids + assert template_project_created["uuid"] not in none_template_uuids + assert hypertool_project_created["uuid"] not in none_template_uuids