diff --git a/packages/models-library/src/models_library/api_schemas_webserver/projects.py b/packages/models-library/src/models_library/api_schemas_webserver/projects.py index 449132d6d66..2b15e052944 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/projects.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/projects.py @@ -261,6 +261,7 @@ class ProjectPatch(InputSchema): ), ] = None quality: dict[str, Any] | None = None + template_type: ProjectTemplateType | None = None def to_domain_model(self) -> dict[str, Any]: return self.model_dump(exclude_unset=True, by_alias=False) diff --git a/packages/models-library/src/models_library/projects.py b/packages/models-library/src/models_library/projects.py index 38558e05b1f..1889d5ee714 100644 --- a/packages/models-library/src/models_library/projects.py +++ b/packages/models-library/src/models_library/projects.py @@ -3,7 +3,7 @@ """ from datetime import datetime -from enum import Enum, auto +from enum import Enum from typing import Annotated, Any, Final, TypeAlias from uuid import UUID @@ -54,9 +54,9 @@ class ProjectType(str, Enum): class ProjectTemplateType(StrAutoEnum): - TEMPLATE = auto() - TUTORIAL = auto() - HYPERTOOL = auto() + TEMPLATE = "TEMPLATE" + TUTORIAL = "TUTORIAL" + HYPERTOOL = "HYPERTOOL" class BaseProjectModel(BaseModel): diff --git a/packages/postgres-database/src/simcore_postgres_database/webserver_models.py b/packages/postgres-database/src/simcore_postgres_database/webserver_models.py index b62a2fd83fe..84fe833df19 100644 --- a/packages/postgres-database/src/simcore_postgres_database/webserver_models.py +++ b/packages/postgres-database/src/simcore_postgres_database/webserver_models.py @@ -1,9 +1,10 @@ -""" Facade for webserver service +"""Facade for webserver service - Facade to direct access to models in the database by - the webserver service +Facade to direct access to models in the database by +the webserver service """ + from .models.api_keys import api_keys from .models.classifiers import group_classifiers from .models.comp_pipeline import StateType, comp_pipeline @@ -11,7 +12,7 @@ from .models.confirmations import ConfirmationAction, confirmations from .models.groups import GroupType, groups, user_to_groups from .models.products import products -from .models.projects import ProjectType, projects +from .models.projects import ProjectTemplateType, ProjectType, projects from .models.projects_nodes import projects_nodes from .models.projects_tags import projects_tags from .models.projects_to_wallet import projects_to_wallet @@ -35,6 +36,7 @@ "projects", "projects_nodes", "ProjectType", + "ProjectTemplateType", "scicrunch_resources", "StateType", "projects_tags", 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 6f2673c7bfe..bcda438663d 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 @@ -3962,6 +3962,14 @@ paths: 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: show_hidden in: query required: false @@ -14868,6 +14876,10 @@ components: - type: object - type: 'null' title: Quality + templateType: + anyOf: + - $ref: '#/components/schemas/ProjectTemplateType' + - type: 'null' type: object title: ProjectPatch ProjectPermalink: diff --git a/services/web/server/src/simcore_service_webserver/projects/_controller/_rest_exceptions.py b/services/web/server/src/simcore_service_webserver/projects/_controller/_rest_exceptions.py index 7f29c859eb9..f986af6a1f8 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_controller/_rest_exceptions.py +++ b/services/web/server/src/simcore_service_webserver/projects/_controller/_rest_exceptions.py @@ -27,6 +27,7 @@ from ..exceptions import ( ClustersKeeperNotAvailableError, DefaultPricingUnitNotFoundError, + InsufficientRoleForProjectTemplateTypeUpdateError, NodeNotFoundError, ParentNodeNotFoundError, ProjectDeleteError, @@ -40,6 +41,7 @@ ProjectOwnerNotFoundInTheProjectAccessRightsError, ProjectStartsTooManyDynamicNodesError, ProjectTooManyProjectOpenedError, + ProjectTypeAndTemplateIncompatibilityError, ProjectWalletPendingTransactionError, WrongTagIdsInQueryError, ) @@ -88,6 +90,10 @@ status.HTTP_403_FORBIDDEN, "Do not have sufficient access rights on project {project_uuid} for this action", ), + InsufficientRoleForProjectTemplateTypeUpdateError: HttpErrorInfo( + status.HTTP_403_FORBIDDEN, + "Do not have sufficient access rights on updating project template type", + ), ProjectInvalidUsageError: HttpErrorInfo( status.HTTP_422_UNPROCESSABLE_ENTITY, "Invalid usage for project", @@ -124,6 +130,10 @@ status.HTTP_400_BAD_REQUEST, "Wrong tag IDs in query", ), + ProjectTypeAndTemplateIncompatibilityError: HttpErrorInfo( + status.HTTP_400_BAD_REQUEST, + "Wrong project type and template type combination: {reason}", + ), } 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 86ecd1d2185..a403a96ecf4 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 @@ -153,6 +153,7 @@ async def list_projects(request: web.Request): user_id=req_ctx.user_id, product_name=req_ctx.product_name, project_type=query_params.project_type, + template_type=query_params.template_type, show_hidden=query_params.show_hidden, trashed=query_params.filters.trashed, folder_id=query_params.folder_id, 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 08de1844520..4b7bbff3237 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 @@ -2,7 +2,7 @@ from models_library.basic_types import IDStr from models_library.folders import FolderID -from models_library.projects import ProjectID +from models_library.projects import ProjectID, ProjectTemplateType from models_library.projects_nodes_io import NodeID from models_library.rest_base import RequestParameters from models_library.rest_filters import Filters, FiltersQueryParameters @@ -136,6 +136,7 @@ class ProjectFilters(Filters): class ProjectsListExtraQueryParams(RequestParameters): project_type: Annotated[ProjectTypeAPI, Field(alias="type")] = ProjectTypeAPI.all + template_type: ProjectTemplateType | None = None show_hidden: Annotated[ bool, Field(description="includes projects marked as hidden in the listing") ] = False @@ -167,6 +168,20 @@ def _search_check_empty_string(cls, v): return None return v + _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 + _null_or_none_str_to_none_validator = field_validator("folder_id", mode="before")( null_or_none_str_to_none_validator ) 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 573298fbe71..a751fc1a15c 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,13 +10,16 @@ from aiohttp import web from models_library.folders import FolderID, FolderQuery, FolderScope -from models_library.projects import ProjectID +from models_library.projects import ProjectID, ProjectTemplateType 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 ..catalog import catalog_service @@ -91,6 +94,7 @@ async def list_projects( # pylint: disable=too-many-arguments folder_id: FolderID | None, # attrs filter project_type: ProjectTypeAPI, + template_type: ProjectTemplateType | None, show_hidden: bool, # NOTE: Be careful, this filters only hidden projects trashed: bool | None, # search @@ -148,6 +152,9 @@ async def list_projects( # pylint: disable=too-many-arguments ), # attrs filter_by_project_type=ProjectTypeAPI.to_project_type_db(project_type), + filter_by_template_type=( + ProjectTemplateTypeDB(template_type) if template_type else None + ), filter_by_services=user_available_services, filter_trashed=trashed, filter_hidden=show_hidden, diff --git a/services/web/server/src/simcore_service_webserver/projects/_projects_repository_legacy.py b/services/web/server/src/simcore_service_webserver/projects/_projects_repository_legacy.py index 4c068fd6376..f6df80d866a 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_projects_repository_legacy.py +++ b/services/web/server/src/simcore_service_webserver/projects/_projects_repository_legacy.py @@ -40,7 +40,6 @@ from simcore_postgres_database.aiopg_errors import UniqueViolation from simcore_postgres_database.models.groups import user_to_groups from simcore_postgres_database.models.project_to_groups import project_to_groups -from simcore_postgres_database.models.projects import ProjectTemplateType from simcore_postgres_database.models.projects_nodes import projects_nodes from simcore_postgres_database.models.projects_tags import projects_tags from simcore_postgres_database.models.projects_to_folders import projects_to_folders @@ -58,7 +57,12 @@ ProjectNodeCreate, ProjectNodesRepo, ) -from simcore_postgres_database.webserver_models import ProjectType, projects, users +from simcore_postgres_database.webserver_models import ( + ProjectTemplateType, + ProjectType, + projects, + users, +) from sqlalchemy import func, literal_column, sql from sqlalchemy.dialects.postgresql import BOOLEAN, INTEGER from sqlalchemy.dialects.postgresql import insert as pg_insert @@ -583,6 +587,7 @@ def _create_shared_workspace_query( def _create_attributes_filters( *, filter_by_project_type: ProjectType | None, + filter_by_template_type: ProjectTemplateType | None, filter_hidden: bool | None, filter_published: bool | None, filter_trashed: bool | None, @@ -595,6 +600,11 @@ def _create_attributes_filters( if filter_by_project_type is not None: attributes_filters.append(projects.c.type == filter_by_project_type.value) + if filter_by_template_type is not None: + attributes_filters.append( + projects.c.template_type == filter_by_template_type.value + ) + if filter_hidden is not None: attributes_filters.append(projects.c.hidden.is_(filter_hidden)) @@ -647,6 +657,7 @@ async def list_projects_dicts( # pylint: disable=too-many-arguments,too-many-st folder_query: FolderQuery, # attribute filters filter_by_project_type: ProjectType | None = None, + filter_by_template_type: ProjectTemplateType | None = None, filter_by_services: list[dict] | None = None, filter_published: bool | None = None, filter_hidden: bool | None = False, @@ -694,6 +705,7 @@ async def list_projects_dicts( # pylint: disable=too-many-arguments,too-many-st attributes_filters = self._create_attributes_filters( filter_by_project_type=filter_by_project_type, + filter_by_template_type=filter_by_template_type, filter_hidden=filter_hidden, filter_published=filter_published, filter_trashed=filter_trashed, diff --git a/services/web/server/src/simcore_service_webserver/projects/_projects_service.py b/services/web/server/src/simcore_service_webserver/projects/_projects_service.py index 40ccff58a41..3249048f5f0 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_projects_service.py +++ b/services/web/server/src/simcore_service_webserver/projects/_projects_service.py @@ -139,6 +139,7 @@ from .exceptions import ( ClustersKeeperNotAvailableError, DefaultPricingUnitNotFoundError, + InsufficientRoleForProjectTemplateTypeUpdateError, InvalidEC2TypeInResourcesSpecsError, InvalidKeysInResourcesSpecsError, NodeNotFoundError, @@ -150,6 +151,7 @@ ProjectOwnerNotFoundInTheProjectAccessRightsError, ProjectStartsTooManyDynamicNodesError, ProjectTooManyProjectOpenedError, + ProjectTypeAndTemplateIncompatibilityError, ) from .models import ProjectDict, ProjectPatchInternalExtended from .settings import ProjectsSettings, get_plugin_settings @@ -327,7 +329,27 @@ async def patch_project( if new_prj_access_rights[_prj_owner_primary_group] != _prj_required_permissions: raise ProjectOwnerNotFoundInTheProjectAccessRightsError - # 4. Patch the project + # 4. If patching template type + if new_template_type := patch_project_data.get("template_type"): + # 4.1 Check if user is a tester + current_user: dict = await get_user(app, user_id) + if UserRole(current_user["role"]) < UserRole.TESTER: + raise InsufficientRoleForProjectTemplateTypeUpdateError + # 4.2 Check the compatibility of the template type with the project + if project_db.type == ProjectType.STANDARD and new_template_type is not None: + raise ProjectTypeAndTemplateIncompatibilityError( + project_uuid=project_uuid, + project_type=project_db.type, + project_template=new_template_type, + ) + if project_db.type == ProjectType.TEMPLATE and new_template_type is None: + raise ProjectTypeAndTemplateIncompatibilityError( + project_uuid=project_uuid, + project_type=project_db.type, + project_template=new_template_type, + ) + + # 5. Patch the project await _projects_repository.patch_project( app=app, project_uuid=project_uuid, diff --git a/services/web/server/src/simcore_service_webserver/projects/exceptions.py b/services/web/server/src/simcore_service_webserver/projects/exceptions.py index 8c270f99df5..0b1fe9caaf5 100644 --- a/services/web/server/src/simcore_service_webserver/projects/exceptions.py +++ b/services/web/server/src/simcore_service_webserver/projects/exceptions.py @@ -23,8 +23,7 @@ def debug_message(self): return f"{self.code}: {self}" -class ProjectInvalidUsageError(BaseProjectError): - ... +class ProjectInvalidUsageError(BaseProjectError): ... class ProjectOwnerNotFoundInTheProjectAccessRightsError(BaseProjectError): @@ -83,8 +82,17 @@ class ProjectsBatchDeleteError(BaseProjectError): msg_template = "One or more projects could not be deleted in the batch: {errors}" -class ProjectTrashError(BaseProjectError): - ... +class ProjectsPatchError(BaseProjectError): ... + + +class ProjectTypeAndTemplateIncompatibilityError(ProjectsPatchError): + msg_template = "Patching project '{project_uuid}' type {project_type} and template {project_template} is not allowed" + + +class InsufficientRoleForProjectTemplateTypeUpdateError(ProjectsPatchError): ... + + +class ProjectTrashError(BaseProjectError): ... class ProjectStoppingError(ProjectTrashError): @@ -146,12 +154,10 @@ def __init__(self, *, max_num_projects: int, **ctx): self.max_num_projects = max_num_projects -class PermalinkNotAllowedError(BaseProjectError): - ... +class PermalinkNotAllowedError(BaseProjectError): ... -class PermalinkFactoryError(BaseProjectError): - ... +class PermalinkFactoryError(BaseProjectError): ... class ProjectNodeResourcesInvalidError(BaseProjectError): @@ -178,12 +184,10 @@ class InvalidEC2TypeInResourcesSpecsError(ProjectNodeResourcesInvalidError): ) -class ProjectNodeResourcesInsufficientRightsError(BaseProjectError): - ... +class ProjectNodeResourcesInsufficientRightsError(BaseProjectError): ... -class ProjectNodeRequiredInputsNotSetError(BaseProjectError): - ... +class ProjectNodeRequiredInputsNotSetError(BaseProjectError): ... class ProjectNodeConnectionsMissingError(ProjectNodeRequiredInputsNotSetError): diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_trash_service.py b/services/web/server/src/simcore_service_webserver/workspaces/_trash_service.py index 0628977f4df..a0558863ab1 100644 --- a/services/web/server/src/simcore_service_webserver/workspaces/_trash_service.py +++ b/services/web/server/src/simcore_service_webserver/workspaces/_trash_service.py @@ -104,6 +104,7 @@ async def _list_root_child_projects( show_hidden=False, workspace_id=workspace_id, project_type=ProjectTypeAPI.all, + template_type=None, folder_id=None, trashed=None, offset=page_params.offset, diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_service.py b/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_service.py index 30d02bfdb4f..cbb376e4130 100644 --- a/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_service.py +++ b/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_service.py @@ -110,6 +110,7 @@ async def delete_workspace_with_all_content( show_hidden=False, workspace_id=workspace_id, project_type=ProjectTypeAPI.all, + template_type=None, folder_id=None, trashed=None, offset=page_params.offset, 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 b81d8b99dd3..3e9b7ec0922 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 @@ -16,16 +16,18 @@ import sqlalchemy as sa from aiohttp.test_utils import TestClient from models_library.folders import FolderID -from models_library.projects import ProjectID +from models_library.projects import ProjectID, ProjectTemplateType from models_library.users import UserID from pydantic import BaseModel, PositiveInt 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_parametrizations import ( ExpectedResponse, standard_role_response, ) from pytest_simcore.helpers.webserver_projects import create_project +from servicelib.aiohttp import status from simcore_postgres_database.models.folders_v2 import folders_v2 from simcore_postgres_database.models.projects_to_folders import projects_to_folders from simcore_service_webserver._meta import api_version_prefix @@ -36,7 +38,19 @@ def standard_user_role() -> tuple[str, tuple[UserRole, ExpectedResponse]]: all_roles = standard_role_response() - return (all_roles[0], [pytest.param(*all_roles[1][2], id="standard user role")]) + return (all_roles[0], [pytest.param(*all_roles[1][2], id="standard_user_role")]) + + +def standard_and_tester_user_roles() -> tuple[str, tuple[UserRole, ExpectedResponse]]: + all_roles = standard_role_response() + + return ( + all_roles[0], + [ + pytest.param(*all_roles[1][2], id="standard_user_role"), + pytest.param(*all_roles[1][3], id="tester_user_role"), + ], + ) @pytest.fixture @@ -54,6 +68,7 @@ async def _new_project( product_name: str, tests_data_dir: Path, project_data: dict[str, Any], + as_template: bool = False, ): """returns a project for the given user""" assert client.app @@ -63,6 +78,7 @@ async def _new_project( user_id, product_name=product_name, default_project_json=tests_data_dir / "fake-template-projects.isan.2dplot.json", + as_template=as_template, ) @@ -102,7 +118,7 @@ async def test_list_projects_with_search_parameter( fake_project: ProjectDict, tests_data_dir: Path, osparc_product_name: str, - project_db_cleaner, + project_db_cleaner: None, mock_catalog_api_get_services_for_user_in_product, ): projects_info = [ @@ -157,7 +173,7 @@ async def test_list_projects_with_search_parameter( resp = await client.get(f"{base_url}") data = await resp.json() - assert resp.status == 200 + assert resp.status == status.HTTP_200_OK _assert_response_data(data, 5, 0, 5, "/v0/projects?offset=0&limit=20", 5) # Now we will test with empty search parameter @@ -168,7 +184,7 @@ async def test_list_projects_with_search_parameter( resp = await client.get(f"{url}") data = await resp.json() - assert resp.status == 200 + assert resp.status == status.HTTP_200_OK _assert_response_data(data, 5, 0, 5, "/v0/projects?search=&offset=0&limit=20", 5) # Now we will test upper/lower case search @@ -179,7 +195,7 @@ async def test_list_projects_with_search_parameter( resp = await client.get(f"{url}") data = await resp.json() - assert resp.status == 200 + assert resp.status == status.HTTP_200_OK _assert_response_data( data, 1, 0, 1, "/v0/projects?search=nAmE+5&offset=0&limit=20", 1 ) @@ -195,7 +211,7 @@ async def test_list_projects_with_search_parameter( resp = await client.get(f"{url}") data = await resp.json() - assert resp.status == 200 + assert resp.status == status.HTTP_200_OK _assert_response_data( data, 1, @@ -213,7 +229,7 @@ async def test_list_projects_with_search_parameter( resp = await client.get(f"{url}") data = await resp.json() - assert resp.status == 200 + assert resp.status == status.HTTP_200_OK _assert_response_data( data, 1, 0, 1, "/v0/projects?search=2-fe1b-11ed-b038-cdb1&offset=0&limit=20", 1 ) @@ -232,7 +248,7 @@ async def test_list_projects_with_search_parameter( resp = await client.get(f"{url}") data = await resp.json() - assert resp.status == 200 + assert resp.status == status.HTTP_200_OK _assert_response_data( data, 5, @@ -250,7 +266,7 @@ async def test_list_projects_with_search_parameter( resp = await client.get(f"{url}") data = await resp.json() - assert resp.status == 200 + assert resp.status == status.HTTP_200_OK _assert_response_data(data, 2, 0, 2, "/v0/projects?search=oda&offset=0&limit=20", 2) # Now we will test search that returns nothing @@ -261,7 +277,7 @@ async def test_list_projects_with_search_parameter( resp = await client.get(f"{url}") data = await resp.json() - assert resp.status == 200 + assert resp.status == status.HTTP_200_OK _assert_response_data( data, 0, 0, 0, "/v0/projects?search=does+not+exists&offset=0&limit=20", 0 ) @@ -274,7 +290,7 @@ async def test_list_projects_with_search_parameter( resp = await client.get(f"{url}") data = await resp.json() - assert resp.status == 200 + assert resp.status == status.HTTP_200_OK _assert_response_data(data, 2, 0, 1, "/v0/projects?search=oda&offset=0&limit=1", 1) assert data["_meta"]["limit"] == 1 assert data["_links"]["next"].endswith("/v0/projects?search=oda&offset=1&limit=1") @@ -292,8 +308,8 @@ async def test_list_projects_with_order_by_parameter( fake_project: ProjectDict, tests_data_dir: Path, osparc_product_name: str, - project_db_cleaner, - mock_catalog_api_get_services_for_user_in_product, + project_db_cleaner: None, + mock_catalog_api_get_services_for_user_in_product: None, ): projects_info = [ _ProjectInfo( @@ -351,7 +367,7 @@ async def test_list_projects_with_order_by_parameter( ) resp = await client.get(f"{url}") data = await resp.json() - assert resp.status == 200 + assert resp.status == status.HTTP_200_OK assert [item["uuid"][0] for item in data["data"]] == _alphabetically_ordered_list # Order by uuid descending @@ -361,7 +377,7 @@ async def test_list_projects_with_order_by_parameter( ) resp = await client.get(f"{url}") data = await resp.json() - assert resp.status == 200 + assert resp.status == status.HTTP_200_OK assert [item["uuid"][0] for item in data["data"]] == _alphabetically_ordered_list[ ::-1 ] @@ -373,7 +389,7 @@ async def test_list_projects_with_order_by_parameter( ) resp = await client.get(f"{url}") data = await resp.json() - assert resp.status == 200 + assert resp.status == status.HTTP_200_OK assert [item["name"][0] for item in data["data"]] == _alphabetically_ordered_list # Order by description ascending @@ -383,7 +399,7 @@ async def test_list_projects_with_order_by_parameter( ) resp = await client.get(f"{url}") data = await resp.json() - assert resp.status == 200 + assert resp.status == status.HTTP_200_OK assert [ item["description"][0] for item in data["data"] ] == _alphabetically_ordered_list @@ -432,9 +448,9 @@ async def test_list_projects_for_specific_folder_id( fake_project: ProjectDict, tests_data_dir: Path, osparc_product_name: str, - project_db_cleaner, - mock_catalog_api_get_services_for_user_in_product, - setup_folders_db, + project_db_cleaner: None, + mock_catalog_api_get_services_for_user_in_product: None, + setup_folders_db: FolderID, ): projects_info = [ _ProjectInfo( @@ -478,7 +494,7 @@ async def test_list_projects_for_specific_folder_id( resp = await client.get(f"{base_url}") data = await resp.json() - assert resp.status == 200 + assert resp.status == status.HTTP_200_OK _assert_response_data(data, 3, 0, 3, "/v0/projects?offset=0&limit=20", 3) # Now we will test listing of the root directory with provided folder id query @@ -488,7 +504,7 @@ async def test_list_projects_for_specific_folder_id( resp = await client.get(f"{url}") data = await resp.json() - assert resp.status == 200 + assert resp.status == status.HTTP_200_OK _assert_response_data( data, 3, 0, 3, "/v0/projects?folder_id=null&offset=0&limit=20", 3 ) @@ -501,7 +517,191 @@ async def test_list_projects_for_specific_folder_id( resp = await client.get(f"{url}") data = await resp.json() - assert resp.status == 200 + assert resp.status == status.HTTP_200_OK _assert_response_data( data, 1, 0, 1, f"/v0/projects?folder_id={setup_folders_db}&offset=0&limit=20", 1 ) + + +@pytest.mark.parametrize(*standard_and_tester_user_roles()) +async def test_list_and_patch_projects_with_template_type( + client: TestClient, + logged_user: UserDict, + expected: ExpectedResponse, + fake_project: ProjectDict, + tests_data_dir: Path, + osparc_product_name: str, + project_db_cleaner: None, + mock_catalog_api_get_services_for_user_in_product, +): + projects_type = [ + "STANDARD", + "STANDARD", + "STANDARD", + "TEMPLATE", + "TEMPLATE", + ] + generated_projects = [] + for _type in projects_type: + project_data = deepcopy(fake_project) + prj = await _new_project( + client, + logged_user["id"], + osparc_product_name, + tests_data_dir, + project_data, + as_template=_type == "TEMPLATE", + ) + generated_projects.append(prj) + + base_url = client.app.router["list_projects"].url_for() + # Now we will test listing with type=user + query_parameters = {"type": "user"} + url = base_url.with_query(**query_parameters) + + resp = await client.get(f"{url}") + data = await resp.json() + + assert resp.status == status.HTTP_200_OK + _assert_response_data(data, 3, 0, 3, "/v0/projects?type=user&offset=0&limit=20", 3) + + # Now we will test listing with type=all + query_parameters = {"type": "all"} + url = base_url.with_query(**query_parameters) + + resp = await client.get(f"{url}") + data = await resp.json() + + assert resp.status == status.HTTP_200_OK + _assert_response_data(data, 5, 0, 5, "/v0/projects?type=all&offset=0&limit=20", 5) + + # Now we will test listing with type=template + query_parameters = {"type": "template"} + url = base_url.with_query(**query_parameters) + + resp = await client.get(f"{url}") + data = await resp.json() + + assert resp.status == status.HTTP_200_OK + _assert_response_data( + data, 2, 0, 2, "/v0/projects?type=template&offset=0&limit=20", 2 + ) + + # Now we will test listing with type=user and template_type=null + query_parameters = {"type": "user", "template_type": "null"} + url = base_url.with_query(**query_parameters) + + resp = await client.get(f"{url}") + data = await resp.json() + + assert resp.status == status.HTTP_200_OK + _assert_response_data( + data, 3, 0, 3, "/v0/projects?type=user&template_type=null&offset=0&limit=20", 3 + ) + + # Now we will test listing with incompatible type and template_type + query_parameters = {"type": "user", "template_type": "TEMPLATE"} + url = base_url.with_query(**query_parameters) + resp = await client.get(f"{url}") + data = await resp.json() + assert resp.status == 422 + + # Now we will test listing with type=template and template_type=TEMPLATE + query_parameters = {"type": "template", "template_type": "TEMPLATE"} + url = base_url.with_query(**query_parameters) + + resp = await client.get(f"{url}") + data = await resp.json() + + assert resp.status == status.HTTP_200_OK + _assert_response_data( + data, + 2, + 0, + 2, + "/v0/projects?type=template&template_type=TEMPLATE&offset=0&limit=20", + 2, + ) + + # Lets now PATCH the template project (Currently user is not tester so it should fail) + patch_url = client.app.router["patch_project"].url_for( + project_id=generated_projects[-1]["uuid"] # <-- Patching template project + ) + resp = await client.patch( + f"{patch_url}", + data=json.dumps( + { + "templateType": ProjectTemplateType.HYPERTOOL.value, + } + ), + ) + if UserRole(logged_user["role"]) >= UserRole.TESTER: + await assert_status(resp, status.HTTP_204_NO_CONTENT) + else: + await assert_status(resp, status.HTTP_403_FORBIDDEN) + + if UserRole(logged_user["role"]) >= UserRole.TESTER: + # Now we will test listing with type=user and template_type=null + query_parameters = {"type": "user", "template_type": "null"} + url = base_url.with_query(**query_parameters) + + resp = await client.get(f"{url}") + data = await resp.json() + + assert resp.status == status.HTTP_200_OK + _assert_response_data( + data, + 3, + 0, + 3, + "/v0/projects?type=user&template_type=null&offset=0&limit=20", + 3, + ) + + # Now we will test listing with type=user and template_type=HYPERTOOL + query_parameters = {"type": "template", "template_type": "HYPERTOOL"} + url = base_url.with_query(**query_parameters) + + resp = await client.get(f"{url}") + data = await resp.json() + + assert resp.status == status.HTTP_200_OK + _assert_response_data( + data, + 1, + 0, + 1, + "/v0/projects?type=template&template_type=HYPERTOOL&offset=0&limit=20", + 1, + ) + + # Now we will test listing with type=template and template_type=TEMPLATE + query_parameters = {"type": "template", "template_type": "TEMPLATE"} + url = base_url.with_query(**query_parameters) + + resp = await client.get(f"{url}") + data = await resp.json() + + assert resp.status == status.HTTP_200_OK + _assert_response_data( + data, + 1, + 0, + 1, + "/v0/projects?type=template&template_type=TEMPLATE&offset=0&limit=20", + 1, + ) + + # Lets now PATCH the standard project + patch_url = client.app.router["patch_project"].url_for( + project_id=generated_projects[0]["uuid"] # <-- Patching standard project + ) + resp = await client.patch( + f"{patch_url}", + data=json.dumps( + { + "templateType": ProjectTemplateType.HYPERTOOL.value, + } + ), + ) + await assert_status(resp, status.HTTP_400_BAD_REQUEST)