From 299c1ac3149aad80ed8899a0d350a3164f670e4f Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Fri, 8 Nov 2024 11:43:00 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Trash=20folders=20(#6642)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/specs/web-server/_folders.py | 8 +- api/specs/web-server/_projects_crud.py | 6 +- api/specs/web-server/_trash.py | 41 +++- .../api_schemas_webserver/folders.py | 1 + .../api_schemas_webserver/folders_v2.py | 1 + .../src/models_library/folders.py | 4 + .../src/models_library/projects.py | 1 + ...58751a_project_and_folder_trash_columns.py | 73 ++++++ .../models/folders_v2.py | 16 ++ .../models/projects.py | 13 +- .../helpers/webserver_projects.py | 2 +- services/web/server/VERSION | 2 +- services/web/server/setup.cfg | 2 +- .../api/v0/openapi.yaml | 142 ++++++++--- .../src/simcore_service_webserver/errors.py | 5 +- .../exceptions_handlers.py | 90 +++++++ .../folders/_exceptions_handlers.py | 74 ++++++ .../folders/_folders_api.py | 8 +- .../folders/_folders_db.py | 133 +++++++++-- .../folders/_folders_handlers.py | 126 ++-------- .../folders/_models.py | 85 +++++++ .../folders/_trash_api.py | 177 ++++++++++++++ .../folders/_trash_handlers.py | 66 ++++++ .../folders/plugin.py | 3 +- .../projects/_crud_api_read.py | 4 +- .../projects/_trash_api.py | 8 +- .../projects/_trash_handlers.py | 71 ++---- .../simcore_service_webserver/projects/db.py | 25 +- .../projects/models.py | 4 +- .../workspaces/_workspaces_api.py | 4 +- .../unit/isolated/test_exceptions_handlers.py | 117 ++++++++++ .../02/test_projects_cancellations.py | 3 +- .../02/test_projects_crud_handlers.py | 38 +-- .../02/test_projects_states_handlers.py | 2 +- .../tests/unit/with_dbs/03/test_project_db.py | 1 + .../tests/unit/with_dbs/03/test_trash.py | 221 ++++++++++++++++++ .../unit/with_dbs/04/folders/test_folders.py | 201 +++++++++------- .../test_studies_dispatcher_studies_access.py | 3 +- .../sleepers_project_template_sql.csv | 4 +- 39 files changed, 1421 insertions(+), 364 deletions(-) create mode 100644 packages/postgres-database/src/simcore_postgres_database/migration/versions/5ad02358751a_project_and_folder_trash_columns.py create mode 100644 services/web/server/src/simcore_service_webserver/exceptions_handlers.py create mode 100644 services/web/server/src/simcore_service_webserver/folders/_exceptions_handlers.py create mode 100644 services/web/server/src/simcore_service_webserver/folders/_models.py create mode 100644 services/web/server/src/simcore_service_webserver/folders/_trash_api.py create mode 100644 services/web/server/src/simcore_service_webserver/folders/_trash_handlers.py create mode 100644 services/web/server/tests/unit/isolated/test_exceptions_handlers.py diff --git a/api/specs/web-server/_folders.py b/api/specs/web-server/_folders.py index ee529da655c..90f1ad3beb1 100644 --- a/api/specs/web-server/_folders.py +++ b/api/specs/web-server/_folders.py @@ -21,7 +21,7 @@ from models_library.workspaces import WorkspaceID from pydantic import Json from simcore_service_webserver._meta import API_VTAG -from simcore_service_webserver.folders._folders_handlers import FoldersPathParams +from simcore_service_webserver.folders._models import FolderFilters, FoldersPathParams router = APIRouter( prefix=f"/{API_VTAG}", @@ -30,8 +30,6 @@ ], ) -### Folders - @router.post( "/folders", @@ -57,6 +55,10 @@ async def list_folders( example='{"field": "name", "direction": "desc"}', ), ] = '{"field": "modified_at", "direction": "desc"}', + filters: Annotated[ + Json | None, + Query(description=FolderFilters.schema_json(indent=1)), + ] = None, ): ... diff --git a/api/specs/web-server/_projects_crud.py b/api/specs/web-server/_projects_crud.py index aad8fa82760..4c560464eb8 100644 --- a/api/specs/web-server/_projects_crud.py +++ b/api/specs/web-server/_projects_crud.py @@ -32,6 +32,7 @@ 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 ( + ProjectFilters, ProjectListFullSearchParams, ProjectListParams, ) @@ -83,7 +84,10 @@ async def list_projects( example='{"field": "last_change_date", "direction": "desc"}', ), ] = '{"field": "last_change_date", "direction": "desc"}', - filters: Annotated[Json | None, Query()] = None, + filters: Annotated[ + Json | None, + Query(description=ProjectFilters.schema_json(indent=1)), + ] = None, ): ... diff --git a/api/specs/web-server/_trash.py b/api/specs/web-server/_trash.py index cdde2b8c32f..cdd883f7cf3 100644 --- a/api/specs/web-server/_trash.py +++ b/api/specs/web-server/_trash.py @@ -9,10 +9,14 @@ from fastapi import APIRouter, Depends, status from simcore_service_webserver._meta import API_VTAG -from simcore_service_webserver.projects._trash_handlers import ( - ProjectPathParams, +from simcore_service_webserver.folders._models import ( + FoldersPathParams, RemoveQueryParams, ) +from simcore_service_webserver.projects._trash_handlers import ProjectPathParams +from simcore_service_webserver.projects._trash_handlers import ( + RemoveQueryParams as RemoveQueryParams_duplicated, +) router = APIRouter( prefix=f"/{API_VTAG}", @@ -59,3 +63,36 @@ def untrash_project( _p: Annotated[ProjectPathParams, Depends()], ): ... + + +_extra_tags = ["folders"] + + +@router.post( + "/folders/{folder_id}:trash", + tags=_extra_tags, + status_code=status.HTTP_204_NO_CONTENT, + responses={ + status.HTTP_404_NOT_FOUND: {"description": "Not such a folder"}, + status.HTTP_409_CONFLICT: { + "description": "One or more projects is in use and cannot be trashed" + }, + status.HTTP_503_SERVICE_UNAVAILABLE: {"description": "Trash service error"}, + }, +) +def trash_folder( + _p: Annotated[FoldersPathParams, Depends()], + _q: Annotated[RemoveQueryParams_duplicated, Depends()], +): + ... + + +@router.post( + "/folders/{folder_id}:untrash", + tags=_extra_tags, + status_code=status.HTTP_204_NO_CONTENT, +) +def untrash_folder( + _p: Annotated[FoldersPathParams, Depends()], +): + ... diff --git a/packages/models-library/src/models_library/api_schemas_webserver/folders.py b/packages/models-library/src/models_library/api_schemas_webserver/folders.py index e971b1f8c73..f8a235109a4 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/folders.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/folders.py @@ -18,6 +18,7 @@ class FolderGet(OutputSchema): description: str created_at: datetime modified_at: datetime + trashed_at: datetime | None owner: GroupID my_access_rights: AccessRights access_rights: dict[GroupID, AccessRights] diff --git a/packages/models-library/src/models_library/api_schemas_webserver/folders_v2.py b/packages/models-library/src/models_library/api_schemas_webserver/folders_v2.py index 4398f1377f7..4ba77e0e7c3 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/folders_v2.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/folders_v2.py @@ -18,6 +18,7 @@ class FolderGet(OutputSchema): name: str created_at: datetime modified_at: datetime + trashed_at: datetime | None owner: GroupID workspace_id: WorkspaceID | None my_access_rights: AccessRights diff --git a/packages/models-library/src/models_library/folders.py b/packages/models-library/src/models_library/folders.py index 73262e1e647..4d73618750c 100644 --- a/packages/models-library/src/models_library/folders.py +++ b/packages/models-library/src/models_library/folders.py @@ -29,6 +29,10 @@ class FolderDB(BaseModel): ..., description="Timestamp of last modification", ) + trashed_at: datetime | None = Field( + ..., + ) + user_id: UserID | None workspace_id: WorkspaceID | None diff --git a/packages/models-library/src/models_library/projects.py b/packages/models-library/src/models_library/projects.py index af2d99dc003..d59f9b30ad3 100644 --- a/packages/models-library/src/models_library/projects.py +++ b/packages/models-library/src/models_library/projects.py @@ -190,6 +190,7 @@ class Project(BaseProjectModel): default=None, alias="trashedAt", ) + trashed_explicitly: bool = Field(default=False, alias="trashedExplicitly") class Config: description = "Document that stores metadata, pipeline and UI setup of a study" diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/5ad02358751a_project_and_folder_trash_columns.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/5ad02358751a_project_and_folder_trash_columns.py new file mode 100644 index 00000000000..2cd8adb00f0 --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/5ad02358751a_project_and_folder_trash_columns.py @@ -0,0 +1,73 @@ +"""project and folder trash columns + +Revision ID: 5ad02358751a +Revises: fce5d231e16d +Create Date: 2024-11-07 17:14:01.094583+00:00 + +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "5ad02358751a" +down_revision = "fce5d231e16d" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "folders_v2", + sa.Column( + "trashed_at", + sa.DateTime(timezone=True), + nullable=True, + comment="The date and time when the folder was marked as trashed.Null if the folder has not been trashed [default].", + ), + ) + op.add_column( + "folders_v2", + sa.Column( + "trashed_explicitly", + sa.Boolean(), + server_default=sa.text("false"), + nullable=False, + comment="Indicates whether the folder was explicitly trashed by the user (true) or inherited its trashed status from a parent (false) [default].", + ), + ) + op.add_column( + "projects", + sa.Column( + "trashed_explicitly", + sa.Boolean(), + server_default=sa.text("false"), + nullable=False, + comment="Indicates whether the project was explicitly trashed by the user (true) or inherited its trashed status from a parent (false) [default].", + ), + ) + op.alter_column( + "projects", + "trashed_at", + existing_type=postgresql.TIMESTAMP(timezone=True), + comment="The date and time when the project was marked as trashed. Null if the project has not been trashed [default].", + existing_nullable=True, + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column( + "projects", + "trashed_at", + existing_type=postgresql.TIMESTAMP(timezone=True), + comment=None, + existing_comment="The date and time when the project was marked as trashed. Null if the project has not been trashed [default].", + existing_nullable=True, + ) + op.drop_column("projects", "trashed_explicitly") + op.drop_column("folders_v2", "trashed_explicitly") + op.drop_column("folders_v2", "trashed_at") + # ### end Alembic commands ### diff --git a/packages/postgres-database/src/simcore_postgres_database/models/folders_v2.py b/packages/postgres-database/src/simcore_postgres_database/models/folders_v2.py index b1393bf5367..fcad0ada76c 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/folders_v2.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/folders_v2.py @@ -1,4 +1,5 @@ import sqlalchemy as sa +from sqlalchemy.sql import expression from ._common import column_created_datetime, column_modified_datetime from .base import metadata @@ -74,4 +75,19 @@ ), column_created_datetime(timezone=True), column_modified_datetime(timezone=True), + sa.Column( + "trashed_at", + sa.DateTime(timezone=True), + nullable=True, + comment="The date and time when the folder was marked as trashed." + "Null if the folder has not been trashed [default].", + ), + sa.Column( + "trashed_explicitly", + sa.Boolean, + nullable=False, + server_default=expression.false(), + comment="Indicates whether the folder was explicitly trashed by the user (true)" + " or inherited its trashed status from a parent (false) [default].", + ), ) diff --git a/packages/postgres-database/src/simcore_postgres_database/models/projects.py b/packages/postgres-database/src/simcore_postgres_database/models/projects.py index 629113f06dc..778d2b80eb5 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/projects.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/projects.py @@ -5,7 +5,7 @@ import sqlalchemy as sa from sqlalchemy.dialects.postgresql import ARRAY, JSONB -from sqlalchemy.sql import func +from sqlalchemy.sql import expression, func from .base import metadata @@ -145,7 +145,16 @@ class ProjectType(enum.Enum): "trashed_at", sa.DateTime(timezone=True), nullable=True, - doc="Timestamp indicating when the project was marked as trashed, or null otherwise.", + comment="The date and time when the project was marked as trashed. " + "Null if the project has not been trashed [default].", + ), + sa.Column( + "trashed_explicitly", + sa.Boolean, + nullable=False, + server_default=expression.false(), + comment="Indicates whether the project was explicitly trashed by the user (true)" + " or inherited its trashed status from a parent (false) [default].", ), sa.Column( "workspace_id", diff --git a/packages/pytest-simcore/src/pytest_simcore/helpers/webserver_projects.py b/packages/pytest-simcore/src/pytest_simcore/helpers/webserver_projects.py index b6687e22239..37c9733fd3a 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/webserver_projects.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/webserver_projects.py @@ -186,5 +186,5 @@ async def assert_get_same_project( data, error = await assert_status(resp, expected) if not error: - assert data == project + assert data == {k: project[k] for k in data} return data diff --git a/services/web/server/VERSION b/services/web/server/VERSION index a8ab6c9666a..bcce5d06b8a 100644 --- a/services/web/server/VERSION +++ b/services/web/server/VERSION @@ -1 +1 @@ -0.44.0 +0.45.0 diff --git a/services/web/server/setup.cfg b/services/web/server/setup.cfg index ab412830c97..2b54478220b 100644 --- a/services/web/server/setup.cfg +++ b/services/web/server/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.44.0 +current_version = 0.45.0 commit = True message = services/webserver api version: {current_version} → {new_version} tag = False 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 a49c71acf17..dafb3f8fb08 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 @@ -2,7 +2,7 @@ openapi: 3.0.2 info: title: simcore-service-webserver description: Main service with an interface (http-API & websockets) to the web front-end - version: 0.44.0 + version: 0.45.0 servers: - url: '' description: webserver @@ -2626,6 +2626,27 @@ paths: example: '{"field": "name", "direction": "desc"}' name: order_by in: query + - description: "{\n \"title\": \"FolderFilters\",\n \"description\": \"Encoded\ + \ as JSON. Each available filter can have its own logic (should be well\ + \ documented)\\nInspired by Docker API https://docs.docker.com/engine/api/v1.43/#tag/Container/operation/ContainerList.\"\ + ,\n \"type\": \"object\",\n \"properties\": {\n \"trashed\": {\n \"title\"\ + : \"Trashed\",\n \"description\": \"Set to true to list trashed, false\ + \ to list non-trashed (default), None to list all\",\n \"default\": false,\n\ + \ \"type\": \"boolean\"\n }\n }\n}" + required: false + schema: + title: Filters + type: string + description: "{\n \"title\": \"FolderFilters\",\n \"description\": \"Encoded\ + \ as JSON. Each available filter can have its own logic (should be well\ + \ documented)\\nInspired by Docker API https://docs.docker.com/engine/api/v1.43/#tag/Container/operation/ContainerList.\"\ + ,\n \"type\": \"object\",\n \"properties\": {\n \"trashed\": {\n \"\ + title\": \"Trashed\",\n \"description\": \"Set to true to list trashed,\ + \ false to list non-trashed (default), None to list all\",\n \"default\"\ + : false,\n \"type\": \"boolean\"\n }\n }\n}" + format: json-string + name: filters + in: query - required: false schema: title: Limit @@ -3056,10 +3077,24 @@ paths: example: '{"field": "last_change_date", "direction": "desc"}' name: order_by in: query - - required: false + - description: "{\n \"title\": \"ProjectFilters\",\n \"description\": \"Encoded\ + \ as JSON. Each available filter can have its own logic (should be well\ + \ documented)\\nInspired by Docker API https://docs.docker.com/engine/api/v1.43/#tag/Container/operation/ContainerList.\"\ + ,\n \"type\": \"object\",\n \"properties\": {\n \"trashed\": {\n \"title\"\ + : \"Trashed\",\n \"description\": \"Set to true to list trashed, false\ + \ to list non-trashed (default), None to list all\",\n \"default\": false,\n\ + \ \"type\": \"boolean\"\n }\n }\n}" + required: false schema: title: Filters type: string + description: "{\n \"title\": \"ProjectFilters\",\n \"description\": \"Encoded\ + \ as JSON. Each available filter can have its own logic (should be well\ + \ documented)\\nInspired by Docker API https://docs.docker.com/engine/api/v1.43/#tag/Container/operation/ContainerList.\"\ + ,\n \"type\": \"object\",\n \"properties\": {\n \"trashed\": {\n \"\ + title\": \"Trashed\",\n \"description\": \"Set to true to list trashed,\ + \ false to list non-trashed (default), None to list all\",\n \"default\"\ + : false,\n \"type\": \"boolean\"\n }\n }\n}" format: json-string name: filters in: query @@ -4328,7 +4363,7 @@ paths: '403': description: ProjectInvalidRightsError '404': - description: ProjectNotFoundError, UserDefaultWalletNotFoundError + description: UserDefaultWalletNotFoundError, ProjectNotFoundError '409': description: ProjectTooManyProjectOpenedError '422': @@ -5410,6 +5445,57 @@ paths: responses: '204': description: Successful Response + /v0/folders/{folder_id}:trash: + post: + tags: + - trash + - folders + summary: Trash Folder + operationId: trash_folder + parameters: + - required: true + schema: + title: Folder Id + exclusiveMinimum: true + type: integer + minimum: 0 + name: folder_id + in: path + - required: false + schema: + title: Force + type: boolean + default: false + name: force + in: query + responses: + '204': + description: Successful Response + '404': + description: Not such a folder + '409': + description: One or more projects is in use and cannot be trashed + '503': + description: Trash service error + /v0/folders/{folder_id}:untrash: + post: + tags: + - trash + - folders + summary: Untrash Folder + operationId: untrash_folder + parameters: + - required: true + schema: + title: Folder Id + exclusiveMinimum: true + type: integer + minimum: 0 + name: folder_id + in: path + responses: + '204': + description: Successful Response /v0/repos/projects: get: tags: @@ -8427,6 +8513,10 @@ components: title: Modifiedat type: string format: date-time + trashedAt: + title: Trashedat + type: string + format: date-time owner: title: Owner exclusiveMinimum: true @@ -12583,28 +12673,25 @@ components: type: string - type: string default: UNDEFINED - id: - title: Id - type: string - read: - title: Read - type: boolean resource_id: - title: Resource ID + title: Resource Id anyOf: - enum: - - "" + - '' type: string - type: string - default: "" + default: '' user_from_id: - title: User ID of the one creating it - anyOf: - - enum: - - None - type: integer - - type: integer - default: None + title: User From Id + exclusiveMinimum: true + type: integer + minimum: 0 + id: + title: Id + type: string + read: + title: Read + type: boolean UserNotificationCreate: title: UserNotificationCreate required: @@ -12645,21 +12732,18 @@ components: - type: string default: UNDEFINED resource_id: - title: Resource ID + title: Resource Id anyOf: - enum: - - "" + - '' type: string - type: string - default: "" + default: '' user_from_id: - title: User ID of the one creating it - anyOf: - - enum: - - None - type: integer - - type: integer - default: None + title: User From Id + exclusiveMinimum: true + type: integer + minimum: 0 UserNotificationPatch: title: UserNotificationPatch required: diff --git a/services/web/server/src/simcore_service_webserver/errors.py b/services/web/server/src/simcore_service_webserver/errors.py index 173699f5888..bc041cc5840 100644 --- a/services/web/server/src/simcore_service_webserver/errors.py +++ b/services/web/server/src/simcore_service_webserver/errors.py @@ -1,8 +1,5 @@ -from typing import Any - from models_library.errors_classes import OsparcErrorMixin class WebServerBaseError(OsparcErrorMixin, Exception): - def __init__(self, **ctx: Any) -> None: - super().__init__(**ctx) + msg_template = "Error in web-server service" diff --git a/services/web/server/src/simcore_service_webserver/exceptions_handlers.py b/services/web/server/src/simcore_service_webserver/exceptions_handlers.py new file mode 100644 index 00000000000..7e1ae0bd3e0 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/exceptions_handlers.py @@ -0,0 +1,90 @@ +import functools +import logging +from collections.abc import Iterable +from typing import NamedTuple, TypeAlias + +from aiohttp import web +from servicelib.aiohttp.typing_extension import Handler +from servicelib.aiohttp.web_exceptions_extension import get_http_error_class_or_none +from servicelib.logging_errors import create_troubleshotting_log_kwargs +from servicelib.status_codes_utils import is_5xx_server_error + +_logger = logging.getLogger(__name__) + + +class HttpErrorInfo(NamedTuple): + status_code: int + msg_template: str + + +ExceptionToHttpErrorMap: TypeAlias = dict[type[BaseException], HttpErrorInfo] + + +class _DefaultDict(dict): + def __missing__(self, key): + return f"'{key}=?'" + + +def _sort_exceptions_by_specificity( + exceptions: Iterable[type[BaseException]], *, concrete_first: bool = True +) -> list[type[BaseException]]: + return sorted( + exceptions, + key=lambda exc: sum(issubclass(e, exc) for e in exceptions if e is not exc), + reverse=not concrete_first, + ) + + +def create_exception_handlers_decorator( + exceptions_catch: type[BaseException] | tuple[type[BaseException], ...], + exc_to_status_map: ExceptionToHttpErrorMap, +): + mapped_classes: tuple[type[BaseException], ...] = tuple( + _sort_exceptions_by_specificity(exc_to_status_map.keys()) + ) + + assert all( # nosec + issubclass(cls, exceptions_catch) for cls in mapped_classes + ), f"Every {mapped_classes=} must inherit by one or more of {exceptions_catch=}" + + def _decorator(handler: Handler): + @functools.wraps(handler) + async def _wrapper(request: web.Request) -> web.StreamResponse: + try: + return await handler(request) + + except exceptions_catch as exc: + if exc_cls := next( + (cls for cls in mapped_classes if isinstance(exc, cls)), None + ): + http_error_info = exc_to_status_map[exc_cls] + + # safe formatting, i.e. does not raise + user_msg = http_error_info.msg_template.format_map( + _DefaultDict(getattr(exc, "__dict__", {})) + ) + + http_error_cls = get_http_error_class_or_none( + http_error_info.status_code + ) + assert http_error_cls # nosec + + if is_5xx_server_error(http_error_info.status_code): + _logger.exception( + **create_troubleshotting_log_kwargs( + user_msg, + error=exc, + error_context={ + "request": request, + "request.remote": f"{request.remote}", + "request.method": f"{request.method}", + "request.path": f"{request.path}", + }, + ) + ) + raise http_error_cls(reason=user_msg) from exc + raise # reraise + + return _wrapper + + return _decorator diff --git a/services/web/server/src/simcore_service_webserver/folders/_exceptions_handlers.py b/services/web/server/src/simcore_service_webserver/folders/_exceptions_handlers.py new file mode 100644 index 00000000000..4f83b5e1872 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/folders/_exceptions_handlers.py @@ -0,0 +1,74 @@ +import logging + +from servicelib.aiohttp import status + +from ..exceptions_handlers import ( + ExceptionToHttpErrorMap, + HttpErrorInfo, + create_exception_handlers_decorator, +) +from ..projects.exceptions import ( + BaseProjectError, + ProjectRunningConflictError, + ProjectStoppingError, +) +from ..workspaces.errors import ( + WorkspaceAccessForbiddenError, + WorkspaceFolderInconsistencyError, + WorkspaceNotFoundError, + WorkspacesValueError, +) +from .errors import ( + FolderAccessForbiddenError, + FolderNotFoundError, + FoldersValueError, + FolderValueNotPermittedError, +) + +_logger = logging.getLogger(__name__) + + +_TO_HTTP_ERROR_MAP: ExceptionToHttpErrorMap = { + FolderNotFoundError: HttpErrorInfo( + status.HTTP_404_NOT_FOUND, + "Folder was not found", + ), + WorkspaceNotFoundError: HttpErrorInfo( + status.HTTP_404_NOT_FOUND, + "Workspace was not found", + ), + FolderAccessForbiddenError: HttpErrorInfo( + status.HTTP_403_FORBIDDEN, + "Does not have access to this folder", + ), + WorkspaceAccessForbiddenError: HttpErrorInfo( + status.HTTP_403_FORBIDDEN, + "Does not have access to this workspace", + ), + WorkspaceFolderInconsistencyError: HttpErrorInfo( + status.HTTP_403_FORBIDDEN, + "This folder does not exist in this workspace", + ), + FolderValueNotPermittedError: HttpErrorInfo( + status.HTTP_409_CONFLICT, + "Provided folder value is not permitted: {reason}", + ), + FoldersValueError: HttpErrorInfo( + status.HTTP_409_CONFLICT, + "Invalid folder value set: {reason}", + ), + ProjectRunningConflictError: HttpErrorInfo( + status.HTTP_409_CONFLICT, + "One or more studies in this folder are in use and cannot be trashed. Please stop all services first and try again", + ), + ProjectStoppingError: HttpErrorInfo( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Something went wrong while stopping services before trashing. Aborting trash.", + ), +} + + +handle_plugin_requests_exceptions = create_exception_handlers_decorator( + exceptions_catch=(BaseProjectError, FoldersValueError, WorkspacesValueError), + exc_to_status_map=_TO_HTTP_ERROR_MAP, +) 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 a6de1ce842d..0344124abb6 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 @@ -19,7 +19,7 @@ from ..folders.errors import FolderValueNotPermittedError from ..projects.projects_api import submit_delete_project_task from ..users.api import get_user -from ..workspaces._workspaces_api import check_user_workspace_access +from ..workspaces.api import check_user_workspace_access from ..workspaces.errors import ( WorkspaceAccessForbiddenError, WorkspaceFolderInconsistencyError, @@ -92,6 +92,7 @@ async def create_folder( name=folder_db.name, created_at=folder_db.created, modified_at=folder_db.modified, + trashed_at=folder_db.trashed_at, owner=folder_db.created_by_gid, workspace_id=workspace_id, my_access_rights=user_folder_access_rights, @@ -134,6 +135,7 @@ async def get_folder( name=folder_db.name, created_at=folder_db.created, modified_at=folder_db.modified, + trashed_at=folder_db.trashed_at, owner=folder_db.created_by_gid, workspace_id=folder_db.workspace_id, my_access_rights=user_folder_access_rights, @@ -146,6 +148,7 @@ async def list_folders( product_name: ProductName, folder_id: FolderID | None, workspace_id: WorkspaceID | None, + trashed: bool | None, offset: NonNegativeInt, limit: int, order_by: OrderBy, @@ -180,6 +183,7 @@ async def list_folders( user_id=user_id if workspace_is_private else None, workspace_id=workspace_id, product_name=product_name, + trashed=trashed, offset=offset, limit=limit, order_by=order_by, @@ -192,6 +196,7 @@ async def list_folders( name=folder.name, created_at=folder.created, modified_at=folder.modified, + trashed_at=folder.trashed_at, owner=folder.created_by_gid, workspace_id=folder.workspace_id, my_access_rights=user_folder_access_rights, @@ -268,6 +273,7 @@ async def update_folder( name=folder_db.name, created_at=folder_db.created, modified_at=folder_db.modified, + trashed_at=folder_db.trashed_at, owner=folder_db.created_by_gid, workspace_id=folder_db.workspace_id, my_access_rights=user_folder_access_rights, diff --git a/services/web/server/src/simcore_service_webserver/folders/_folders_db.py b/services/web/server/src/simcore_service_webserver/folders/_folders_db.py index 5c1dcf4d47f..0ee44c17199 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_folders_db.py +++ b/services/web/server/src/simcore_service_webserver/folders/_folders_db.py @@ -5,7 +5,8 @@ """ import logging -from typing import cast +from datetime import datetime +from typing import Any, Final, cast from aiohttp import web from models_library.folders import FolderDB, FolderID @@ -28,6 +29,17 @@ _logger = logging.getLogger(__name__) +class UnSet: + ... + + +_unset: Final = UnSet() + + +def as_dict_exclude_unset(**params) -> dict[str, Any]: + return {k: v for k, v in params.items() if not isinstance(v, UnSet)} + + _SELECTION_ARGS = ( folders_v2.c.folder_id, folders_v2.c.name, @@ -35,6 +47,7 @@ folders_v2.c.created_by_gid, folders_v2.c.created, folders_v2.c.modified, + folders_v2.c.trashed_at, folders_v2.c.user_id, folders_v2.c.workspace_id, ) @@ -80,14 +93,16 @@ async def list_( user_id: UserID | None, workspace_id: WorkspaceID | None, product_name: ProductName, + trashed: bool | None, offset: NonNegativeInt, limit: int, order_by: OrderBy, ) -> tuple[int, list[FolderDB]]: """ content_of_folder_id - Used to filter in which folder we want to list folders. None means root folder. + trashed - If set to true, it returns folders **explicitly** trashed, if false then non-trashed folders. """ - assert not ( + assert not ( # nosec user_id is not None and workspace_id is not None ), "Both user_id and workspace_id cannot be provided at the same time. Please provide only one." @@ -106,6 +121,16 @@ async def list_( assert workspace_id # nosec base_query = base_query.where(folders_v2.c.workspace_id == workspace_id) + if trashed is not None: + base_query = base_query.where( + ( + (folders_v2.c.trashed_at.is_not(None)) + & (folders_v2.c.trashed_explicitly.is_(True)) + ) + if trashed + else folders_v2.c.trashed_at.is_(None) + ) + # Select total count from base_query subquery = base_query.subquery() count_query = select(func.count()).select_from(subquery) @@ -188,34 +213,91 @@ async def get_for_user_or_workspace( return FolderDB.from_orm(row) -async def update( +async def _update_impl( app: web.Application, - *, - folder_id: FolderID, - name: str, - parent_folder_id: FolderID | None, + folders_id_or_ids: FolderID | set[FolderID], product_name: ProductName, + # updatable columns + name: str | UnSet = _unset, + parent_folder_id: FolderID | None | UnSet = _unset, + trashed_at: datetime | None | UnSet = _unset, + trashed_explicitly: bool | UnSet = _unset, ) -> FolderDB: + """ + Batch/single patch of folder/s + """ + # NOTE: exclude unset can also be done using a pydantic model and dict(exclude_unset=True) + updated = as_dict_exclude_unset( + name=name, + parent_folder_id=parent_folder_id, + trashed_at=trashed_at, + trashed_explicitly=trashed_explicitly, + ) + + query = ( + (folders_v2.update().values(modified=func.now(), **updated)) + .where(folders_v2.c.product_name == product_name) + .returning(*_SELECTION_ARGS) + ) + + if isinstance(folders_id_or_ids, set): + # batch-update + query = query.where(folders_v2.c.folder_id.in_(list(folders_id_or_ids))) + else: + # single-update + query = query.where(folders_v2.c.folder_id == folders_id_or_ids) + async with get_database_engine(app).acquire() as conn: - result = await conn.execute( - folders_v2.update() - .values( - name=name, - parent_folder_id=parent_folder_id, - modified=func.now(), - ) - .where( - (folders_v2.c.folder_id == folder_id) - & (folders_v2.c.product_name == product_name) - ) - .returning(*_SELECTION_ARGS) - ) + result = await conn.execute(query) row = await result.first() if row is None: - raise FolderNotFoundError(reason=f"Folder {folder_id} not found.") + raise FolderNotFoundError(reason=f"Folder {folders_id_or_ids} not found.") return FolderDB.from_orm(row) +async def update_batch( + app: web.Application, + *folder_id: FolderID, + product_name: ProductName, + # updatable columns + name: str | UnSet = _unset, + parent_folder_id: FolderID | None | UnSet = _unset, + trashed_at: datetime | None | UnSet = _unset, + trashed_explicitly: bool | UnSet = _unset, +) -> FolderDB: + return await _update_impl( + app=app, + folders_id_or_ids=set(folder_id), + product_name=product_name, + name=name, + parent_folder_id=parent_folder_id, + trashed_at=trashed_at, + trashed_explicitly=trashed_explicitly, + ) + + +async def update( + app: web.Application, + *, + folder_id: FolderID, + product_name: ProductName, + # updatable columns + name: str | UnSet = _unset, + parent_folder_id: FolderID | None | UnSet = _unset, + trashed_at: datetime | None | UnSet = _unset, + trashed_explicitly: bool | UnSet = _unset, +) -> FolderDB: + return await _update_impl( + app=app, + folders_id_or_ids=folder_id, + product_name=product_name, + name=name, + parent_folder_id=parent_folder_id, + trashed_at=trashed_at, + trashed_explicitly=trashed_explicitly, + ) + + async def delete_recursively( app: web.Application, *, @@ -231,6 +313,7 @@ async def delete_recursively( & (folders_v2.c.product_name == product_name) ) folder_hierarchy_cte = base_query.cte(name="folder_hierarchy", recursive=True) + # Step 2: Define the recursive case folder_alias = aliased(folders_v2) recursive_query = select( @@ -241,8 +324,10 @@ async def delete_recursively( folder_alias.c.parent_folder_id == folder_hierarchy_cte.c.folder_id, ) ) + # Step 3: Combine base and recursive cases into a CTE folder_hierarchy_cte = folder_hierarchy_cte.union_all(recursive_query) + # Step 4: Execute the query to get all descendants final_query = select(folder_hierarchy_cte) result = await conn.execute(final_query) @@ -320,8 +405,7 @@ async def get_projects_recursively_only_if_user_is_owner( result = await conn.execute(query) rows = await result.fetchall() or [] - results = [ProjectID(row[0]) for row in rows] - return results + return [ProjectID(row[0]) for row in rows] async def get_folders_recursively( @@ -339,6 +423,7 @@ async def get_folders_recursively( & (folders_v2.c.product_name == product_name) ) folder_hierarchy_cte = base_query.cte(name="folder_hierarchy", recursive=True) + # Step 2: Define the recursive case folder_alias = aliased(folders_v2) recursive_query = select( @@ -349,8 +434,10 @@ async def get_folders_recursively( folder_alias.c.parent_folder_id == folder_hierarchy_cte.c.folder_id, ) ) + # Step 3: Combine base and recursive cases into a CTE folder_hierarchy_cte = folder_hierarchy_cte.union_all(recursive_query) + # Step 4: Execute the query to get all descendants final_query = select(folder_hierarchy_cte) result = await conn.execute(final_query) 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 f331c98da4a..e4fffd82fc6 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 @@ -1,4 +1,3 @@ -import functools import logging from aiohttp import web @@ -8,135 +7,42 @@ FolderGetPage, PutFolderBodyParams, ) -from models_library.basic_types import IDStr -from models_library.folders import FolderID -from models_library.rest_ordering import OrderBy, OrderDirection -from models_library.rest_pagination import Page, PageQueryParameters +from models_library.rest_ordering import OrderBy +from models_library.rest_pagination import Page from models_library.rest_pagination_utils import paginate_data -from models_library.users import UserID -from models_library.utils.common_validators import null_or_none_str_to_none_validator -from models_library.workspaces import WorkspaceID -from pydantic import Extra, Field, Json, parse_obj_as, validator +from pydantic import parse_obj_as from servicelib.aiohttp import status from servicelib.aiohttp.requests_validation import ( - RequestParams, - StrictRequestParams, parse_request_body_as, parse_request_path_parameters_as, parse_request_query_parameters_as, ) -from servicelib.aiohttp.typing_extension import Handler from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON -from servicelib.request_keys import RQT_USERID_KEY from servicelib.rest_constants import RESPONSE_MODEL_POLICY -from .._constants import RQ_PRODUCT_KEY from .._meta import API_VTAG as VTAG from ..login.decorators import login_required from ..security.decorators import permission_required from ..utils_aiohttp import envelope_json_response -from ..workspaces.errors import ( - WorkspaceAccessForbiddenError, - WorkspaceFolderInconsistencyError, - WorkspaceNotFoundError, -) from . import _folders_api -from .errors import ( - FolderAccessForbiddenError, - FolderNotFoundError, - FoldersValueError, - FolderValueNotPermittedError, +from ._exceptions_handlers import handle_plugin_requests_exceptions +from ._models import ( + FolderFilters, + FolderListWithJsonStrQueryParams, + FoldersPathParams, + FoldersRequestContext, ) _logger = logging.getLogger(__name__) -def handle_folders_exceptions(handler: Handler): - @functools.wraps(handler) - async def wrapper(request: web.Request) -> web.StreamResponse: - try: - return await handler(request) - - except (FolderNotFoundError, WorkspaceNotFoundError) as exc: - raise web.HTTPNotFound(reason=f"{exc}") from exc - - except ( - FolderAccessForbiddenError, - WorkspaceAccessForbiddenError, - WorkspaceFolderInconsistencyError, - ) as exc: - raise web.HTTPForbidden(reason=f"{exc}") from exc - - except (FolderValueNotPermittedError, FoldersValueError) as exc: - raise web.HTTPBadRequest(reason=f"{exc}") from exc - - return wrapper - - -# -# folders COLLECTION ------------------------- -# - routes = web.RouteTableDef() -class FoldersRequestContext(RequestParams): - user_id: UserID = Field(..., alias=RQT_USERID_KEY) # type: ignore[literal-required] - product_name: str = Field(..., alias=RQ_PRODUCT_KEY) # type: ignore[literal-required] - - -class FoldersPathParams(StrictRequestParams): - folder_id: FolderID - - -class FolderListWithJsonStrQueryParams(PageQueryParameters): - # pylint: disable=unsubscriptable-object - order_by: Json[OrderBy] = Field( - default=OrderBy(field=IDStr("modified"), direction=OrderDirection.DESC), - description="Order by field (modified_at|name|description) and direction (asc|desc). The default sorting order is ascending.", - example='{"field": "name", "direction": "desc"}', - alias="order_by", - ) - folder_id: FolderID | None = Field( - default=None, - description="List the subfolders of this folder. By default, list the subfolders of the root directory (Folder ID is None).", - ) - workspace_id: WorkspaceID | None = Field( - default=None, - description="List folders in specific workspace. By default, list in the user private workspace", - ) - - @validator("order_by", check_fields=False) - @classmethod - def validate_order_by_field(cls, v): - if v.field not in { - "modified_at", - "name", - "description", - }: - msg = f"We do not support ordering by provided field {v.field}" - raise ValueError(msg) - if v.field == "modified_at": - v.field = "modified" - return v - - class Config: - extra = Extra.forbid - - # validators - _null_or_none_str_to_none_validator = validator( - "folder_id", allow_reuse=True, pre=True - )(null_or_none_str_to_none_validator) - - _null_or_none_str_to_none_validator2 = validator( - "workspace_id", allow_reuse=True, pre=True - )(null_or_none_str_to_none_validator) - - @routes.post(f"/{VTAG}/folders", name="create_folder") @login_required @permission_required("folder.create") -@handle_folders_exceptions +@handle_plugin_requests_exceptions async def create_folder(request: web.Request): req_ctx = FoldersRequestContext.parse_obj(request) body_params = await parse_request_body_as(CreateFolderBodyParams, request) @@ -156,19 +62,23 @@ async def create_folder(request: web.Request): @routes.get(f"/{VTAG}/folders", name="list_folders") @login_required @permission_required("folder.read") -@handle_folders_exceptions +@handle_plugin_requests_exceptions async def list_folders(request: web.Request): req_ctx = FoldersRequestContext.parse_obj(request) query_params: FolderListWithJsonStrQueryParams = parse_request_query_parameters_as( FolderListWithJsonStrQueryParams, request ) + if not query_params.filters: + query_params.filters = FolderFilters() + folders: FolderGetPage = await _folders_api.list_folders( app=request.app, user_id=req_ctx.user_id, product_name=req_ctx.product_name, folder_id=query_params.folder_id, workspace_id=query_params.workspace_id, + trashed=query_params.filters.trashed, offset=query_params.offset, limit=query_params.limit, order_by=parse_obj_as(OrderBy, query_params.order_by), @@ -192,7 +102,7 @@ async def list_folders(request: web.Request): @routes.get(f"/{VTAG}/folders/{{folder_id}}", name="get_folder") @login_required @permission_required("folder.read") -@handle_folders_exceptions +@handle_plugin_requests_exceptions async def get_folder(request: web.Request): req_ctx = FoldersRequestContext.parse_obj(request) path_params = parse_request_path_parameters_as(FoldersPathParams, request) @@ -213,7 +123,7 @@ async def get_folder(request: web.Request): ) @login_required @permission_required("folder.update") -@handle_folders_exceptions +@handle_plugin_requests_exceptions async def replace_folder(request: web.Request): req_ctx = FoldersRequestContext.parse_obj(request) path_params = parse_request_path_parameters_as(FoldersPathParams, request) @@ -236,7 +146,7 @@ async def replace_folder(request: web.Request): ) @login_required @permission_required("folder.delete") -@handle_folders_exceptions +@handle_plugin_requests_exceptions async def delete_folder_group(request: web.Request): req_ctx = FoldersRequestContext.parse_obj(request) path_params = parse_request_path_parameters_as(FoldersPathParams, request) diff --git a/services/web/server/src/simcore_service_webserver/folders/_models.py b/services/web/server/src/simcore_service_webserver/folders/_models.py new file mode 100644 index 00000000000..fb337b5b199 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/folders/_models.py @@ -0,0 +1,85 @@ +import logging + +from models_library.basic_types import IDStr +from models_library.folders import FolderID +from models_library.rest_filters import Filters, FiltersQueryParameters +from models_library.rest_ordering import OrderBy, OrderDirection +from models_library.rest_pagination import PageQueryParameters +from models_library.users import UserID +from models_library.utils.common_validators import null_or_none_str_to_none_validator +from models_library.workspaces import WorkspaceID +from pydantic import BaseModel, Extra, Field, Json, validator +from servicelib.aiohttp.requests_validation import RequestParams, StrictRequestParams +from servicelib.request_keys import RQT_USERID_KEY + +from .._constants import RQ_PRODUCT_KEY + +_logger = logging.getLogger(__name__) + + +class FoldersRequestContext(RequestParams): + user_id: UserID = Field(..., alias=RQT_USERID_KEY) # type: ignore[literal-required] + product_name: str = Field(..., alias=RQ_PRODUCT_KEY) # type: ignore[literal-required] + + +class FoldersPathParams(StrictRequestParams): + folder_id: FolderID + + +class FolderFilters(Filters): + trashed: bool | None = Field( + default=False, + description="Set to true to list trashed, false to list non-trashed (default), None to list all", + ) + + +class FolderListWithJsonStrQueryParams( + PageQueryParameters, FiltersQueryParameters[FolderFilters] +): + # pylint: disable=unsubscriptable-object + order_by: Json[OrderBy] = Field( + default=OrderBy(field=IDStr("modified"), direction=OrderDirection.DESC), + description="Order by field (modified_at|name|description) and direction (asc|desc). The default sorting order is ascending.", + example='{"field": "name", "direction": "desc"}', + alias="order_by", + ) + folder_id: FolderID | None = Field( + default=None, + description="List the subfolders of this folder. By default, list the subfolders of the root directory (Folder ID is None).", + ) + workspace_id: WorkspaceID | None = Field( + default=None, + description="List folders in specific workspace. By default, list in the user private workspace", + ) + + @validator("order_by", check_fields=False) + @classmethod + def _validate_order_by_field(cls, v): + if v.field not in { + "modified_at", + "name", + "description", + }: + msg = f"We do not support ordering by provided field {v.field}" + raise ValueError(msg) + if v.field == "modified_at": + v.field = "modified" + return v + + class Config: + extra = Extra.forbid + + # validators + _null_or_none_str_to_none_validator = validator( + "folder_id", allow_reuse=True, pre=True + )(null_or_none_str_to_none_validator) + + _null_or_none_str_to_none_validator2 = validator( + "workspace_id", allow_reuse=True, pre=True + )(null_or_none_str_to_none_validator) + + +class RemoveQueryParams(BaseModel): + force: bool = Field( + default=False, description="Force removal (even if resource is active)" + ) diff --git a/services/web/server/src/simcore_service_webserver/folders/_trash_api.py b/services/web/server/src/simcore_service_webserver/folders/_trash_api.py new file mode 100644 index 00000000000..1cad0415161 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/folders/_trash_api.py @@ -0,0 +1,177 @@ +import logging +from datetime import datetime + +import arrow +from aiohttp import web +from models_library.folders import FolderID +from models_library.products import ProductName +from models_library.projects import ProjectID +from models_library.users import UserID + +from ..projects._trash_api import trash_project, untrash_project +from ..workspaces.api import check_user_workspace_access +from . import _folders_db + +_logger = logging.getLogger(__name__) + + +async def _check_exists_and_access( + app: web.Application, + *, + product_name: ProductName, + user_id: UserID, + folder_id: FolderID, +) -> bool: + # exists? + # check whether this folder exists + # otherwise raise not-found error + folder_db = await _folders_db.get( + app, folder_id=folder_id, product_name=product_name + ) + + # can? + # check whether user in product has enough permissions to delete this folder + # otherwise raise forbidden error + workspace_is_private = True + if folder_db.workspace_id: + await check_user_workspace_access( + app, + user_id=user_id, + workspace_id=folder_db.workspace_id, + product_name=product_name, + permission="delete", + ) + workspace_is_private = False + + await _folders_db.get_for_user_or_workspace( + app, + folder_id=folder_id, + product_name=product_name, + user_id=user_id if workspace_is_private else None, + workspace_id=folder_db.workspace_id, + ) + return workspace_is_private + + +async def _folders_db_update( + app: web.Application, + *, + product_name: ProductName, + folder_id: FolderID, + trashed_at: datetime | None, +): + # EXPLICIT un/trash + await _folders_db.update( + app, + folder_id=folder_id, + product_name=product_name, + trashed_at=trashed_at, + trashed_explicitly=trashed_at is not None, + ) + + # IMPLICIT un/trash + child_folders: set[FolderID] = { + f + for f in await _folders_db.get_folders_recursively( + app, folder_id=folder_id, product_name=product_name + ) + if f != folder_id + } + + if child_folders: + await _folders_db.update_batch( + app, + *child_folders, + product_name=product_name, + trashed_at=trashed_at, + trashed_explicitly=False, + ) + + +async def trash_folder( + app: web.Application, + *, + product_name: ProductName, + user_id: UserID, + folder_id: FolderID, + force_stop_first: bool, +): + + workspace_is_private = await _check_exists_and_access( + app, product_name=product_name, user_id=user_id, folder_id=folder_id + ) + + # Trash + trashed_at = arrow.utcnow().datetime + + _logger.debug( + "TODO: Unit of work for all folders and projects and fails if force_stop_first=%s is False", + force_stop_first, + ) + + # 1. Trash folder and children + await _folders_db_update( + app, + folder_id=folder_id, + product_name=product_name, + trashed_at=trashed_at, + ) + + # 2. Trash all child projects that I am an owner + child_projects: list[ + ProjectID + ] = await _folders_db.get_projects_recursively_only_if_user_is_owner( + app, + folder_id=folder_id, + private_workspace_user_id_or_none=user_id if workspace_is_private else None, + user_id=user_id, + product_name=product_name, + ) + + for project_id in child_projects: + await trash_project( + app, + product_name=product_name, + user_id=user_id, + project_id=project_id, + force_stop_first=force_stop_first, + explicit=False, + ) + + +async def untrash_folder( + app: web.Application, + *, + product_name: ProductName, + user_id: UserID, + folder_id: FolderID, +): + workspace_is_private = await _check_exists_and_access( + app, product_name=product_name, user_id=user_id, folder_id=folder_id + ) + + # 3. UNtrash + + # 3.1 UNtrash folder and children + await _folders_db_update( + app, + folder_id=folder_id, + product_name=product_name, + trashed_at=None, + ) + + # 3.2 UNtrash all child projects that I am an owner + child_projects: list[ + ProjectID + ] = await _folders_db.get_projects_recursively_only_if_user_is_owner( + app, + folder_id=folder_id, + private_workspace_user_id_or_none=user_id if workspace_is_private else None, + user_id=user_id, + product_name=product_name, + ) + + for project_id in child_projects: + await untrash_project( + app, product_name=product_name, user_id=user_id, project_id=project_id + ) diff --git a/services/web/server/src/simcore_service_webserver/folders/_trash_handlers.py b/services/web/server/src/simcore_service_webserver/folders/_trash_handlers.py new file mode 100644 index 00000000000..55b53fcd4ee --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/folders/_trash_handlers.py @@ -0,0 +1,66 @@ +import logging + +from aiohttp import web +from servicelib.aiohttp import status +from servicelib.aiohttp.requests_validation import ( + parse_request_path_parameters_as, + parse_request_query_parameters_as, +) + +from .._meta import API_VTAG as VTAG +from ..application_settings_utils import requires_dev_feature_enabled +from ..login.decorators import get_user_id, login_required +from ..products.api import get_product_name +from ..security.decorators import permission_required +from . import _trash_api +from ._exceptions_handlers import handle_plugin_requests_exceptions +from ._models import FoldersPathParams, RemoveQueryParams + +_logger = logging.getLogger(__name__) + + +routes = web.RouteTableDef() + + +@routes.post(f"/{VTAG}/folders/{{folder_id}}:trash", name="trash_folder") +@requires_dev_feature_enabled +@login_required +@permission_required("folder.delete") +@handle_plugin_requests_exceptions +async def trash_folder(request: web.Request): + user_id = get_user_id(request) + product_name = get_product_name(request) + path_params = parse_request_path_parameters_as(FoldersPathParams, request) + query_params: RemoveQueryParams = parse_request_query_parameters_as( + RemoveQueryParams, request + ) + + await _trash_api.trash_folder( + request.app, + product_name=product_name, + user_id=user_id, + folder_id=path_params.folder_id, + force_stop_first=query_params.force, + ) + + return web.json_response(status=status.HTTP_204_NO_CONTENT) + + +@routes.post(f"/{VTAG}/folders/{{folder_id}}:untrash", name="untrash_folder") +@requires_dev_feature_enabled +@login_required +@permission_required("folder.delete") +@handle_plugin_requests_exceptions +async def untrash_folder(request: web.Request): + user_id = get_user_id(request) + product_name = get_product_name(request) + path_params = parse_request_path_parameters_as(FoldersPathParams, request) + + await _trash_api.untrash_folder( + request.app, + product_name=product_name, + user_id=user_id, + folder_id=path_params.folder_id, + ) + + return web.json_response(status=status.HTTP_204_NO_CONTENT) diff --git a/services/web/server/src/simcore_service_webserver/folders/plugin.py b/services/web/server/src/simcore_service_webserver/folders/plugin.py index bfc0fafb351..8ddef03ec1f 100644 --- a/services/web/server/src/simcore_service_webserver/folders/plugin.py +++ b/services/web/server/src/simcore_service_webserver/folders/plugin.py @@ -7,7 +7,7 @@ from servicelib.aiohttp.application_keys import APP_SETTINGS_KEY from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup -from . import _folders_handlers +from . import _folders_handlers, _trash_handlers _logger = logging.getLogger(__name__) @@ -24,3 +24,4 @@ def setup_folders(app: web.Application): # routes app.router.add_routes(_folders_handlers.routes) + app.router.add_routes(_trash_handlers.routes) 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 f6c98c6e08e..f8b6aee4ff9 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 @@ -115,8 +115,8 @@ async def list_projects( # pylint: disable=too-many-arguments # attrs filter_by_project_type=ProjectTypeAPI.to_project_type_db(project_type), filter_by_services=user_available_services, - trashed=trashed, - hidden=show_hidden, + filter_trashed=trashed, + filter_hidden=show_hidden, # composed attrs search=search, # pagination diff --git a/services/web/server/src/simcore_service_webserver/projects/_trash_api.py b/services/web/server/src/simcore_service_webserver/projects/_trash_api.py index 6469375c853..d3bc6092aaf 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_trash_api.py +++ b/services/web/server/src/simcore_service_webserver/projects/_trash_api.py @@ -69,6 +69,7 @@ async def trash_project( user_id: UserID, project_id: ProjectID, force_stop_first: bool, + explicit: bool, ): """ @@ -113,13 +114,14 @@ async def _schedule(): product_name=product_name, ) - # mark as trash await projects_api.patch_project( app, user_id=user_id, product_name=product_name, project_uuid=project_id, - project_patch=ProjectPatchExtended(trashed_at=arrow.utcnow().datetime), + project_patch=ProjectPatchExtended( + trashed_at=arrow.utcnow().datetime, trashed_explicitly=explicit + ), ) @@ -136,5 +138,5 @@ async def untrash_project( user_id=user_id, product_name=product_name, project_uuid=project_id, - project_patch=ProjectPatchExtended(trashed_at=None), + project_patch=ProjectPatchExtended(trashed_at=None, trashed_explicitly=False), ) diff --git a/services/web/server/src/simcore_service_webserver/projects/_trash_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_trash_handlers.py index 2995488c562..4593779e735 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_trash_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_trash_handlers.py @@ -1,6 +1,4 @@ -import functools import logging -from typing import NamedTuple from aiohttp import web from servicelib.aiohttp import status @@ -8,13 +6,14 @@ parse_request_path_parameters_as, parse_request_query_parameters_as, ) -from servicelib.aiohttp.typing_extension import Handler -from servicelib.aiohttp.web_exceptions_extension import get_http_error_class_or_none -from servicelib.logging_errors import create_troubleshotting_log_kwargs -from servicelib.status_codes_utils import is_5xx_server_error from .._meta import API_VTAG as VTAG from ..application_settings_utils import requires_dev_feature_enabled +from ..exceptions_handlers import ( + ExceptionToHttpErrorMap, + HttpErrorInfo, + create_exception_handlers_decorator, +) from ..login.decorators import get_user_id, login_required from ..products.api import get_product_name from ..projects._common_models import ProjectPathParams @@ -34,12 +33,7 @@ # -class HttpErrorInfo(NamedTuple): - status_code: int - msg_template: str - - -_TO_HTTP_ERROR_MAP: dict[type[Exception], HttpErrorInfo] = { +_TO_HTTP_ERROR_MAP: ExceptionToHttpErrorMap = { ProjectRunningConflictError: HttpErrorInfo( status.HTTP_409_CONFLICT, "Current study is in use and cannot be trashed [project_id={project_uuid}]. Please stop all services first and try again", @@ -51,49 +45,9 @@ class HttpErrorInfo(NamedTuple): } -class _DefaultDict(dict): - def __missing__(self, key): - return f"'{key}=?'" - - -def _handle_request_exceptions(handler: Handler): - @functools.wraps(handler) - async def _wrapper(request: web.Request) -> web.StreamResponse: - try: - return await handler(request) - - except ProjectTrashError as exc: - for exc_cls, http_error_info in _TO_HTTP_ERROR_MAP.items(): - if isinstance(exc, exc_cls): - - # safe formatting, i.e. does not raise - user_msg = http_error_info.msg_template.format_map( - _DefaultDict(getattr(exc, "__dict__", {})) - ) - - http_error_cls = get_http_error_class_or_none( - http_error_info.status_code - ) - assert http_error_cls # nosec - - if is_5xx_server_error(http_error_info.status_code): - _logger.exception( - **create_troubleshotting_log_kwargs( - user_msg, - error=exc, - error_context={ - "request": request, - "request.remote": f"{request.remote}", - "request.method": f"{request.method}", - "request.path": f"{request.path}", - }, - ) - ) - raise http_error_cls(reason=user_msg) from exc - raise - - return _wrapper - +_handle_exceptions = create_exception_handlers_decorator( + exceptions_catch=ProjectTrashError, exc_to_status_map=_TO_HTTP_ERROR_MAP +) # # ROUTES @@ -106,7 +60,7 @@ async def _wrapper(request: web.Request) -> web.StreamResponse: @requires_dev_feature_enabled @login_required @permission_required("project.delete") -@_handle_request_exceptions +@_handle_exceptions async def empty_trash(request: web.Request): user_id = get_user_id(request) product_name = get_product_name(request) @@ -122,7 +76,7 @@ async def empty_trash(request: web.Request): @requires_dev_feature_enabled @login_required @permission_required("project.delete") -@_handle_request_exceptions +@_handle_exceptions async def trash_project(request: web.Request): user_id = get_user_id(request) product_name = get_product_name(request) @@ -137,6 +91,7 @@ async def trash_project(request: web.Request): user_id=user_id, project_id=path_params.project_id, force_stop_first=query_params.force, + explicit=True, ) return web.json_response(status=status.HTTP_204_NO_CONTENT) @@ -146,7 +101,7 @@ async def trash_project(request: web.Request): @requires_dev_feature_enabled @login_required @permission_required("project.delete") -@_handle_request_exceptions +@_handle_exceptions async def untrash_project(request: web.Request): user_id = get_user_id(request) product_name = get_product_name(request) 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 6cbe059dfb7..5e0c216f77e 100644 --- a/services/web/server/src/simcore_service_webserver/projects/db.py +++ b/services/web/server/src/simcore_service_webserver/projects/db.py @@ -362,9 +362,9 @@ async def list_projects( # pylint: disable=too-many-arguments search: str | None = None, filter_by_project_type: ProjectType | None = None, filter_by_services: list[dict] | None = None, - published: bool | None = False, - hidden: bool | None = False, - trashed: bool | None = False, + filter_published: bool | None = False, + filter_hidden: bool | None = False, + filter_trashed: bool | None = False, # pagination offset: int | None = 0, limit: int | None = None, @@ -442,16 +442,21 @@ async def list_projects( # pylint: disable=too-many-arguments projects.c.type == filter_by_project_type.value ) - if hidden is not None: - attributes_filters.append(projects.c.hidden.is_(hidden)) + if filter_hidden is not None: + attributes_filters.append(projects.c.hidden.is_(filter_hidden)) - if published is not None: - attributes_filters.append(projects.c.published.is_(published)) + if filter_published is not None: + attributes_filters.append(projects.c.published.is_(filter_published)) - if trashed is not None: + if filter_trashed is not None: attributes_filters.append( - projects.c.trashed_at.is_not(None) - if trashed + # marked explicitly as trashed + ( + projects.c.trashed_at.is_not(None) + & projects.c.trashed_explicitly.is_(True) + ) + if filter_trashed + # not marked as trashed else projects.c.trashed_at.is_(None) ) query = query.where(sa.and_(*attributes_filters)) diff --git a/services/web/server/src/simcore_service_webserver/projects/models.py b/services/web/server/src/simcore_service_webserver/projects/models.py index 37961a9aff4..d3457fb52b0 100644 --- a/services/web/server/src/simcore_service_webserver/projects/models.py +++ b/services/web/server/src/simcore_service_webserver/projects/models.py @@ -53,6 +53,7 @@ class ProjectDB(BaseModel): hidden: bool workspace_id: WorkspaceID | None trashed_at: datetime | None + trashed_explicitly: bool = False class Config: orm_mode = True @@ -101,7 +102,8 @@ class Config: class ProjectPatchExtended(ProjectPatch): # Only used internally - trashed_at: datetime | None = None + trashed_at: datetime | None + trashed_explicitly: bool class Config: allow_population_by_field_name = True diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_api.py b/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_api.py index 256b50de114..a645037f5a4 100644 --- a/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_api.py +++ b/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_api.py @@ -12,11 +12,11 @@ from models_library.users import UserID from models_library.workspaces import UserWorkspaceAccessRightsDB, WorkspaceID from pydantic import NonNegativeInt -from simcore_service_webserver.projects._db_utils import PermissionStr -from simcore_service_webserver.workspaces.errors import WorkspaceAccessForbiddenError +from ..projects._db_utils import PermissionStr from ..users.api import get_user from . import _workspaces_db as db +from .errors import WorkspaceAccessForbiddenError _logger = logging.getLogger(__name__) diff --git a/services/web/server/tests/unit/isolated/test_exceptions_handlers.py b/services/web/server/tests/unit/isolated/test_exceptions_handlers.py new file mode 100644 index 00000000000..27cde72283b --- /dev/null +++ b/services/web/server/tests/unit/isolated/test_exceptions_handlers.py @@ -0,0 +1,117 @@ +# pylint: disable=protected-access +# pylint: disable=redefined-outer-name +# pylint: disable=too-many-arguments +# pylint: disable=too-many-statements +# pylint: disable=unused-argument +# pylint: disable=unused-variable + + +import logging + +import pytest +from aiohttp import web +from aiohttp.test_utils import make_mocked_request +from servicelib.aiohttp import status +from simcore_service_webserver.errors import WebServerBaseError +from simcore_service_webserver.exceptions_handlers import ( + HttpErrorInfo, + _sort_exceptions_by_specificity, + create_exception_handlers_decorator, +) + + +class BasePluginError(WebServerBaseError): + ... + + +class OneError(BasePluginError): + ... + + +class OtherError(BasePluginError): + ... + + +def test_sort_concrete_first(): + assert _sort_exceptions_by_specificity([Exception, BasePluginError]) == [ + BasePluginError, + Exception, + ] + + assert _sort_exceptions_by_specificity( + [Exception, BasePluginError], concrete_first=False + ) == [ + Exception, + BasePluginError, + ] + + +def test_sort_exceptions_by_specificity(): + + got_exceptions_cls = _sort_exceptions_by_specificity( + [ + Exception, + OtherError, + OneError, + BasePluginError, + ValueError, + ArithmeticError, + ZeroDivisionError, + ] + ) + + for from_, exc in enumerate(got_exceptions_cls, start=1): + for exc_after in got_exceptions_cls[from_:]: + assert not issubclass(exc_after, exc), f"{got_exceptions_cls=}" + + +async def test_exception_handlers_decorator( + caplog: pytest.LogCaptureFixture, +): + + _handle_exceptions = create_exception_handlers_decorator( + exceptions_catch=BasePluginError, + exc_to_status_map={ + OneError: HttpErrorInfo( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + msg_template="This is one error for front-end", + ) + }, + ) + + @_handle_exceptions + async def _rest_handler(request: web.Request) -> web.Response: + if request.query.get("raise") == "OneError": + raise OneError + if request.query.get("raise") == "ArithmeticError": + raise ArithmeticError + + return web.Response(reason="all good") + + with caplog.at_level(logging.ERROR): + + # emulates successful call + resp = await _rest_handler(make_mocked_request("GET", "/foo")) + assert resp.status == status.HTTP_200_OK + assert resp.reason == "all good" + + assert not caplog.records + + # this will be passed and catched by the outermost error middleware + with pytest.raises(ArithmeticError): + await _rest_handler( + make_mocked_request("GET", "/foo?raise=ArithmeticError") + ) + + assert not caplog.records + + # this is a 5XX will be converted to response but is logged as error as well + with pytest.raises(web.HTTPException) as exc_info: + await _rest_handler(make_mocked_request("GET", "/foo?raise=OneError")) + + resp = exc_info.value + assert resp.status == status.HTTP_503_SERVICE_UNAVAILABLE + assert "front-end" in resp.reason + + assert caplog.records + assert caplog.records[0].levelno == logging.ERROR diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_cancellations.py b/services/web/server/tests/unit/with_dbs/02/test_projects_cancellations.py index 6c841fa8650..aa79512a12a 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_cancellations.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_cancellations.py @@ -4,7 +4,8 @@ # pylint: disable=unused-variable import asyncio -from typing import Any, Awaitable, Callable +from collections.abc import Awaitable, Callable +from typing import Any from urllib.parse import urlparse import pytest diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py b/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py index bf69984d6af..8904cead4bf 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py @@ -165,7 +165,7 @@ async def _assert_get_same_project( project_permalink = data.pop("permalink", None) folder_id = data.pop("folderId", None) - assert data == project + assert data == {k: project[k] for k in data} if project_state: assert parse_obj_as(ProjectState, project_state) @@ -201,22 +201,24 @@ async def test_list_projects( assert len(data) == 2 # template project - project_state = data[0].pop("state") - project_permalink = data[0].pop("permalink") - folder_id = data[0].pop("folderId") + got = data[0] + project_state = got.pop("state") + project_permalink = got.pop("permalink") + folder_id = got.pop("folderId") - assert data[0] == template_project + assert got == {k: template_project[k] for k in got} assert not ProjectState( **project_state ).locked.value, "Templates are not locked" assert parse_obj_as(ProjectPermalink, project_permalink) # standard project - project_state = data[1].pop("state") - project_permalink = data[1].pop("permalink", None) - folder_id = data[1].pop("folderId") + got = data[1] + project_state = got.pop("state") + project_permalink = got.pop("permalink", None) + folder_id = got.pop("folderId") - assert data[1] == user_project + assert got == {k: user_project[k] for k in got} assert ProjectState(**project_state) assert project_permalink is None assert folder_id is None @@ -227,11 +229,12 @@ async def test_list_projects( assert len(data) == 1 # standad project - project_state = data[0].pop("state") - project_permalink = data[0].pop("permalink", None) - folder_id = data[0].pop("folderId") + got = data[0] + project_state = got.pop("state") + project_permalink = got.pop("permalink", None) + folder_id = got.pop("folderId") - assert data[0] == user_project + assert got == {k: user_project[k] for k in got} assert not ProjectState( **project_state ).locked.value, "Single user does not lock" @@ -244,11 +247,12 @@ async def test_list_projects( assert len(data) == 1 # template project - project_state = data[0].pop("state") - project_permalink = data[0].pop("permalink") - folder_id = data[0].pop("folderId") + got = data[0] + project_state = got.pop("state") + project_permalink = got.pop("permalink") + folder_id = got.pop("folderId") - assert data[0] == template_project + assert got == {k: template_project[k] for k in got} assert not ProjectState( **project_state ).locked.value, "Templates are not locked" diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_states_handlers.py b/services/web/server/tests/unit/with_dbs/02/test_projects_states_handlers.py index 02285ebb0d5..3514d3b2475 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_states_handlers.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_states_handlers.py @@ -930,7 +930,7 @@ async def test_get_active_project( data_last_change_date = data.pop("lastChangeDate") assert user_project_last_change_date < data_last_change_date - assert data == user_project + assert data == {k: user_project[k] for k in data} else: mocked_notifications_plugin["subscribe"].assert_not_called() diff --git a/services/web/server/tests/unit/with_dbs/03/test_project_db.py b/services/web/server/tests/unit/with_dbs/03/test_project_db.py index 89a67734b60..fadfe561267 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_project_db.py +++ b/services/web/server/tests/unit/with_dbs/03/test_project_db.py @@ -98,6 +98,7 @@ def _assert_added_project( "lastChangeDate", "accessRights", # NOTE: access rights were moved away from the projects table "trashedAt", + "trashedExplicitly", ] assert {k: v for k, v in expected_prj.items() if k in _DIFFERENT_KEYS} != { k: v for k, v in added_prj.items() if k in _DIFFERENT_KEYS 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 5a760d5f9fd..7d6c701c522 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 @@ -14,6 +14,7 @@ import pytest from aiohttp.test_utils import TestClient from aioresponses import aioresponses +from models_library.api_schemas_webserver.folders_v2 import FolderGet from models_library.api_schemas_webserver.projects import ProjectGet, ProjectListItem from models_library.rest_pagination import Page from pytest_mock import MockerFixture @@ -24,6 +25,7 @@ from servicelib.aiohttp import status from simcore_service_webserver.db.models import UserRole from simcore_service_webserver.projects.models import ProjectDict +from yarl import URL @pytest.fixture @@ -174,3 +176,222 @@ async def test_trash_projects( # noqa: PLR0915 await asyncio.sleep(0.1) mock_stop_pipeline.assert_awaited() mock_remove_dynamic_services.assert_awaited() + + +@pytest.mark.acceptance_test( + "For https://github.com/ITISFoundation/osparc-simcore/pull/6642" +) +async def test_trash_single_folder(client: TestClient, logged_user: UserInfoDict): + assert client.app + + # CREATE a folder + resp = await client.post("/v0/folders", json={"name": "My first folder"}) + data, _ = await assert_status(resp, status.HTTP_201_CREATED) + folder = FolderGet.parse_obj(data) + + # --------------------------------------------------------------------- + + # LIST NOT trashed + resp = await client.get("/v0/folders") + await assert_status(resp, status.HTTP_200_OK) + + page = Page[FolderGet].parse_obj(await resp.json()) + assert page.meta.total == 1 + + assert page.data[0] == folder + + # LIST trashed + resp = await client.get("/v0/folders", params={"filters": '{"trashed": true}'}) + await assert_status(resp, status.HTTP_200_OK) + + page = Page[FolderGet].parse_obj(await resp.json()) + assert page.meta.total == 0 + + # TRASH + assert client.app.router["trash_folder"].url_for(folder_id="folder_id") == URL( + "/v0/folders/folder_id:trash" + ) + + trashing_at = arrow.utcnow().datetime + resp = await client.post(f"/v0/folders/{folder.folder_id}:trash") + await assert_status( + resp, + status.HTTP_204_NO_CONTENT, + ) + + # GET + resp = await client.get(f"/v0/folders/{folder.folder_id}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + got = FolderGet.parse_obj(data) + assert got.folder_id == folder.folder_id + + assert got.trashed_at + assert trashing_at < got.trashed_at + assert got.trashed_at < arrow.utcnow().datetime + + # LIST trashed + resp = await client.get("/v0/folders", params={"filters": '{"trashed": true}'}) + await assert_status(resp, status.HTTP_200_OK) + + page = Page[FolderGet].parse_obj(await resp.json()) + + assert page.meta.total == 1 + assert page.data[0].folder_id == folder.folder_id + + # UNTRASH + assert client.app.router["untrash_folder"].url_for(folder_id="folder_id") == URL( + "/v0/folders/folder_id:untrash" + ) + + resp = await client.post(f"/v0/folders/{folder.folder_id}:untrash") + data, _ = await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # GET + resp = await client.get(f"/v0/folders/{folder.folder_id}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + got = FolderGet.parse_obj(data) + + assert got.folder_id == folder.folder_id + assert got.trashed_at is None + + +@pytest.mark.acceptance_test( + "For https://github.com/ITISFoundation/osparc-simcore/pull/6642" +) +async def test_trash_folder_with_content( + client: TestClient, + logged_user: UserInfoDict, + user_project: ProjectDict, + mocked_catalog: None, + mocked_director_v2: None, +): + assert client.app + project_uuid = UUID(user_project["uuid"]) + + # CREATE a folder + resp = await client.post("/v0/folders", json={"name": "My first folder"}) + data, _ = await assert_status(resp, status.HTTP_201_CREATED) + folder = FolderGet.parse_obj(data) + + # CREATE a SUB-folder + resp = await client.post( + "/v0/folders", + json={"name": "My subfolder 1", "parentFolderId": folder.folder_id}, + ) + data, _ = await assert_status(resp, status.HTTP_201_CREATED) + subfolder = FolderGet.parse_obj(data) + + # MOVE project to SUB-folder + resp = await client.put( + f"/v0/projects/{project_uuid}/folders/{subfolder.folder_id}" + ) + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # CHECK created + resp = await client.get("/v0/folders") + await assert_status(resp, status.HTTP_200_OK) + page = Page[FolderGet].parse_obj(await resp.json()) + assert page.meta.total == 1 + assert page.data[0] == folder + + resp = await client.get("/v0/folders", params={"folder_id": f"{folder.folder_id}"}) + await assert_status(resp, status.HTTP_200_OK) + page = Page[FolderGet].parse_obj(await resp.json()) + assert page.meta.total == 1 + assert page.data[0] == subfolder + + resp = await client.get( + "/v0/projects", params={"folder_id": f"{subfolder.folder_id}"} + ) + await assert_status(resp, status.HTTP_200_OK) + page = Page[ProjectListItem].parse_obj(await resp.json()) + assert page.meta.total == 1 + assert page.data[0].uuid == project_uuid + assert page.data[0].folder_id == subfolder.folder_id + + # --------------------------------------------------------------------- + + # TRASH folder + resp = await client.post(f"/v0/folders/{folder.folder_id}:trash") + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # ONLY folder listed in trash. The rest is not listed anymore! + resp = await client.get("/v0/folders", params={"filters": '{"trashed": true}'}) + await assert_status(resp, status.HTTP_200_OK) + page = Page[FolderGet].parse_obj(await resp.json()) + assert page.meta.total == 1 + assert page.data[0].folder_id == folder.folder_id + + resp = await client.get( + "/v0/folders", + params={"filters": '{"trashed": true}', "folder_id": f"{folder.folder_id}"}, + ) + await assert_status(resp, status.HTTP_200_OK) + page = Page[FolderGet].parse_obj(await resp.json()) + assert page.meta.total == 0 + + resp = await client.get( + "/v0/projects", + params={"filters": '{"trashed": true}', "folder_id": f"{subfolder.folder_id}"}, + ) + await assert_status(resp, status.HTTP_200_OK) + page = Page[ProjectListItem].parse_obj(await resp.json()) + assert page.meta.total == 0 + + # CHECK marked as trashed + resp = await client.get(f"/v0/folders/{folder.folder_id}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + got = FolderGet.parse_obj(data) + assert got.trashed_at is not None + + resp = await client.get(f"/v0/folders/{subfolder.folder_id}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + got = FolderGet.parse_obj(data) + assert got.trashed_at is not None + + resp = await client.get(f"/v0/projects/{project_uuid}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + got = ProjectGet.parse_obj(data) + assert got.trashed_at is not None + + # UNTRASH folder + resp = await client.post(f"/v0/folders/{folder.folder_id}:untrash") + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # NO folders listed in trash. + resp = await client.get("/v0/folders", params={"filters": '{"trashed": true}'}) + await assert_status(resp, status.HTTP_200_OK) + page = Page[FolderGet].parse_obj(await resp.json()) + assert page.meta.total == 0 + + resp = await client.get( + "/v0/folders", + params={"filters": '{"trashed": true}', "folder_id": f"{folder.folder_id}"}, + ) + await assert_status(resp, status.HTTP_200_OK) + page = Page[FolderGet].parse_obj(await resp.json()) + assert page.meta.total == 0 + + resp = await client.get( + "/v0/projects", + params={"filters": '{"trashed": true}', "folder_id": f"{subfolder.folder_id}"}, + ) + await assert_status(resp, status.HTTP_200_OK) + page = Page[ProjectListItem].parse_obj(await resp.json()) + assert page.meta.total == 0 + + # CHECK marked as trashed + resp = await client.get(f"/v0/folders/{folder.folder_id}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + got = FolderGet.parse_obj(data) + assert got.trashed_at is None + + resp = await client.get(f"/v0/folders/{subfolder.folder_id}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + got = FolderGet.parse_obj(data) + assert got.trashed_at is None + + resp = await client.get(f"/v0/projects/{project_uuid}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + got = ProjectGet.parse_obj(data) + assert got.trashed_at is None diff --git a/services/web/server/tests/unit/with_dbs/04/folders/test_folders.py b/services/web/server/tests/unit/with_dbs/04/folders/test_folders.py index 345e3875628..d2df6efb6e7 100644 --- a/services/web/server/tests/unit/with_dbs/04/folders/test_folders.py +++ b/services/web/server/tests/unit/with_dbs/04/folders/test_folders.py @@ -1,16 +1,18 @@ -import asyncio - # pylint: disable=redefined-outer-name # pylint: disable=unused-argument # pylint: disable=unused-variable # pylint: disable=too-many-arguments # pylint: disable=too-many-statements + + +import asyncio from http import HTTPStatus from unittest import mock import pytest from aiohttp.test_utils import TestClient from models_library.api_schemas_webserver.folders_v2 import FolderGet +from pydantic import parse_obj_as from pytest_mock import MockerFixture from pytest_simcore.helpers.assert_checks import assert_status from pytest_simcore.helpers.webserver_login import LoggedUser, UserInfoDict @@ -21,7 +23,10 @@ from servicelib.aiohttp import status from servicelib.aiohttp.application_keys import APP_FIRE_AND_FORGET_TASKS_KEY from simcore_service_webserver.db.models import UserRole -from simcore_service_webserver.projects._groups_db import update_or_insert_project_group +from simcore_service_webserver.projects._groups_db import ( + GroupID, + update_or_insert_project_group, +) from simcore_service_webserver.projects.models import ProjectDict @@ -35,7 +40,7 @@ async def test_folders_user_role_permissions( assert client.app url = client.app.router["list_folders"].url_for() - resp = await client.get(url.path) + resp = await client.get(f"{url}") await assert_status(resp, expected.ok) @@ -50,68 +55,66 @@ async def test_folders_full_workflow( # list user folders url = client.app.router["list_folders"].url_for() - resp = await client.get(url.path) + resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) assert data == [] # create a new folder url = client.app.router["create_folder"].url_for() - resp = await client.post(url.path, json={"name": "My first folder"}) - added_folder, _ = await assert_status(resp, status.HTTP_201_CREATED) - assert FolderGet.parse_obj(added_folder) + resp = await client.post(f"{url}", json={"name": "My first folder"}) + data, _ = await assert_status(resp, status.HTTP_201_CREATED) + added_folder = FolderGet.parse_obj(data) # list user folders url = client.app.router["list_folders"].url_for() - resp = await client.get(url.path) + resp = await client.get(f"{url}") data, _, meta, links = await assert_status( resp, status.HTTP_200_OK, include_meta=True, include_links=True ) assert len(data) == 1 - assert data[0]["folderId"] == added_folder["folderId"] - assert data[0]["name"] == "My first folder" + assert data[0]["folderId"] == added_folder.folder_id + assert data[0]["name"] == added_folder.name assert meta["count"] == 1 assert links # get a user folder - url = client.app.router["get_folder"].url_for( - folder_id=f"{added_folder['folderId']}" - ) - resp = await client.get(url) + url = client.app.router["get_folder"].url_for(folder_id=f"{added_folder.folder_id}") + resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) - assert FolderGet.parse_obj(data) - assert data["folderId"] == added_folder["folderId"] - assert data["name"] == "My first folder" + got_folder = FolderGet.parse_obj(data) + assert got_folder.folder_id == added_folder.folder_id + assert got_folder.name == added_folder.name # update a folder url = client.app.router["replace_folder"].url_for( - folder_id=f"{added_folder['folderId']}" + folder_id=f"{added_folder.folder_id}" ) resp = await client.put( - url.path, - json={ - "name": "My Second folder", - }, + f"{url}", + json={"name": "My Second folder"}, ) data, _ = await assert_status(resp, status.HTTP_200_OK) - assert FolderGet.parse_obj(data) + updated_folder = FolderGet.parse_obj(data) + assert updated_folder.folder_id == got_folder.folder_id + assert updated_folder.name != got_folder.name # list user folders url = client.app.router["list_folders"].url_for() - resp = await client.get(url.path) + resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) assert len(data) == 1 assert data[0]["name"] == "My Second folder" # delete a folder url = client.app.router["delete_folder"].url_for( - folder_id=f"{added_folder['folderId']}" + folder_id=f"{added_folder.folder_id}" ) - resp = await client.delete(url.path) + resp = await client.delete(f"{url}") data, _ = await assert_status(resp, status.HTTP_204_NO_CONTENT) # list user folders url = client.app.router["list_folders"].url_for() - resp = await client.get(url.path) + resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) assert data == [] @@ -127,19 +130,19 @@ async def test_sub_folders_full_workflow( # list user folders url = client.app.router["list_folders"].url_for() - resp = await client.get(url.path) + resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) assert data == [] # create a new folder url = client.app.router["create_folder"].url_for() - resp = await client.post(url.path, json={"name": "My first folder"}) + resp = await client.post(f"{url}", json={"name": "My first folder"}) root_folder, _ = await assert_status(resp, status.HTTP_201_CREATED) # create a subfolder folder url = client.app.router["create_folder"].url_for() resp = await client.post( - url.path, + f"{url}", json={ "name": "My subfolder", "parentFolderId": root_folder["folderId"], @@ -149,22 +152,25 @@ async def test_sub_folders_full_workflow( # list user root folders url = client.app.router["list_folders"].url_for() - resp = await client.get(url.path) + resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) assert len(data) == 1 assert data[0]["name"] == "My first folder" # list user specific folder - base_url = client.app.router["list_folders"].url_for() - url = base_url.with_query({"folder_id": f"{subfolder_folder['folderId']}"}) - resp = await client.get(url) + url = ( + client.app.router["list_folders"] + .url_for() + .with_query({"folder_id": f"{subfolder_folder['folderId']}"}) + ) + resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) assert len(data) == 0 # create a sub sub folder url = client.app.router["create_folder"].url_for() resp = await client.post( - url.path, + f"{url}", json={ "name": "My sub sub folder", "parentFolderId": subfolder_folder["folderId"], @@ -173,9 +179,12 @@ async def test_sub_folders_full_workflow( subsubfolder_folder, _ = await assert_status(resp, status.HTTP_201_CREATED) # list user subfolder folders - base_url = client.app.router["list_folders"].url_for() - url = base_url.with_query({"folder_id": f"{subfolder_folder['folderId']}"}) - resp = await client.get(url) + url = ( + client.app.router["list_folders"] + .url_for() + .with_query({"folder_id": f"{subfolder_folder['folderId']}"}) + ) + resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) assert len(data) == 1 assert data[0]["name"] == "My sub sub folder" @@ -186,20 +195,20 @@ async def test_sub_folders_full_workflow( folder_id=f"{subfolder_folder['folderId']}", ) resp = await client.put( - url.path, + f"{url}", json={ "name": "My Updated Folder", "parentFolderId": f"{subsubfolder_folder['folderId']}", }, ) - await assert_status(resp, status.HTTP_400_BAD_REQUEST) + await assert_status(resp, status.HTTP_409_CONFLICT) # move sub sub folder to root folder url = client.app.router["replace_folder"].url_for( folder_id=f"{subsubfolder_folder['folderId']}" ) resp = await client.put( - url.path, + f"{url}", json={ "name": "My Updated Folder", "parentFolderId": None, @@ -209,9 +218,8 @@ async def test_sub_folders_full_workflow( assert FolderGet.parse_obj(data) # list user root folders - base_url = client.app.router["list_folders"].url_for() - url = base_url.with_query({"folder_id": "null"}) - resp = await client.get(url) + url = client.app.router["list_folders"].url_for().with_query({"folder_id": "null"}) + resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) assert len(data) == 2 @@ -227,20 +235,20 @@ async def test_project_folder_movement_full_workflow( # create a new folder url = client.app.router["create_folder"].url_for() - resp = await client.post(url.path, json={"name": "My first folder"}) + resp = await client.post(f"{url}", 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"{user_project['uuid']}" ) - resp = await client.put(url.path) + resp = await client.put(f"{url}") await assert_status(resp, status.HTTP_204_NO_CONTENT) # create a sub folder url = client.app.router["create_folder"].url_for() resp = await client.post( - url.path, + f"{url}", json={ "name": "My sub folder", "parentFolderId": root_folder["folderId"], @@ -252,14 +260,14 @@ async def test_project_folder_movement_full_workflow( url = client.app.router["replace_project_folder"].url_for( folder_id=f"{sub_folder['folderId']}", project_id=f"{user_project['uuid']}" ) - resp = await client.put(url.path) + resp = await client.put(f"{url}") await assert_status(resp, status.HTTP_204_NO_CONTENT) # move project to the root directory url = client.app.router["replace_project_folder"].url_for( folder_id="null", project_id=f"{user_project['uuid']}" ) - resp = await client.put(url.path) + resp = await client.put(f"{url}") await assert_status(resp, status.HTTP_204_NO_CONTENT) @@ -284,7 +292,7 @@ async def test_project_listing_inside_of_private_folder( # create a new folder url = client.app.router["create_folder"].url_for() - resp = await client.post(url.path, json={"name": "My first folder"}) + resp = await client.post(f"{url}", json={"name": "My first folder"}) original_user_folder, _ = await assert_status(resp, status.HTTP_201_CREATED) # add project to the folder @@ -292,13 +300,16 @@ async def test_project_listing_inside_of_private_folder( folder_id=f"{original_user_folder['folderId']}", project_id=f"{user_project['uuid']}", ) - resp = await client.put(url.path) + resp = await client.put(f"{url}") await assert_status(resp, status.HTTP_204_NO_CONTENT) # list project in user private folder - base_url = client.app.router["list_projects"].url_for() - url = base_url.with_query({"folder_id": f"{original_user_folder['folderId']}"}) - resp = await client.get(url) + url = ( + client.app.router["list_projects"] + .url_for() + .with_query({"folder_id": f"{original_user_folder['folderId']}"}) + ) + resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) assert len(data) == 1 assert data[0]["uuid"] == user_project["uuid"] @@ -308,29 +319,32 @@ async def test_project_listing_inside_of_private_folder( # Create new user async with LoggedUser(client) as new_logged_user: # Try to list folder that user doesn't have access to - base_url = client.app.router["list_projects"].url_for() - url = base_url.with_query({"folder_id": f"{original_user_folder['folderId']}"}) - resp = await client.get(url) - _, errors = await assert_status( - resp, - status.HTTP_403_FORBIDDEN, + url = ( + client.app.router["list_projects"] + .url_for() + .with_query({"folder_id": f"{original_user_folder['folderId']}"}) ) + resp = await client.get(f"{url}") + _, errors = await assert_status(resp, status.HTTP_403_FORBIDDEN) assert errors # Now we will share the project with the new user await update_or_insert_project_group( client.app, project_id=user_project["uuid"], - group_id=new_logged_user["primary_gid"], + group_id=parse_obj_as(GroupID, new_logged_user["primary_gid"]), read=True, write=True, delete=False, ) # list new user root folder - base_url = client.app.router["list_projects"].url_for() - url = base_url.with_query({"folder_id": "null"}) - resp = await client.get(url) + url = ( + client.app.router["list_projects"] + .url_for() + .with_query({"folder_id": "null"}) + ) + resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) assert len(data) == 1 assert data[0]["uuid"] == user_project["uuid"] @@ -339,7 +353,7 @@ async def test_project_listing_inside_of_private_folder( # create a new folder url = client.app.router["create_folder"].url_for() - resp = await client.post(url.path, json={"name": "New user folder"}) + resp = await client.post(f"{url}", json={"name": "New user folder"}) new_user_folder, _ = await assert_status(resp, status.HTTP_201_CREATED) # add project to the folder @@ -347,13 +361,16 @@ async def test_project_listing_inside_of_private_folder( folder_id=f"{new_user_folder['folderId']}", project_id=f"{user_project['uuid']}", ) - resp = await client.put(url.path) + resp = await client.put(f"{url}") await assert_status(resp, status.HTTP_204_NO_CONTENT) # list new user specific folder - base_url = client.app.router["list_projects"].url_for() - url = base_url.with_query({"folder_id": f"{new_user_folder['folderId']}"}) - resp = await client.get(url) + url = ( + client.app.router["list_projects"] + .url_for() + .with_query({"folder_id": f"{new_user_folder['folderId']}"}) + ) + resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) assert len(data) == 1 assert data[0]["uuid"] == user_project["uuid"] @@ -394,14 +411,14 @@ async def test_folders_deletion( # create a new folder url = client.app.router["create_folder"].url_for() - resp = await client.post(url.path, json={"name": "My first folder"}) + resp = await client.post(f"{url}", json={"name": "My first folder"}) root_folder, _ = await assert_status(resp, status.HTTP_201_CREATED) assert FolderGet.parse_obj(root_folder) # create a subfolder folder url = client.app.router["create_folder"].url_for() resp = await client.post( - url.path, + f"{url}", json={ "name": "My subfolder 1", "parentFolderId": root_folder["folderId"], @@ -412,7 +429,7 @@ async def test_folders_deletion( # create a subfolder folder url = client.app.router["create_folder"].url_for() resp = await client.post( - url.path, + f"{url}", json={ "name": "My subfolder 2", "parentFolderId": root_folder["folderId"], @@ -425,13 +442,13 @@ async def test_folders_deletion( folder_id=f"{subfolder_2['folderId']}", project_id=f"{user_project['uuid']}", ) - resp = await client.put(url.path) + resp = await client.put(f"{url}") await assert_status(resp, status.HTTP_204_NO_CONTENT) # create a sub sub folder folder url = client.app.router["create_folder"].url_for() resp = await client.post( - url.path, + f"{url}", json={ "name": "My sub sub folder", "parentFolderId": subfolder_1["folderId"], @@ -441,21 +458,24 @@ async def test_folders_deletion( # list user folders url = client.app.router["list_folders"].url_for() - resp = await client.get(url.path) + resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) assert len(data) == 1 # list subfolder projects - base_url = client.app.router["list_projects"].url_for() - url = base_url.with_query({"folder_id": f"{subfolder_2['folderId']}"}) - resp = await client.get(url) + url = ( + client.app.router["list_projects"] + .url_for() + .with_query({"folder_id": f"{subfolder_2['folderId']}"}) + ) + resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) assert len(data) == 1 assert data[0]["uuid"] == user_project["uuid"] # list root projects - base_url = client.app.router["list_projects"].url_for() - resp = await client.get(base_url) + url = client.app.router["list_projects"].url_for() + resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) assert len(data) == 0 @@ -463,24 +483,27 @@ async def test_folders_deletion( url = client.app.router["delete_folder"].url_for( folder_id=f"{subfolder_1['folderId']}" ) - resp = await client.delete(url.path) + resp = await client.delete(f"{url}") await assert_status(resp, status.HTTP_204_NO_CONTENT) # delete a root folder url = client.app.router["delete_folder"].url_for( folder_id=f"{root_folder['folderId']}" ) - resp = await client.delete(url.path) + resp = await client.delete(f"{url}") await assert_status(resp, status.HTTP_204_NO_CONTENT) - fire_and_forget_tasks = client.app[APP_FIRE_AND_FORGET_TASKS_KEY] - t: asyncio.Task = list(fire_and_forget_tasks)[0] - assert t.get_name().startswith("fire_and_forget_task_delete_project_task_") - await t + fire_and_forget_task: asyncio.Task = next( + iter(client.app[APP_FIRE_AND_FORGET_TASKS_KEY]) + ) + assert fire_and_forget_task.get_name().startswith( + "fire_and_forget_task_delete_project_task_" + ) + await fire_and_forget_task assert len(client.app[APP_FIRE_AND_FORGET_TASKS_KEY]) == 0 # list root projects (The project should have been deleted) - base_url = client.app.router["list_projects"].url_for() - resp = await client.get(base_url) + url = client.app.router["list_projects"].url_for() + resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) assert len(data) == 0 diff --git a/services/web/server/tests/unit/with_dbs/04/studies_dispatcher/test_studies_dispatcher_studies_access.py b/services/web/server/tests/unit/with_dbs/04/studies_dispatcher/test_studies_dispatcher_studies_access.py index 330913490ae..3cb82c2bf20 100644 --- a/services/web/server/tests/unit/with_dbs/04/studies_dispatcher/test_studies_dispatcher_studies_access.py +++ b/services/web/server/tests/unit/with_dbs/04/studies_dispatcher/test_studies_dispatcher_studies_access.py @@ -62,10 +62,11 @@ def _assert_same_projects(got: dict, expected: dict): "workbench", "accessRights", "ui", + "trashedExplicitly", } for key in expected: if key not in exclude: - assert got[key] == expected[key], "Failed in %s" % key + assert got[key] == expected[key], f"Failed in {key}" def _is_user_authenticated(session: ClientSession) -> bool: diff --git a/tests/e2e/tutorials/sleepers_project_template_sql.csv b/tests/e2e/tutorials/sleepers_project_template_sql.csv index 29c16a6f416..6dbcd7d2a26 100644 --- a/tests/e2e/tutorials/sleepers_project_template_sql.csv +++ b/tests/e2e/tutorials/sleepers_project_template_sql.csv @@ -1,2 +1,2 @@ -id,type,uuid,name,description,thumbnail,prj_owner,creation_date,last_change_date,workbench,published,access_rights,dev,ui,classifiers,quality,hidden,workspace_id,trashed_at -10,TEMPLATE,ed6c2f58-dc16-445d-bb97-e989e2611603,Sleepers,5 sleepers interconnected,"",,2019-06-06 14:34:19.631,2019-06-06 14:34:28.647,"{""027e3ff9-3119-45dd-b8a2-2e31661a7385"": {""key"": ""simcore/services/comp/itis/sleeper"", ""version"": ""1.0.0"", ""label"": ""sleeper 0"", ""inputs"": {""in_2"": 2}, ""inputAccess"": {""in_1"": ""Invisible"", ""in_2"": ""ReadOnly""}, ""inputNodes"": [], ""outputs"": {}, ""progress"": 0, ""thumbnail"": """", ""position"": {""x"": 50, ""y"": 300}}, ""562aaea9-95ff-46f3-8e84-db8f3c9e3a39"": {""key"": ""simcore/services/comp/itis/sleeper"", ""version"": ""1.0.0"", ""label"": ""sleeper 1"", ""inputs"": {""in_1"": {""nodeUuid"": ""027e3ff9-3119-45dd-b8a2-2e31661a7385"", ""output"": ""out_1""}, ""in_2"": 2}, ""inputNodes"": [""027e3ff9-3119-45dd-b8a2-2e31661a7385""], ""outputs"": {}, ""progress"": 0, ""thumbnail"": """", ""position"": {""x"": 300, ""y"": 200}}, ""bf405067-d168-44ba-b6dc-bb3e08542f92"": {""key"": ""simcore/services/comp/itis/sleeper"", ""version"": ""1.0.0"", ""label"": ""sleeper 2"", ""inputs"": {""in_1"": {""nodeUuid"": ""562aaea9-95ff-46f3-8e84-db8f3c9e3a39"", ""output"": ""out_1""}, ""in_2"": {""nodeUuid"": ""562aaea9-95ff-46f3-8e84-db8f3c9e3a39"", ""output"": ""out_2""}}, ""inputNodes"": [""562aaea9-95ff-46f3-8e84-db8f3c9e3a39""], ""outputs"": {}, ""progress"": 0, ""thumbnail"": """", ""position"": {""x"": 550, ""y"": 200}}, ""de2578c5-431e-5065-a079-a5a0476e3c10"": {""key"": ""simcore/services/comp/itis/sleeper"", ""version"": ""1.0.0"", ""label"": ""sleeper 3"", ""inputs"": {""in_2"": {""nodeUuid"": ""027e3ff9-3119-45dd-b8a2-2e31661a7385"", ""output"": ""out_2""}}, ""inputNodes"": [""027e3ff9-3119-45dd-b8a2-2e31661a7385""], ""outputs"": {}, ""progress"": 0, ""thumbnail"": """", ""position"": {""x"": 420, ""y"": 400}}, ""de2578c5-431e-559d-aa19-dc9293e10e4c"": {""key"": ""simcore/services/comp/itis/sleeper"", ""version"": ""1.0.0"", ""label"": ""sleeper 4"", ""inputs"": {""in_1"": {""nodeUuid"": ""bf405067-d168-44ba-b6dc-bb3e08542f92"", ""output"": ""out_1""}, ""in_2"": {""nodeUuid"": ""de2578c5-431e-5065-a079-a5a0476e3c10"", ""output"": ""out_2""}}, ""inputNodes"": [""bf405067-d168-44ba-b6dc-bb3e08542f92"", ""de2578c5-431e-5065-a079-a5a0476e3c10""], ""outputs"": {}, ""progress"": 0, ""thumbnail"": """", ""position"": {""x"": 800, ""y"": 300}}}",true,"{""1"": {""read"":true, ""write"":false, ""delete"":false}}", "{}", "{}", "{}", "{}",false,, +id,type,uuid,name,description,thumbnail,prj_owner,creation_date,last_change_date,workbench,published,access_rights,dev,ui,classifiers,quality,hidden,workspace_id,trashed_at,trashed_explicitly +10,TEMPLATE,ed6c2f58-dc16-445d-bb97-e989e2611603,Sleepers,5 sleepers interconnected,"",,2019-06-06 14:34:19.631,2019-06-06 14:34:28.647,"{""027e3ff9-3119-45dd-b8a2-2e31661a7385"": {""key"": ""simcore/services/comp/itis/sleeper"", ""version"": ""1.0.0"", ""label"": ""sleeper 0"", ""inputs"": {""in_2"": 2}, ""inputAccess"": {""in_1"": ""Invisible"", ""in_2"": ""ReadOnly""}, ""inputNodes"": [], ""outputs"": {}, ""progress"": 0, ""thumbnail"": """", ""position"": {""x"": 50, ""y"": 300}}, ""562aaea9-95ff-46f3-8e84-db8f3c9e3a39"": {""key"": ""simcore/services/comp/itis/sleeper"", ""version"": ""1.0.0"", ""label"": ""sleeper 1"", ""inputs"": {""in_1"": {""nodeUuid"": ""027e3ff9-3119-45dd-b8a2-2e31661a7385"", ""output"": ""out_1""}, ""in_2"": 2}, ""inputNodes"": [""027e3ff9-3119-45dd-b8a2-2e31661a7385""], ""outputs"": {}, ""progress"": 0, ""thumbnail"": """", ""position"": {""x"": 300, ""y"": 200}}, ""bf405067-d168-44ba-b6dc-bb3e08542f92"": {""key"": ""simcore/services/comp/itis/sleeper"", ""version"": ""1.0.0"", ""label"": ""sleeper 2"", ""inputs"": {""in_1"": {""nodeUuid"": ""562aaea9-95ff-46f3-8e84-db8f3c9e3a39"", ""output"": ""out_1""}, ""in_2"": {""nodeUuid"": ""562aaea9-95ff-46f3-8e84-db8f3c9e3a39"", ""output"": ""out_2""}}, ""inputNodes"": [""562aaea9-95ff-46f3-8e84-db8f3c9e3a39""], ""outputs"": {}, ""progress"": 0, ""thumbnail"": """", ""position"": {""x"": 550, ""y"": 200}}, ""de2578c5-431e-5065-a079-a5a0476e3c10"": {""key"": ""simcore/services/comp/itis/sleeper"", ""version"": ""1.0.0"", ""label"": ""sleeper 3"", ""inputs"": {""in_2"": {""nodeUuid"": ""027e3ff9-3119-45dd-b8a2-2e31661a7385"", ""output"": ""out_2""}}, ""inputNodes"": [""027e3ff9-3119-45dd-b8a2-2e31661a7385""], ""outputs"": {}, ""progress"": 0, ""thumbnail"": """", ""position"": {""x"": 420, ""y"": 400}}, ""de2578c5-431e-559d-aa19-dc9293e10e4c"": {""key"": ""simcore/services/comp/itis/sleeper"", ""version"": ""1.0.0"", ""label"": ""sleeper 4"", ""inputs"": {""in_1"": {""nodeUuid"": ""bf405067-d168-44ba-b6dc-bb3e08542f92"", ""output"": ""out_1""}, ""in_2"": {""nodeUuid"": ""de2578c5-431e-5065-a079-a5a0476e3c10"", ""output"": ""out_2""}}, ""inputNodes"": [""bf405067-d168-44ba-b6dc-bb3e08542f92"", ""de2578c5-431e-5065-a079-a5a0476e3c10""], ""outputs"": {}, ""progress"": 0, ""thumbnail"": """", ""position"": {""x"": 800, ""y"": 300}}}",true,"{""1"": {""read"":true, ""write"":false, ""delete"":false}}", "{}", "{}", "{}", "{}",false,,,false