diff --git a/.env-devel b/.env-devel index 9ba17baebb7a..26fb29a093a7 100644 --- a/.env-devel +++ b/.env-devel @@ -334,7 +334,6 @@ LOGIN_ACCOUNT_DELETION_RETENTION_DAYS=31 LOGIN_REGISTRATION_CONFIRMATION_REQUIRED=0 LOGIN_REGISTRATION_INVITATION_REQUIRED=0 PROJECTS_INACTIVITY_INTERVAL=00:00:20 -PROJECTS_TRASH_RETENTION_DAYS=7 PROJECTS_MAX_COPY_SIZE_BYTES=30Gib PROJECTS_MAX_NUM_RUNNING_DYNAMIC_NODES=5 REST_SWAGGER_API_DOC_ENABLED=1 @@ -353,6 +352,7 @@ TRACING_OPENTELEMETRY_COLLECTOR_EXPORTER_ENDPOINT=http://jaeger:4318 TRACING_OPENTELEMETRY_COLLECTOR_PORT=4318 TRACING_OPENTELEMETRY_COLLECTOR_SAMPLING_PERCENTAGE=100 TRAEFIK_SIMCORE_ZONE=internal_simcore_stack +TRASH_RETENTION_DAYS=7 TWILIO_ACCOUNT_SID=DUMMY TWILIO_AUTH_TOKEN=DUMMY TWILIO_COUNTRY_CODES_W_ALPHANUMERIC_SID_SUPPORT=["41"] diff --git a/api/specs/web-server/_folders.py b/api/specs/web-server/_folders.py index 2aa77e485d4f..aa0f88c8d93f 100644 --- a/api/specs/web-server/_folders.py +++ b/api/specs/web-server/_folders.py @@ -19,14 +19,14 @@ from models_library.generics import Envelope from models_library.rest_error import EnvelopedError from simcore_service_webserver._meta import API_VTAG -from simcore_service_webserver.folders._exceptions_handlers import _TO_HTTP_ERROR_MAP -from simcore_service_webserver.folders._models import ( +from simcore_service_webserver.folders._common.exceptions_handlers import ( + _TO_HTTP_ERROR_MAP, +) +from simcore_service_webserver.folders._common.models import ( FolderSearchQueryParams, FoldersListQueryParams, FoldersPathParams, -) -from simcore_service_webserver.folders._workspaces_handlers import ( - _FolderWorkspacesPathParams, + FolderWorkspacesPathParams, ) router = APIRouter( @@ -109,6 +109,6 @@ async def delete_folder( tags=["workspaces"], ) async def move_folder_to_workspace( - _path: Annotated[_FolderWorkspacesPathParams, Depends()], + _path: Annotated[FolderWorkspacesPathParams, Depends()], ): ... diff --git a/api/specs/web-server/_trash.py b/api/specs/web-server/_trash.py index 6eb39f6593cf..8f0f5ea6086a 100644 --- a/api/specs/web-server/_trash.py +++ b/api/specs/web-server/_trash.py @@ -10,12 +10,12 @@ from fastapi import APIRouter, Depends, status from models_library.trash import RemoveQueryParams from simcore_service_webserver._meta import API_VTAG -from simcore_service_webserver.folders._models import ( +from simcore_service_webserver.folders._common.models import ( FoldersPathParams, FolderTrashQueryParams, ) -from simcore_service_webserver.projects._trash_handlers import ProjectPathParams -from simcore_service_webserver.workspaces._models import ( +from simcore_service_webserver.projects._trash_rest import ProjectPathParams +from simcore_service_webserver.workspaces._common.models import ( WorkspacesPathParams, WorkspaceTrashQueryParams, ) diff --git a/api/specs/web-server/_workspaces.py b/api/specs/web-server/_workspaces.py index fce290fffb0c..a86f5ceaaae6 100644 --- a/api/specs/web-server/_workspaces.py +++ b/api/specs/web-server/_workspaces.py @@ -19,14 +19,16 @@ from models_library.generics import Envelope from models_library.rest_error import EnvelopedError from simcore_service_webserver._meta import API_VTAG -from simcore_service_webserver.folders._exceptions_handlers import _TO_HTTP_ERROR_MAP -from simcore_service_webserver.workspaces._groups_api import WorkspaceGroupGet -from simcore_service_webserver.workspaces._models import ( +from simcore_service_webserver.folders._common.exceptions_handlers import ( + _TO_HTTP_ERROR_MAP, +) +from simcore_service_webserver.workspaces._common.models import ( WorkspacesGroupsBodyParams, WorkspacesGroupsPathParams, WorkspacesListQueryParams, WorkspacesPathParams, ) +from simcore_service_webserver.workspaces._groups_service import WorkspaceGroupGet router = APIRouter( prefix=f"/{API_VTAG}", diff --git a/packages/common-library/src/common_library/exclude.py b/packages/common-library/src/common_library/exclude.py index 6f635dfe643f..7f2392dec338 100644 --- a/packages/common-library/src/common_library/exclude.py +++ b/packages/common-library/src/common_library/exclude.py @@ -8,6 +8,10 @@ class UnSet: UnSet.VALUE = UnSet() +def is_unset(v: Any) -> bool: + return isinstance(v, UnSet) + + def as_dict_exclude_unset(**params) -> dict[str, Any]: return {k: v for k, v in params.items() if not isinstance(v, UnSet)} 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 deleted file mode 100644 index dd4647185710..000000000000 --- a/packages/models-library/src/models_library/api_schemas_webserver/folders.py +++ /dev/null @@ -1,48 +0,0 @@ -from datetime import datetime -from typing import NamedTuple - -from models_library.basic_types import IDStr -from models_library.folders import FolderID -from models_library.groups import GroupID -from models_library.projects_access import AccessRights -from models_library.utils.common_validators import null_or_none_str_to_none_validator -from pydantic import ConfigDict, PositiveInt, field_validator - -from ._base import InputSchema, OutputSchema - - -class FolderGet(OutputSchema): - folder_id: FolderID - parent_folder_id: FolderID | None = None - name: str - description: str - created_at: datetime - modified_at: datetime - trashed_at: datetime | None - owner: GroupID - my_access_rights: AccessRights - access_rights: dict[GroupID, AccessRights] - - -class FolderGetPage(NamedTuple): - items: list[FolderGet] - total: PositiveInt - - -class CreateFolderBodyParams(InputSchema): - name: IDStr - description: str - parent_folder_id: FolderID | None = None - - model_config = ConfigDict(extra="forbid") - - _null_or_none_str_to_none_validator = field_validator( - "parent_folder_id", mode="before" - )(null_or_none_str_to_none_validator) - - -class PutFolderBodyParams(InputSchema): - name: IDStr - description: str - - model_config = ConfigDict(extra="forbid") diff --git a/packages/models-library/src/models_library/api_schemas_webserver/groups.py b/packages/models-library/src/models_library/api_schemas_webserver/groups.py index ec9738044b41..4755e9c90af9 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/groups.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/groups.py @@ -73,7 +73,7 @@ class GroupGet(OutputSchema): ] = DEFAULT_FACTORY @classmethod - def from_model(cls, group: Group, access_rights: AccessRightsDict) -> Self: + def from_domain_model(cls, group: Group, access_rights: AccessRightsDict) -> Self: # Adapts these domain models into this schema return cls.model_validate( { @@ -151,7 +151,7 @@ class GroupCreate(InputSchema): description: str thumbnail: AnyUrl | None = None - def to_model(self) -> StandardGroupCreate: + def to_domain_model(self) -> StandardGroupCreate: data = remap_keys( self.model_dump( mode="json", @@ -169,7 +169,7 @@ class GroupUpdate(InputSchema): description: str | None = None thumbnail: AnyUrl | None = None - def to_model(self) -> StandardGroupUpdate: + def to_domain_model(self) -> StandardGroupUpdate: data = remap_keys( self.model_dump( mode="json", @@ -230,7 +230,7 @@ class MyGroupsGet(OutputSchema): ) @classmethod - def from_model( + def from_domain_model( cls, groups_by_type: GroupsByTypeTuple, my_product_group: tuple[Group, AccessRightsDict] | None, @@ -239,10 +239,12 @@ def from_model( assert groups_by_type.everyone # nosec return cls( - me=GroupGet.from_model(*groups_by_type.primary), - organizations=[GroupGet.from_model(*gi) for gi in groups_by_type.standard], - all=GroupGet.from_model(*groups_by_type.everyone), - product=GroupGet.from_model(*my_product_group) + me=GroupGet.from_domain_model(*groups_by_type.primary), + organizations=[ + GroupGet.from_domain_model(*gi) for gi in groups_by_type.standard + ], + all=GroupGet.from_domain_model(*groups_by_type.everyone), + product=GroupGet.from_domain_model(*my_product_group) if my_product_group else None, ) @@ -320,7 +322,7 @@ class GroupUserGet(OutputSchemaWithoutCamelCase): ) @classmethod - def from_model(cls, user: GroupMember) -> Self: + def from_domain_model(cls, user: GroupMember) -> Self: return cls.model_validate( { "id": user.id, diff --git a/packages/models-library/src/models_library/api_schemas_webserver/projects.py b/packages/models-library/src/models_library/api_schemas_webserver/projects.py index a595245e331b..95b6b50805fa 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/projects.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/projects.py @@ -6,8 +6,9 @@ """ from datetime import datetime -from typing import Annotated, Any, Literal, TypeAlias +from typing import Annotated, Any, Literal, Self, TypeAlias +from common_library.dict_tools import remap_keys from models_library.folders import FolderID from models_library.utils._original_fastapi_encoders import jsonable_encoder from models_library.workspaces import WorkspaceID @@ -95,6 +96,7 @@ class ProjectGet(OutputSchema): permalink: ProjectPermalink | None = None workspace_id: WorkspaceID | None folder_id: FolderID | None + trashed_at: datetime | None _empty_description = field_validator("description", mode="before")( @@ -103,6 +105,15 @@ class ProjectGet(OutputSchema): model_config = ConfigDict(frozen=False) + @classmethod + def from_domain_model(cls, project_data: dict[str, Any]) -> Self: + return cls.model_validate( + remap_keys( + project_data, + rename={"trashed": "trashed_at"}, + ) + ) + TaskProjectGet: TypeAlias = TaskGet @@ -160,6 +171,9 @@ class ProjectPatch(InputSchema): ] = Field(default=None) quality: dict[str, Any] | None = Field(default=None) + def to_domain_model(self) -> dict[str, Any]: + return self.model_dump(exclude_unset=True, by_alias=False) + __all__: tuple[str, ...] = ( "EmptyModel", diff --git a/packages/models-library/src/models_library/api_schemas_webserver/users.py b/packages/models-library/src/models_library/api_schemas_webserver/users.py index f5f49bf726cb..0f6e010a4a85 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/users.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/users.py @@ -108,7 +108,7 @@ def _to_upper_string(cls, v): return v @classmethod - def from_model( + def from_domain_model( cls, my_profile: MyProfile, my_groups_by_type: GroupsByTypeTuple, @@ -133,7 +133,7 @@ def from_model( ) return cls( **data, - groups=MyGroupsGet.from_model(my_groups_by_type, my_product_group), + groups=MyGroupsGet.from_domain_model(my_groups_by_type, my_product_group), preferences=my_preferences, ) @@ -221,7 +221,7 @@ class UserGet(OutputSchema): email: EmailStr | None = None @classmethod - def from_model(cls, data): + def from_domain_model(cls, data): return cls.model_validate(data, from_attributes=True) @@ -293,7 +293,7 @@ class MyTokenCreate(InputSchemaWithoutCamelCase): token_key: IDStr token_secret: IDStr - def to_model(self) -> UserThirdPartyToken: + def to_domain_model(self) -> UserThirdPartyToken: return UserThirdPartyToken( service=self.service, token_key=self.token_key, @@ -309,7 +309,7 @@ class MyTokenGet(OutputSchemaWithoutCamelCase): ] = None @classmethod - def from_model(cls, token: UserThirdPartyToken) -> Self: + def from_domain_model(cls, token: UserThirdPartyToken) -> Self: return cls( service=token.service, # type: ignore[arg-type] token_key=token.token_key, # type: ignore[arg-type] @@ -327,5 +327,5 @@ class MyPermissionGet(OutputSchema): allowed: bool @classmethod - def from_model(cls, permission: UserPermission) -> Self: + def from_domain_model(cls, permission: UserPermission) -> Self: return cls(name=permission.name, allowed=permission.allowed) diff --git a/packages/models-library/src/models_library/api_schemas_webserver/workspaces.py b/packages/models-library/src/models_library/api_schemas_webserver/workspaces.py index de3b0640b981..17762e9efea7 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/workspaces.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/workspaces.py @@ -1,13 +1,13 @@ from datetime import datetime -from typing import NamedTuple +from typing import Self -from models_library.basic_types import IDStr -from models_library.groups import GroupID -from models_library.workspaces import WorkspaceID -from pydantic import ConfigDict, PositiveInt +from pydantic import ConfigDict from ..access_rights import AccessRights +from ..basic_types import IDStr +from ..groups import GroupID from ..users import UserID +from ..workspaces import UserWorkspaceWithAccessRights, WorkspaceID from ._base import InputSchema, OutputSchema @@ -23,10 +23,20 @@ class WorkspaceGet(OutputSchema): my_access_rights: AccessRights access_rights: dict[GroupID, AccessRights] - -class WorkspaceGetPage(NamedTuple): - items: list[WorkspaceGet] - total: PositiveInt + @classmethod + def from_domain_model(cls, wks: UserWorkspaceWithAccessRights) -> Self: + return cls( + workspace_id=wks.workspace_id, + name=wks.name, + description=wks.description, + thumbnail=wks.thumbnail, + created_at=wks.created, + modified_at=wks.modified, + trashed_at=wks.trashed, + trashed_by=wks.trashed_by if wks.trashed else None, + my_access_rights=wks.my_access_rights, + access_rights=wks.access_rights, + ) class WorkspaceCreateBodyParams(InputSchema): diff --git a/packages/models-library/src/models_library/folders.py b/packages/models-library/src/models_library/folders.py index 554311731119..0a3821fc9873 100644 --- a/packages/models-library/src/models_library/folders.py +++ b/packages/models-library/src/models_library/folders.py @@ -64,7 +64,7 @@ class FolderDB(BaseModel): ..., description="Timestamp of last modification", ) - trashed_at: datetime | None = Field( + trashed: datetime | None = Field( ..., ) diff --git a/packages/models-library/src/models_library/projects.py b/packages/models-library/src/models_library/projects.py index c25309dac3e2..81f230768cc7 100644 --- a/packages/models-library/src/models_library/projects.py +++ b/packages/models-library/src/models_library/projects.py @@ -20,6 +20,7 @@ from .projects_nodes_io import NodeIDStr from .projects_state import ProjectState from .projects_ui import StudyUI +from .users import UserID from .utils.common_validators import ( empty_str_to_none_pre_validator, none_to_empty_str_pre_validator, @@ -182,10 +183,10 @@ class Project(BaseProjectModel): alias="folderId", ) - trashed_at: datetime | None = Field( - default=None, - alias="trashedAt", - ) - trashed_explicitly: bool = Field(default=False, alias="trashedExplicitly") + trashed: datetime | None = None + trashed_by: Annotated[UserID | None, Field(alias="trashedBy")] = None + trashed_explicitly: Annotated[bool, Field(alias="trashedExplicitly")] = False - model_config = ConfigDict(title="osparc-simcore project", extra="forbid") + model_config = ConfigDict( + extra="forbid", + ) diff --git a/packages/models-library/src/models_library/workspaces.py b/packages/models-library/src/models_library/workspaces.py index f6f33061bfe6..01f66685fa12 100644 --- a/packages/models-library/src/models_library/workspaces.py +++ b/packages/models-library/src/models_library/workspaces.py @@ -31,7 +31,7 @@ class WorkspaceQuery(BaseModel): @field_validator("workspace_id", mode="before") @classmethod - def validate_workspace_id(cls, value, info: ValidationInfo): + def _validate_workspace_id(cls, value, info: ValidationInfo): scope = info.data.get("workspace_scope") if scope == WorkspaceScope.SHARED and value is None: msg = f"workspace_id must be provided when workspace_scope is SHARED. Got {scope=}, {value=}" @@ -43,12 +43,7 @@ def validate_workspace_id(cls, value, info: ValidationInfo): return value -# -# DB -# - - -class WorkspaceDB(BaseModel): +class Workspace(BaseModel): workspace_id: WorkspaceID name: str description: str | None @@ -71,16 +66,16 @@ class WorkspaceDB(BaseModel): model_config = ConfigDict(from_attributes=True) -class UserWorkspaceAccessRightsDB(WorkspaceDB): - my_access_rights: AccessRights - access_rights: dict[GroupID, AccessRights] - - model_config = ConfigDict(from_attributes=True) - - -class WorkspaceUpdateDB(BaseModel): +class WorkspaceUpdates(BaseModel): name: str | None = None description: str | None = None thumbnail: str | None = None trashed: datetime | None = None trashed_by: UserID | None = None + + +class UserWorkspaceWithAccessRights(Workspace): + my_access_rights: AccessRights + access_rights: dict[GroupID, AccessRights] + + model_config = ConfigDict(from_attributes=True) diff --git a/packages/models-library/tests/test_users.py b/packages/models-library/tests/test_users.py index 97496e133a93..4c9d2756934e 100644 --- a/packages/models-library/tests/test_users.py +++ b/packages/models-library/tests/test_users.py @@ -22,6 +22,6 @@ def test_adapter_from_model_to_schema(): ) my_preferences = {"foo": Preference(default_value=3, value=1)} - MyProfileGet.from_model( + MyProfileGet.from_domain_model( my_profile, my_groups_by_type, my_product_group, my_preferences ) diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/f19905923355_adds_trashed_by_column.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/f19905923355_adds_trashed_by_column.py new file mode 100644 index 000000000000..0f7de4587911 --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/f19905923355_adds_trashed_by_column.py @@ -0,0 +1,84 @@ +"""Adds trashed by column + +Revision ID: f19905923355 +Revises: 307017ee1a49 +Create Date: 2025-01-10 16:43:21.559138+00:00 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "f19905923355" +down_revision = "307017ee1a49" +branch_labels = None +depends_on = None + + +def upgrade(): + + with op.batch_alter_table("folders_v2") as batch_op: + batch_op.alter_column( + "trashed_at", + new_column_name="trashed", + comment="The date and time when the folders was marked as trashed. Null if the folders has not been trashed [default].", + ) + batch_op.add_column( + sa.Column( + "trashed_by", + sa.BigInteger(), + nullable=True, + comment="User who trashed the folders, or null if not trashed or user is unknown.", + ) + ) + batch_op.create_foreign_key( + "fk_folders_trashed_by_user_id", + "users", + ["trashed_by"], + ["id"], + onupdate="CASCADE", + ondelete="SET NULL", + ) + + with op.batch_alter_table("projects") as batch_op: + batch_op.alter_column( + "trashed_at", + new_column_name="trashed", + comment="The date and time when the projects was marked as trashed. Null if the projects has not been trashed [default].", + ) + batch_op.add_column( + sa.Column( + "trashed_by", + sa.BigInteger(), + nullable=True, + comment="User who trashed the projects, or null if not trashed or user is unknown.", + ) + ) + batch_op.create_foreign_key( + "fk_projects_trashed_by_user_id", + "users", + ["trashed_by"], + ["id"], + onupdate="CASCADE", + ondelete="SET NULL", + ) + + +def downgrade(): + with op.batch_alter_table("projects") as batch_op: + batch_op.drop_constraint("fk_projects_trashed_by_user_id", type_="foreignkey") + batch_op.drop_column("trashed_by") + batch_op.alter_column( + "trashed", + new_column_name="trashed_at", + comment="The date and time when the project was marked as trashed. Null if the project has not been trashed [default].", + ) + + with op.batch_alter_table("folders_v2") as batch_op: + batch_op.drop_constraint("fk_folders_trashed_by_user_id", type_="foreignkey") + batch_op.drop_column("trashed_by") + batch_op.alter_column( + "trashed", + new_column_name="trashed_at", + comment="The date and time when the folder was marked as trashed. Null if the folder has not been trashed [default].", + ) 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 78f3de8bdf9d..eebfd2079f80 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,8 +1,15 @@ import sqlalchemy as sa from sqlalchemy.sql import expression -from ._common import RefActions, column_created_datetime, column_modified_datetime +from ._common import ( + RefActions, + column_created_datetime, + column_modified_datetime, + column_trashed_by_user, + column_trashed_datetime, +) from .base import metadata +from .users import users from .workspaces import workspaces folders_v2 = sa.Table( @@ -75,13 +82,8 @@ ), 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].", - ), + column_trashed_datetime("folders"), + column_trashed_by_user("folders", users_table=users), sa.Column( "trashed_explicitly", sa.Boolean, 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 93ff3a74ea3b..e13f9bc42214 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/projects.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/projects.py @@ -7,8 +7,9 @@ from sqlalchemy.dialects.postgresql import ARRAY, JSONB from sqlalchemy.sql import expression, func -from ._common import RefActions +from ._common import RefActions, column_trashed_by_user, column_trashed_datetime from .base import metadata +from .users import users class ProjectType(enum.Enum): @@ -142,13 +143,8 @@ class ProjectType(enum.Enum): default=False, doc="If true, the project is by default not listed in the API", ), - sa.Column( - "trashed_at", - sa.DateTime(timezone=True), - nullable=True, - comment="The date and time when the project was marked as trashed. " - "Null if the project has not been trashed [default].", - ), + column_trashed_datetime("projects"), + column_trashed_by_user("projects", users_table=users), sa.Column( "trashed_explicitly", sa.Boolean, diff --git a/packages/postgres-database/tests/test_utils_projects.py b/packages/postgres-database/tests/test_utils_projects.py index c97c822090f6..be4cde5f180d 100644 --- a/packages/postgres-database/tests/test_utils_projects.py +++ b/packages/postgres-database/tests/test_utils_projects.py @@ -54,7 +54,7 @@ async def registered_project( @pytest.mark.parametrize("expected", (datetime.now(tz=UTC), None)) -async def test_get_project_trashed_at_column_can_be_converted_to_datetime( +async def test_get_project_trashed_column_can_be_converted_to_datetime( asyncpg_engine: AsyncEngine, registered_project: dict, expected: datetime | None ): project_id = registered_project["uuid"] @@ -62,15 +62,16 @@ async def test_get_project_trashed_at_column_can_be_converted_to_datetime( async with transaction_context(asyncpg_engine) as conn: result = await conn.execute( projects.update() - .values(trashed_at=expected) + .values(trashed=expected) .where(projects.c.uuid == project_id) .returning(sa.literal_column("*")) ) row = result.fetchone() - trashed_at = TypeAdapter(datetime | None).validate_python(row.trashed_at) - assert trashed_at == expected + assert row + trashed = TypeAdapter(datetime | None).validate_python(row.trashed) + assert trashed == expected async def test_get_project_last_change_date( diff --git a/packages/pytest-simcore/src/pytest_simcore/helpers/postgres_tools.py b/packages/pytest-simcore/src/pytest_simcore/helpers/postgres_tools.py index 6587f9052fa5..5803adee9f84 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/postgres_tools.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/postgres_tools.py @@ -49,11 +49,19 @@ def migrated_pg_tables_context( simcore_postgres_database.cli.downgrade.callback("base") simcore_postgres_database.cli.clean.callback() # just cleans discover cache - # FIXME: migration downgrade fails to remove User types - # SEE https://github.com/ITISFoundation/osparc-simcore/issues/1776 - # Added drop_all as tmp fix postgres_engine = sa.create_engine(dsn) - metadata.drop_all(bind=postgres_engine) + with postgres_engine.begin() as conn: + conn.execute( + # NOTE: terminates all open transactions before droping all tables + # This solves https://github.com/ITISFoundation/osparc-simcore/issues/7008 + sa.DDL( + "SELECT pg_terminate_backend(pid) " + "FROM pg_stat_activity " + "WHERE state = 'idle in transaction';" + ) + ) + # SEE https://github.com/ITISFoundation/osparc-simcore/issues/1776 + metadata.drop_all(bind=postgres_engine) def is_postgres_responsive(url) -> bool: 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 092ab82d6556..77515cffad5f 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/webserver_projects.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/webserver_projects.py @@ -12,6 +12,7 @@ from aiohttp import web from aiohttp.test_utils import TestClient +from common_library.dict_tools import remap_keys from models_library.projects_nodes_io import NodeID from models_library.services_resources import ServiceResourcesDictHelpers from simcore_postgres_database.utils_projects_nodes import ProjectNodeCreate @@ -113,6 +114,10 @@ async def create_project( for key in DB_EXCLUSIVE_COLUMNS: project_data.pop(key, None) + new_project: ProjectDict = remap_keys( + new_project, + rename={"trashed": "trashedAt"}, + ) return new_project @@ -163,6 +168,7 @@ async def __aenter__(self) -> ProjectDict: default_project_json=self.tests_data_dir / "fake-project.json", as_template=self.as_template, ) + return self.prj async def __aexit__(self, *args): diff --git a/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py b/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py index be032c8f6f4d..cc31177abcec 100644 --- a/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py +++ b/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py @@ -28,7 +28,7 @@ def _groupget_model_dump(group, access_rights) -> dict[str, Any]: - return GroupGet.from_model(group, access_rights).model_dump( + return GroupGet.from_domain_model(group, access_rights).model_dump( mode="json", by_alias=True, exclude_unset=True, diff --git a/services/docker-compose.yml b/services/docker-compose.yml index 766c117244de..0f589739ad38 100644 --- a/services/docker-compose.yml +++ b/services/docker-compose.yml @@ -778,7 +778,7 @@ services: PROJECTS_INACTIVITY_INTERVAL: ${PROJECTS_INACTIVITY_INTERVAL} PROJECTS_MAX_COPY_SIZE_BYTES: ${PROJECTS_MAX_COPY_SIZE_BYTES} PROJECTS_MAX_NUM_RUNNING_DYNAMIC_NODES: ${PROJECTS_MAX_NUM_RUNNING_DYNAMIC_NODES} - PROJECTS_TRASH_RETENTION_DAYS: ${PROJECTS_TRASH_RETENTION_DAYS} + # WEBSERVER_RABBITMQ RABBIT_HOST: ${RABBIT_HOST} @@ -787,6 +787,10 @@ services: RABBIT_SECURE: ${RABBIT_SECURE} RABBIT_USER: ${RABBIT_USER} + # WEBSERVER_TRASH + TRASH_RETENTION_DAYS: ${TRASH_RETENTION_DAYS} + + # ARBITRARY ENV VARS # see [https://docs.gunicorn.org/en/stable/settings.html#timeout], diff --git a/services/static-webserver/client/source/class/osparc/store/StaticInfo.js b/services/static-webserver/client/source/class/osparc/store/StaticInfo.js index 2ac96fd58b06..69a28b57f7b8 100644 --- a/services/static-webserver/client/source/class/osparc/store/StaticInfo.js +++ b/services/static-webserver/client/source/class/osparc/store/StaticInfo.js @@ -70,9 +70,9 @@ qx.Class.define("osparc.store.StaticInfo", { }, getTrashRetentionDays: function() { - const staticKey = "webserverProjects"; + const staticKey = "webserverTrash"; const wsStaticData = this.getValue(staticKey); - const key = "PROJECTS_TRASH_RETENTION_DAYS"; + const key = "TRASH_RETENTION_DAYS"; if (key in wsStaticData) { return wsStaticData[key]; } diff --git a/services/web/server/src/simcore_service_webserver/application.py b/services/web/server/src/simcore_service_webserver/application.py index a868e3453807..e8825167f66d 100644 --- a/services/web/server/src/simcore_service_webserver/application.py +++ b/services/web/server/src/simcore_service_webserver/application.py @@ -49,6 +49,7 @@ from .studies_dispatcher.plugin import setup_studies_dispatcher from .tags.plugin import setup_tags from .tracing import setup_app_tracing +from .trash.plugin import setup_trash from .users.plugin import setup_users from .version_control.plugin import setup_version_control from .wallets.plugin import setup_wallets @@ -143,6 +144,9 @@ def create_application() -> web.Application: # licenses setup_licenses(app) + # trash add-on + setup_trash(app) + # tagging setup_scicrunch(app) setup_tags(app) diff --git a/services/web/server/src/simcore_service_webserver/application_settings.py b/services/web/server/src/simcore_service_webserver/application_settings.py index 676725f65b8d..8570a79ad2ff 100644 --- a/services/web/server/src/simcore_service_webserver/application_settings.py +++ b/services/web/server/src/simcore_service_webserver/application_settings.py @@ -53,6 +53,7 @@ from .statics.settings import FrontEndAppSettings, StaticWebserverModuleSettings from .storage.settings import StorageSettings from .studies_dispatcher.settings import StudiesDispatcherSettings +from .trash.settings import TrashSettings from .users.settings import UsersSettings _logger = logging.getLogger(__name__) @@ -258,6 +259,10 @@ class ApplicationSettings(BaseCustomSettings, MixinLoggingSettings): description="tracing plugin", json_schema_extra={"auto_default_from_env": True} ) + WEBSERVER_TRASH: Annotated[ + TrashSettings, Field(json_schema_extra={"auto_default_from_env": True}) + ] + WEBSERVER_RABBITMQ: Annotated[ RabbitSettings | None, Field( @@ -448,7 +453,9 @@ def to_client_statics(self) -> dict[str, Any]: "SWARM_STACK_NAME": True, "WEBSERVER_PROJECTS": { "PROJECTS_MAX_NUM_RUNNING_DYNAMIC_NODES", - "PROJECTS_TRASH_RETENTION_DAYS", + }, + "WEBSERVER_TRASH": { + "TRASH_RETENTION_DAYS", }, "WEBSERVER_LOGIN": { "LOGIN_ACCOUNT_DELETION_RETENTION_DAYS", diff --git a/services/web/server/src/simcore_service_webserver/folders/_common/__init__.py b/services/web/server/src/simcore_service_webserver/folders/_common/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/services/web/server/src/simcore_service_webserver/folders/_exceptions_handlers.py b/services/web/server/src/simcore_service_webserver/folders/_common/exceptions_handlers.py similarity index 94% rename from services/web/server/src/simcore_service_webserver/folders/_exceptions_handlers.py rename to services/web/server/src/simcore_service_webserver/folders/_common/exceptions_handlers.py index 8b571562c8d8..d117b870c970 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_exceptions_handlers.py +++ b/services/web/server/src/simcore_service_webserver/folders/_common/exceptions_handlers.py @@ -2,23 +2,23 @@ from servicelib.aiohttp import status -from ..exception_handling import ( +from ...exception_handling import ( ExceptionToHttpErrorMap, HttpErrorInfo, exception_handling_decorator, to_exceptions_handlers_map, ) -from ..projects.exceptions import ( +from ...projects.exceptions import ( ProjectInvalidRightsError, ProjectRunningConflictError, ProjectStoppingError, ) -from ..workspaces.errors import ( +from ...workspaces.errors import ( WorkspaceAccessForbiddenError, WorkspaceFolderInconsistencyError, WorkspaceNotFoundError, ) -from .errors import ( +from ..errors import ( FolderAccessForbiddenError, FolderNotFoundError, FoldersValueError, diff --git a/services/web/server/src/simcore_service_webserver/folders/_models.py b/services/web/server/src/simcore_service_webserver/folders/_common/models.py similarity index 96% rename from services/web/server/src/simcore_service_webserver/folders/_models.py rename to services/web/server/src/simcore_service_webserver/folders/_common/models.py index 553d43bd64c2..551c531d74c1 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_models.py +++ b/services/web/server/src/simcore_service_webserver/folders/_common/models.py @@ -20,7 +20,7 @@ from models_library.workspaces import WorkspaceID from pydantic import BaseModel, BeforeValidator, ConfigDict, Field -from .._constants import RQ_PRODUCT_KEY, RQT_USERID_KEY +from ..._constants import RQ_PRODUCT_KEY, RQT_USERID_KEY _logger = logging.getLogger(__name__) @@ -89,7 +89,7 @@ class FolderTrashQueryParams(RemoveQueryParams): ... -class _FolderWorkspacesPathParams(BaseModel): +class FolderWorkspacesPathParams(BaseModel): folder_id: FolderID workspace_id: Annotated[ WorkspaceID | None, BeforeValidator(null_or_none_str_to_none_validator) diff --git a/services/web/server/src/simcore_service_webserver/folders/_folders_db.py b/services/web/server/src/simcore_service_webserver/folders/_folders_repository.py similarity index 91% rename from services/web/server/src/simcore_service_webserver/folders/_folders_db.py rename to services/web/server/src/simcore_service_webserver/folders/_folders_repository.py index 32ff9e4d3a53..243fba7e858e 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_folders_db.py +++ b/services/web/server/src/simcore_service_webserver/folders/_folders_repository.py @@ -55,7 +55,7 @@ folders_v2.c.created_by_gid, folders_v2.c.created, folders_v2.c.modified, - folders_v2.c.trashed_at, + folders_v2.c.trashed, folders_v2.c.user_id, folders_v2.c.workspace_id, ) @@ -95,40 +95,17 @@ async def create( return FolderDB.model_validate(row) -async def list_( # pylint: disable=too-many-arguments,too-many-branches - app: web.Application, - connection: AsyncConnection | None = None, - *, +def _create_private_workspace_query( product_name: ProductName, user_id: UserID, - # hierarchy filters - folder_query: FolderQuery, - workspace_query: WorkspaceQuery, - # attribute filters - filter_trashed: bool | None, - filter_by_text: str | None, - # pagination - offset: NonNegativeInt, - limit: int, - # order - order_by: OrderBy, -) -> tuple[int, list[UserFolderAccessRightsDB]]: - """ - folder_query - Used to filter in which folder we want to list folders. - trashed - If set to true, it returns folders **explicitly** trashed, if false then non-trashed folders. - """ - - workspace_access_rights_subquery = create_my_workspace_access_rights_subquery( - user_id=user_id - ) - - if workspace_query.workspace_scope is not WorkspaceScope.SHARED: - assert workspace_query.workspace_scope in ( # nosec + workspace_scope: WorkspaceScope, +): + if workspace_scope is not WorkspaceScope.SHARED: + assert workspace_scope in ( # nosec WorkspaceScope.PRIVATE, WorkspaceScope.ALL, ) - - private_workspace_query = ( + return ( select( *_SELECTION_ARGS, func.json_build_object( @@ -146,15 +123,25 @@ async def list_( # pylint: disable=too-many-arguments,too-many-branches & (folders_v2.c.user_id == user_id) ) ) - else: - private_workspace_query = None + return None + - if workspace_query.workspace_scope is not WorkspaceScope.PRIVATE: - assert workspace_query.workspace_scope in ( # nosec +def _create_shared_workspace_query( + product_name: ProductName, + user_id: UserID, + workspace_scope: WorkspaceScope, + workspace_id: WorkspaceID | None, +): + if workspace_scope is not WorkspaceScope.PRIVATE: + assert workspace_scope in ( # nosec WorkspaceScope.SHARED, WorkspaceScope.ALL, ) + workspace_access_rights_subquery = create_my_workspace_access_rights_subquery( + user_id=user_id + ) + shared_workspace_query = ( select( *_SELECTION_ARGS, workspace_access_rights_subquery.c.my_access_rights @@ -172,24 +159,62 @@ async def list_( # pylint: disable=too-many-arguments,too-many-branches ) ) - if workspace_query.workspace_scope == WorkspaceScope.SHARED: + if workspace_scope == WorkspaceScope.SHARED: shared_workspace_query = shared_workspace_query.where( - folders_v2.c.workspace_id == workspace_query.workspace_id + folders_v2.c.workspace_id == workspace_id ) else: shared_workspace_query = None + return shared_workspace_query + + +async def list_( # pylint: disable=too-many-arguments,too-many-branches + app: web.Application, + connection: AsyncConnection | None = None, + *, + product_name: ProductName, + user_id: UserID, + # hierarchy filters + folder_query: FolderQuery, + workspace_query: WorkspaceQuery, + # attribute filters + filter_trashed: bool | None, + filter_by_text: str | None, + # pagination + offset: NonNegativeInt, + limit: int, + # order + order_by: OrderBy, +) -> tuple[int, list[UserFolderAccessRightsDB]]: + """ + folder_query - Used to filter in which folder we want to list folders. + trashed - If set to true, it returns folders **explicitly** trashed, if false then non-trashed folders. + """ + + private_workspace_query = _create_private_workspace_query( + workspace_scope=workspace_query.workspace_scope, + product_name=product_name, + user_id=user_id, + ) + shared_workspace_query = _create_shared_workspace_query( + workspace_scope=workspace_query.workspace_scope, + product_name=product_name, + user_id=user_id, + workspace_id=workspace_query.workspace_id, + ) + attributes_filters: list[ColumnElement] = [] if filter_trashed is not None: attributes_filters.append( ( - (folders_v2.c.trashed_at.is_not(None)) + (folders_v2.c.trashed.is_not(None)) & (folders_v2.c.trashed_explicitly.is_(True)) ) if filter_trashed - else folders_v2.c.trashed_at.is_(None) + else folders_v2.c.trashed.is_(None) ) if folder_query.folder_scope is not FolderScope.ALL: if folder_query.folder_scope == FolderScope.SPECIFIC: @@ -275,7 +300,7 @@ async def get_for_user_or_workspace( *, folder_id: FolderID, product_name: ProductName, - user_id: UserID | None, + user_id: UserID | None, # owned workspace_id: WorkspaceID | None, ) -> FolderDB: assert not ( @@ -307,6 +332,7 @@ async def get_for_user_or_workspace( async def update( + # pylint: disable=too-many-arguments app: web.Application, connection: AsyncConnection | None = None, *, @@ -315,10 +341,11 @@ async def update( # updatable columns name: str | UnSet = UnSet.VALUE, parent_folder_id: FolderID | None | UnSet = UnSet.VALUE, - trashed_at: datetime | None | UnSet = UnSet.VALUE, + trashed: datetime | None | UnSet = UnSet.VALUE, trashed_explicitly: bool | UnSet = UnSet.VALUE, + trashed_by: UserID | UnSet = UnSet.VALUE, # who trashed workspace_id: WorkspaceID | None | UnSet = UnSet.VALUE, - user_id: UserID | None | UnSet = UnSet.VALUE, + user_id: UserID | None | UnSet = UnSet.VALUE, # ownership ) -> FolderDB: """ Batch/single patch of folder/s @@ -327,10 +354,11 @@ async def update( updated = as_dict_exclude_unset( name=name, parent_folder_id=parent_folder_id, - trashed_at=trashed_at, + trashed=trashed, + trashed_by=trashed_by, # (who trashed) trashed_explicitly=trashed_explicitly, workspace_id=workspace_id, - user_id=user_id, + user_id=user_id, # (who owns) ) 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_rest.py similarity index 89% rename from services/web/server/src/simcore_service_webserver/folders/_folders_handlers.py rename to services/web/server/src/simcore_service_webserver/folders/_folders_rest.py index e1dea38ecae6..80da8bd21e83 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_folders_handlers.py +++ b/services/web/server/src/simcore_service_webserver/folders/_folders_rest.py @@ -8,7 +8,7 @@ FolderReplaceBodyParams, ) from models_library.rest_ordering import OrderBy -from models_library.rest_pagination import Page +from models_library.rest_pagination import ItemT, Page from models_library.rest_pagination_utils import paginate_data from servicelib.aiohttp import status from servicelib.aiohttp.requests_validation import ( @@ -23,9 +23,9 @@ from ..login.decorators import login_required from ..security.decorators import permission_required from ..utils_aiohttp import envelope_json_response -from . import _folders_api -from ._exceptions_handlers import handle_plugin_requests_exceptions -from ._models import ( +from . import _folders_service +from ._common.exceptions_handlers import handle_plugin_requests_exceptions +from ._common.models import ( FolderFilters, FolderSearchQueryParams, FoldersListQueryParams, @@ -39,6 +39,13 @@ routes = web.RouteTableDef() +def _create_json_response_from_page(page: Page[ItemT]): + return web.Response( + text=page.model_dump_json(**RESPONSE_MODEL_POLICY), + content_type=MIMETYPE_APPLICATION_JSON, + ) + + @routes.post(f"/{VTAG}/folders", name="create_folder") @login_required @permission_required("folder.create") @@ -47,7 +54,7 @@ async def create_folder(request: web.Request): req_ctx = FoldersRequestContext.model_validate(request) body_params = await parse_request_body_as(FolderCreateBodyParams, request) - folder = await _folders_api.create_folder( + folder = await _folders_service.create_folder( request.app, user_id=req_ctx.user_id, name=body_params.name, @@ -72,7 +79,7 @@ async def list_folders(request: web.Request): if not query_params.filters: query_params.filters = FolderFilters() - folders: FolderGetPage = await _folders_api.list_folders( + folders: FolderGetPage = await _folders_service.list_folders( app=request.app, user_id=req_ctx.user_id, product_name=req_ctx.product_name, @@ -93,10 +100,7 @@ async def list_folders(request: web.Request): offset=query_params.offset, ) ) - return web.Response( - text=page.model_dump_json(**RESPONSE_MODEL_POLICY), - content_type=MIMETYPE_APPLICATION_JSON, - ) + return _create_json_response_from_page(page) @routes.get(f"/{VTAG}/folders:search", name="list_folders_full_search") @@ -112,7 +116,7 @@ async def list_folders_full_search(request: web.Request): if not query_params.filters: query_params.filters = FolderFilters() - folders: FolderGetPage = await _folders_api.list_folders_full_depth( + folders: FolderGetPage = await _folders_service.list_folders_full_depth( app=request.app, user_id=req_ctx.user_id, product_name=req_ctx.product_name, @@ -132,10 +136,7 @@ async def list_folders_full_search(request: web.Request): offset=query_params.offset, ) ) - return web.Response( - text=page.model_dump_json(**RESPONSE_MODEL_POLICY), - content_type=MIMETYPE_APPLICATION_JSON, - ) + return _create_json_response_from_page(page) @routes.get(f"/{VTAG}/folders/{{folder_id}}", name="get_folder") @@ -146,7 +147,7 @@ async def get_folder(request: web.Request): req_ctx = FoldersRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(FoldersPathParams, request) - folder: FolderGet = await _folders_api.get_folder( + folder: FolderGet = await _folders_service.get_folder( app=request.app, folder_id=path_params.folder_id, user_id=req_ctx.user_id, @@ -168,7 +169,7 @@ async def replace_folder(request: web.Request): path_params = parse_request_path_parameters_as(FoldersPathParams, request) body_params = await parse_request_body_as(FolderReplaceBodyParams, request) - folder = await _folders_api.update_folder( + folder = await _folders_service.update_folder( app=request.app, user_id=req_ctx.user_id, folder_id=path_params.folder_id, @@ -190,7 +191,7 @@ async def delete_folder_group(request: web.Request): req_ctx = FoldersRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(FoldersPathParams, request) - await _folders_api.delete_folder( + await _folders_service.delete_folder( app=request.app, user_id=req_ctx.user_id, folder_id=path_params.folder_id, diff --git a/services/web/server/src/simcore_service_webserver/folders/_folders_api.py b/services/web/server/src/simcore_service_webserver/folders/_folders_service.py similarity index 94% rename from services/web/server/src/simcore_service_webserver/folders/_folders_api.py rename to services/web/server/src/simcore_service_webserver/folders/_folders_service.py index 6cd65316b057..e99605125586 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_folders_api.py +++ b/services/web/server/src/simcore_service_webserver/folders/_folders_service.py @@ -16,7 +16,6 @@ from servicelib.common_headers import UNDEFINED_DEFAULT_SIMCORE_USER_AGENT_VALUE from servicelib.utils import fire_and_forget_task -from ..folders.errors import FolderValueNotPermittedError from ..projects.projects_api import submit_delete_project_task from ..users.api import get_user from ..workspaces.api import check_user_workspace_access @@ -24,7 +23,8 @@ WorkspaceAccessForbiddenError, WorkspaceFolderInconsistencyError, ) -from . import _folders_db as folders_db +from . import _folders_repository as folders_db +from .errors import FolderValueNotPermittedError _logger = logging.getLogger(__name__) @@ -92,7 +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, + trashed_at=folder_db.trashed, owner=folder_db.created_by_gid, workspace_id=workspace_id, my_access_rights=user_folder_access_rights, @@ -135,7 +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, + trashed_at=folder_db.trashed, owner=folder_db.created_by_gid, workspace_id=folder_db.workspace_id, my_access_rights=user_folder_access_rights, @@ -185,7 +185,7 @@ async def list_folders( name=folder.name, created_at=folder.created, modified_at=folder.modified, - trashed_at=folder.trashed_at, + trashed_at=folder.trashed, owner=folder.created_by_gid, workspace_id=folder.workspace_id, my_access_rights=folder.my_access_rights, @@ -229,7 +229,7 @@ async def list_folders_full_depth( name=folder.name, created_at=folder.created, modified_at=folder.modified, - trashed_at=folder.trashed_at, + trashed_at=folder.trashed, owner=folder.created_by_gid, workspace_id=folder.workspace_id, my_access_rights=folder.my_access_rights, @@ -306,7 +306,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, + trashed_at=folder_db.trashed, 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/_trash_handlers.py b/services/web/server/src/simcore_service_webserver/folders/_trash_rest.py similarity index 87% rename from services/web/server/src/simcore_service_webserver/folders/_trash_handlers.py rename to services/web/server/src/simcore_service_webserver/folders/_trash_rest.py index c702d8a70820..0e035012adb1 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_trash_handlers.py +++ b/services/web/server/src/simcore_service_webserver/folders/_trash_rest.py @@ -11,9 +11,9 @@ 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, FolderTrashQueryParams +from . import _trash_service +from ._common.exceptions_handlers import handle_plugin_requests_exceptions +from ._common.models import FoldersPathParams, FolderTrashQueryParams _logger = logging.getLogger(__name__) @@ -33,7 +33,7 @@ async def trash_folder(request: web.Request): FolderTrashQueryParams, request ) - await _trash_api.trash_folder( + await _trash_service.trash_folder( request.app, product_name=product_name, user_id=user_id, @@ -53,7 +53,7 @@ async def untrash_folder(request: web.Request): product_name = get_product_name(request) path_params = parse_request_path_parameters_as(FoldersPathParams, request) - await _trash_api.untrash_folder( + await _trash_service.untrash_folder( request.app, product_name=product_name, user_id=user_id, diff --git a/services/web/server/src/simcore_service_webserver/folders/_trash_api.py b/services/web/server/src/simcore_service_webserver/folders/_trash_service.py similarity index 86% rename from services/web/server/src/simcore_service_webserver/folders/_trash_api.py rename to services/web/server/src/simcore_service_webserver/folders/_trash_service.py index b3e1823369a2..ba1c9f749201 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_trash_api.py +++ b/services/web/server/src/simcore_service_webserver/folders/_trash_service.py @@ -11,9 +11,9 @@ from sqlalchemy.ext.asyncio import AsyncConnection from ..db.plugin import get_asyncpg_engine -from ..projects._trash_api import trash_project, untrash_project +from ..projects._trash_service import trash_project, untrash_project from ..workspaces.api import check_user_workspace_access -from . import _folders_db +from . import _folders_repository _logger = logging.getLogger(__name__) @@ -28,7 +28,7 @@ async def _check_exists_and_access( # exists? # check whether this folder exists # otherwise raise not-found error - folder_db = await _folders_db.get( + folder_db = await _folders_repository.get( app, folder_id=folder_id, product_name=product_name ) @@ -46,7 +46,7 @@ async def _check_exists_and_access( ) workspace_is_private = False - await _folders_db.get_for_user_or_workspace( + await _folders_repository.get_for_user_or_workspace( app, folder_id=folder_id, product_name=product_name, @@ -63,34 +63,37 @@ async def _folders_db_update( product_name: ProductName, folder_id: FolderID, trashed_at: datetime | None, + trashed_by: UserID, ): # EXPLICIT un/trash - await _folders_db.update( + await _folders_repository.update( app, connection, folders_id_or_ids=folder_id, product_name=product_name, - trashed_at=trashed_at, + trashed=trashed_at, trashed_explicitly=trashed_at is not None, + trashed_by=trashed_by, ) # IMPLICIT un/trash child_folders: set[FolderID] = { f - for f in await _folders_db.get_folders_recursively( + for f in await _folders_repository.get_folders_recursively( app, connection, folder_id=folder_id, product_name=product_name ) if f != folder_id } if child_folders: - await _folders_db.update( + await _folders_repository.update( app, connection, folders_id_or_ids=child_folders, product_name=product_name, - trashed_at=trashed_at, + trashed=trashed_at, trashed_explicitly=False, + trashed_by=trashed_by, ) @@ -119,12 +122,13 @@ async def trash_folder( folder_id=folder_id, product_name=product_name, trashed_at=trashed_at, + trashed_by=user_id, ) # 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( + ] = await _folders_repository.get_projects_recursively_only_if_user_is_owner( app, connection, folder_id=folder_id, @@ -164,12 +168,13 @@ async def untrash_folder( folder_id=folder_id, product_name=product_name, trashed_at=None, + trashed_by=user_id, ) # 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( + ] = await _folders_repository.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, diff --git a/services/web/server/src/simcore_service_webserver/folders/_workspaces_api.py b/services/web/server/src/simcore_service_webserver/folders/_workspaces_repository.py similarity index 95% rename from services/web/server/src/simcore_service_webserver/folders/_workspaces_api.py rename to services/web/server/src/simcore_service_webserver/folders/_workspaces_repository.py index 115ff2c8d8eb..bac38edb7ca4 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_workspaces_api.py +++ b/services/web/server/src/simcore_service_webserver/folders/_workspaces_repository.py @@ -14,7 +14,7 @@ from ..projects._access_rights_api import check_user_project_permission from ..users.api import get_user from ..workspaces.api import check_user_workspace_access -from . import _folders_db +from . import _folders_repository _logger = logging.getLogger(__name__) @@ -28,7 +28,7 @@ async def move_folder_into_workspace( product_name: ProductName, ) -> None: # 1. User needs to have delete permission on source folder - folder_db = await _folders_db.get( + folder_db = await _folders_repository.get( app, folder_id=folder_id, product_name=product_name ) workspace_is_private = True @@ -56,7 +56,7 @@ async def move_folder_into_workspace( ( folder_ids, project_ids, - ) = await _folders_db.get_all_folders_and_projects_ids_recursively( + ) = await _folders_repository.get_all_folders_and_projects_ids_recursively( app, connection=None, folder_id=folder_id, @@ -86,7 +86,7 @@ async def move_folder_into_workspace( ) # 5. BATCH update of folders with workspace_id - await _folders_db.update( + await _folders_repository.update( app, connection=conn, folders_id_or_ids=set(folder_ids), @@ -96,7 +96,7 @@ async def move_folder_into_workspace( ) # 6. Update source folder parent folder ID with NULL (it will appear in the root directory) - await _folders_db.update( + await _folders_repository.update( app, connection=conn, folders_id_or_ids=folder_id, diff --git a/services/web/server/src/simcore_service_webserver/folders/_workspaces_handlers.py b/services/web/server/src/simcore_service_webserver/folders/_workspaces_rest.py similarity index 74% rename from services/web/server/src/simcore_service_webserver/folders/_workspaces_handlers.py rename to services/web/server/src/simcore_service_webserver/folders/_workspaces_rest.py index faa505ecd316..b327e84e5747 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_workspaces_handlers.py +++ b/services/web/server/src/simcore_service_webserver/folders/_workspaces_rest.py @@ -7,9 +7,9 @@ from .._meta import api_version_prefix as VTAG from ..login.decorators import login_required from ..security.decorators import permission_required -from . import _workspaces_api -from ._exceptions_handlers import handle_plugin_requests_exceptions -from ._models import FoldersRequestContext, _FolderWorkspacesPathParams +from . import _workspaces_repository +from ._common.exceptions_handlers import handle_plugin_requests_exceptions +from ._common.models import FoldersRequestContext, FolderWorkspacesPathParams _logger = logging.getLogger(__name__) @@ -26,9 +26,9 @@ @handle_plugin_requests_exceptions async def move_folder_to_workspace(request: web.Request): req_ctx = FoldersRequestContext.model_validate(request) - path_params = parse_request_path_parameters_as(_FolderWorkspacesPathParams, request) + path_params = parse_request_path_parameters_as(FolderWorkspacesPathParams, request) - await _workspaces_api.move_folder_into_workspace( + await _workspaces_repository.move_folder_into_workspace( app=request.app, user_id=req_ctx.user_id, folder_id=path_params.folder_id, diff --git a/services/web/server/src/simcore_service_webserver/folders/api.py b/services/web/server/src/simcore_service_webserver/folders/api.py deleted file mode 100644 index 2c5be8fdf520..000000000000 --- a/services/web/server/src/simcore_service_webserver/folders/api.py +++ /dev/null @@ -1,2 +0,0 @@ -__all__: tuple[str, ...] = () -# nopycln: file 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 2601962e52f4..ec1e3f80ffe4 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, _trash_handlers, _workspaces_handlers +from . import _folders_rest, _trash_rest, _workspaces_rest _logger = logging.getLogger(__name__) @@ -23,6 +23,6 @@ def setup_folders(app: web.Application): assert app[APP_SETTINGS_KEY].WEBSERVER_FOLDERS # nosec # routes - app.router.add_routes(_folders_handlers.routes) - app.router.add_routes(_trash_handlers.routes) - app.router.add_routes(_workspaces_handlers.routes) + app.router.add_routes(_folders_rest.routes) + app.router.add_routes(_trash_rest.routes) + app.router.add_routes(_workspaces_rest.routes) diff --git a/services/web/server/src/simcore_service_webserver/garbage_collector/_tasks_trash.py b/services/web/server/src/simcore_service_webserver/garbage_collector/_tasks_trash.py index a86b71fd3f7f..47d5e7212f23 100644 --- a/services/web/server/src/simcore_service_webserver/garbage_collector/_tasks_trash.py +++ b/services/web/server/src/simcore_service_webserver/garbage_collector/_tasks_trash.py @@ -12,7 +12,7 @@ from tenacity.before_sleep import before_sleep_log from tenacity.wait import wait_exponential -from ..projects._trash_api import prune_all_trashes +from ..trash._service import prune_trash _logger = logging.getLogger(__name__) @@ -28,7 +28,7 @@ before_sleep=before_sleep_log(_logger, logging.WARNING), ) async def _run_task(app: web.Application): - if deleted := await prune_all_trashes(app): + if deleted := await prune_trash(app): for name in deleted: _logger.info("Trash item %s expired and was deleted", f"{name}") else: diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_rest.py b/services/web/server/src/simcore_service_webserver/groups/_groups_rest.py index 32b5e507382d..5456776cfe6d 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_rest.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_rest.py @@ -67,10 +67,14 @@ async def list_groups(request: web.Request): ) my_groups = MyGroupsGet( - me=GroupGet.from_model(*groups_by_type.primary), - organizations=[GroupGet.from_model(*gi) for gi in groups_by_type.standard], - all=GroupGet.from_model(*groups_by_type.everyone), - product=GroupGet.from_model(*my_product_group) if my_product_group else None, + me=GroupGet.from_domain_model(*groups_by_type.primary), + organizations=[ + GroupGet.from_domain_model(*gi) for gi in groups_by_type.standard + ], + all=GroupGet.from_domain_model(*groups_by_type.everyone), + product=GroupGet.from_domain_model(*my_product_group) + if my_product_group + else None, ) return envelope_json_response(my_groups) @@ -94,7 +98,7 @@ async def get_group(request: web.Request): request.app, user_id=req_ctx.user_id, group_id=path_params.gid ) - return envelope_json_response(GroupGet.from_model(group, access_rights)) + return envelope_json_response(GroupGet.from_domain_model(group, access_rights)) @routes.post(f"/{API_VTAG}/groups", name="create_group") @@ -110,10 +114,10 @@ async def create_group(request: web.Request): group, access_rights = await _groups_service.create_standard_group( request.app, user_id=req_ctx.user_id, - create=create.to_model(), + create=create.to_domain_model(), ) - created_group = GroupGet.from_model(group, access_rights) + created_group = GroupGet.from_domain_model(group, access_rights) return envelope_json_response(created_group, status_cls=web.HTTPCreated) @@ -131,10 +135,10 @@ async def update_group(request: web.Request): request.app, user_id=req_ctx.user_id, group_id=path_params.gid, - update=update.to_model(), + update=update.to_domain_model(), ) - updated_group = GroupGet.from_model(group, access_rights) + updated_group = GroupGet.from_domain_model(group, access_rights) return envelope_json_response(updated_group) @@ -173,7 +177,7 @@ async def get_all_group_users(request: web.Request): ) return envelope_json_response( - [GroupUserGet.from_model(user) for user in users_in_group] + [GroupUserGet.from_domain_model(user) for user in users_in_group] ) @@ -216,7 +220,7 @@ async def get_group_user(request: web.Request): request.app, req_ctx.user_id, path_params.gid, path_params.uid ) - return envelope_json_response(GroupUserGet.from_model(user)) + return envelope_json_response(GroupUserGet.from_domain_model(user)) @routes.patch(f"/{API_VTAG}/groups/{{gid}}/users/{{uid}}", name="update_group_user") @@ -236,7 +240,7 @@ async def update_group_user(request: web.Request): access_rights=update.access_rights.model_dump(mode="json"), # type: ignore[arg-type] ) - return envelope_json_response(GroupUserGet.from_model(user)) + return envelope_json_response(GroupUserGet.from_domain_model(user)) @routes.delete(f"/{API_VTAG}/groups/{{gid}}/users/{{uid}}", name="delete_group_user") diff --git a/services/web/server/src/simcore_service_webserver/projects/_crud_api_create.py b/services/web/server/src/simcore_service_webserver/projects/_crud_api_create.py index 9953914f5d04..8b656f67cacd 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_crud_api_create.py +++ b/services/web/server/src/simcore_service_webserver/projects/_crud_api_create.py @@ -14,7 +14,7 @@ from models_library.projects_state import ProjectStatus from models_library.users import UserID from models_library.utils.fastapi_encoders import jsonable_encoder -from models_library.workspaces import UserWorkspaceAccessRightsDB +from models_library.workspaces import UserWorkspaceWithAccessRights from pydantic import TypeAdapter from servicelib.aiohttp.long_running_tasks.server import TaskProgress from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON @@ -28,14 +28,13 @@ from ..catalog import client as catalog_client from ..director_v2 import api as director_v2_api from ..dynamic_scheduler import api as dynamic_scheduler_api -from ..folders import _folders_db as folders_db +from ..folders import _folders_repository as folders_db from ..storage.api import ( copy_data_folders_from_project, get_project_total_size_simcore_s3, ) from ..users.api import get_user_fullname -from ..workspaces import _workspaces_db as workspaces_db -from ..workspaces.api import check_user_workspace_access +from ..workspaces.api import check_user_workspace_access, get_user_workspace from ..workspaces.errors import WorkspaceAccessForbiddenError from . import _folders_db as project_to_folders_db from . import projects_api @@ -409,21 +408,20 @@ async def create_project( # pylint: disable=too-many-arguments,too-many-branche # Overwrite project access rights if workspace_id: - workspace_db: UserWorkspaceAccessRightsDB = ( - await workspaces_db.get_workspace_for_user( - app=request.app, - user_id=user_id, - workspace_id=workspace_id, - product_name=product_name, - ) + workspace: UserWorkspaceWithAccessRights = await get_user_workspace( + request.app, + user_id=user_id, + workspace_id=workspace_id, + product_name=product_name, + permission=None, ) new_project["accessRights"] = { f"{gid}": access.model_dump() - for gid, access in workspace_db.access_rights.items() + for gid, access in workspace.access_rights.items() } # Ensures is like ProjectGet - data = ProjectGet.model_validate(new_project).data(exclude_unset=True) + data = ProjectGet.from_domain_model(new_project).data(exclude_unset=True) raise web.HTTPCreated( text=json_dumps({"data": data}), 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 55a9b7c6429b..3f9248e7237c 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 @@ -6,7 +6,6 @@ """ from aiohttp import web -from models_library.api_schemas_webserver._base import OutputSchema from models_library.api_schemas_webserver.projects import ProjectListItem from models_library.folders import FolderID, FolderQuery, FolderScope from models_library.projects import ProjectID @@ -19,21 +18,20 @@ from simcore_postgres_database.webserver_models import ProjectType as ProjectTypeDB from ..catalog.client import get_services_for_user_in_product -from ..folders import _folders_db as folders_db -from ..workspaces._workspaces_api import check_user_workspace_access +from ..folders import _folders_repository as folders_db +from ..workspaces._workspaces_service import check_user_workspace_access from . import projects_api from ._permalink_api import update_or_pop_permalink_in_project from .db import ProjectDBAPI from .models import ProjectDict, ProjectTypeAPI -async def _append_fields( +async def _append_item( request: web.Request, *, user_id: UserID, project: ProjectDict, is_template: bool, - model_schema_cls: type[OutputSchema], ): # state await projects_api.add_project_states_for_user( @@ -47,7 +45,7 @@ async def _append_fields( await update_or_pop_permalink_in_project(request, project) # validate - return model_schema_cls.model_validate(project).data(exclude_unset=True) + return ProjectListItem.from_domain_model(project).data(exclude_unset=True) async def list_projects( # pylint: disable=too-many-arguments @@ -128,12 +126,11 @@ async def list_projects( # pylint: disable=too-many-arguments projects: list[ProjectDict] = await logged_gather( *( - _append_fields( + _append_item( request, user_id=user_id, project=prj, is_template=prj_type == ProjectTypeDB.TEMPLATE, - model_schema_cls=ProjectListItem, ) for prj, prj_type in zip(db_projects, db_project_types, strict=False) ), @@ -182,12 +179,11 @@ async def list_projects_full_depth( projects: list[ProjectDict] = await logged_gather( *( - _append_fields( + _append_item( request, user_id=user_id, project=prj, is_template=prj_type == ProjectTypeDB.TEMPLATE, - model_schema_cls=ProjectListItem, ) for prj, prj_type in zip(db_projects, db_project_types, strict=False) ), diff --git a/services/web/server/src/simcore_service_webserver/projects/_crud_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_crud_handlers.py index 09be85531976..2ea0d84b8087 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_crud_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_crud_handlers.py @@ -305,7 +305,7 @@ async def get_active_project(request: web.Request) -> web.Response: # updates project's permalink field await update_or_pop_permalink_in_project(request, project) - data = ProjectGet.model_validate(project).data(exclude_unset=True) + data = ProjectGet.from_domain_model(project).data(exclude_unset=True) return web.json_response({"data": data}, dumps=json_dumps) @@ -362,7 +362,7 @@ async def get_project(request: web.Request): # Adds permalink await update_or_pop_permalink_in_project(request, project) - data = ProjectGet.model_validate(project).data(exclude_unset=True) + data = ProjectGet.from_domain_model(project).data(exclude_unset=True) return web.json_response({"data": data}, dumps=json_dumps) except ProjectInvalidRightsError as exc: diff --git a/services/web/server/src/simcore_service_webserver/projects/_db_utils.py b/services/web/server/src/simcore_service_webserver/projects/_db_utils.py index 2b14c2d1566f..da1f06d06dfd 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_db_utils.py +++ b/services/web/server/src/simcore_service_webserver/projects/_db_utils.py @@ -82,20 +82,20 @@ def convert_to_schema_names( ) -> dict: # SEE https://github.com/ITISFoundation/osparc-simcore/issues/3516 converted_args = {} - for key, value in project_database_data.items(): - if key in DB_EXCLUSIVE_COLUMNS: + for col_name, col_value in project_database_data.items(): + if col_name in DB_EXCLUSIVE_COLUMNS: continue - converted_value = value - if isinstance(value, datetime) and key not in {"trashed_at"}: - converted_value = format_datetime(value) - elif key == "prj_owner": + converted_value = col_value + if isinstance(col_value, datetime) and col_name not in {"trashed"}: + converted_value = format_datetime(col_value) + elif col_name == "prj_owner": # this entry has to be converted to the owner e-mail address converted_value = user_email - if key in SCHEMA_NON_NULL_KEYS and value is None: + if col_name in SCHEMA_NON_NULL_KEYS and col_value is None: converted_value = "" - converted_args[snake_to_camel(key)] = converted_value + converted_args[snake_to_camel(col_name)] = converted_value converted_args.update(**kwargs) return converted_args @@ -275,7 +275,26 @@ async def _get_project( query = ( sa.select( - *[col for col in projects.columns if col.name not in ["access_rights"]], + projects.c.id, + projects.c.type, + projects.c.uuid, + projects.c.name, + projects.c.description, + projects.c.thumbnail, + projects.c.prj_owner, # == user.id (who created) + projects.c.creation_date, + projects.c.last_change_date, + projects.c.workbench, + projects.c.ui, + projects.c.classifiers, + projects.c.dev, + projects.c.quality, + projects.c.published, + projects.c.hidden, + projects.c.trashed, + projects.c.trashed_by, # == user.id (who trashed) + projects.c.trashed_explicitly, + projects.c.workspace_id, access_rights_subquery.c.access_rights, ) .select_from(projects.join(access_rights_subquery, isouter=True)) diff --git a/services/web/server/src/simcore_service_webserver/projects/_folders_api.py b/services/web/server/src/simcore_service_webserver/projects/_folders_api.py index 4b465edf0e9d..865339a91658 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_folders_api.py +++ b/services/web/server/src/simcore_service_webserver/projects/_folders_api.py @@ -6,7 +6,7 @@ from models_library.projects import ProjectID from models_library.users import UserID -from ..folders import _folders_db as folders_db +from ..folders import _folders_repository as folders_db from ..projects._access_rights_api import get_user_project_access_rights from . import _folders_db as project_to_folders_db from .db import APP_PROJECT_DBAPI, ProjectDBAPI diff --git a/services/web/server/src/simcore_service_webserver/projects/_projects_db.py b/services/web/server/src/simcore_service_webserver/projects/_projects_db.py index 3c94e9e7cdc2..92c73867f779 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_projects_db.py +++ b/services/web/server/src/simcore_service_webserver/projects/_projects_db.py @@ -34,7 +34,7 @@ projects.c.published, projects.c.hidden, projects.c.workspace_id, - projects.c.trashed_at, + projects.c.trashed, ] diff --git a/services/web/server/src/simcore_service_webserver/projects/_states_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_states_handlers.py index 67a21b23eceb..6b6256c00e59 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_states_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_states_handlers.py @@ -8,6 +8,7 @@ import logging from aiohttp import web +from models_library.api_schemas_webserver.projects import ProjectGet from models_library.projects_state import ProjectState from pydantic import BaseModel from servicelib.aiohttp import status @@ -169,7 +170,7 @@ async def open_project(request: web.Request) -> web.Response: ) await projects_api.notify_project_state_update(request.app, project) - return envelope_json_response(project) + return envelope_json_response(ProjectGet.from_domain_model(project)) except DirectorServiceError as exc: # there was an issue while accessing the director-v2/director-v0 diff --git a/services/web/server/src/simcore_service_webserver/projects/_tags_api.py b/services/web/server/src/simcore_service_webserver/projects/_tags_api.py index c8e0937dbdbb..93bf232706ba 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_tags_api.py +++ b/services/web/server/src/simcore_service_webserver/projects/_tags_api.py @@ -7,9 +7,9 @@ from aiohttp import web from models_library.projects import ProjectID from models_library.users import UserID -from models_library.workspaces import UserWorkspaceAccessRightsDB +from models_library.workspaces import UserWorkspaceWithAccessRights -from ..workspaces import _workspaces_db as workspaces_db +from ..workspaces import _workspaces_repository as workspaces_db from ._access_rights_api import check_user_project_permission from .db import ProjectDBAPI from .models import ProjectDict @@ -36,7 +36,7 @@ async def add_tag( ) if project["workspaceId"] is not None: - workspace_db: UserWorkspaceAccessRightsDB = ( + workspace: UserWorkspaceWithAccessRights = ( await workspaces_db.get_workspace_for_user( app=app, user_id=user_id, @@ -45,8 +45,7 @@ async def add_tag( ) ) project["accessRights"] = { - gid: access.model_dump() - for gid, access in workspace_db.access_rights.items() + gid: access.model_dump() for gid, access in workspace.access_rights.items() } return project @@ -71,7 +70,7 @@ async def remove_tag( ) if project["workspaceId"] is not None: - workspace_db: UserWorkspaceAccessRightsDB = ( + workspace: UserWorkspaceWithAccessRights = ( await workspaces_db.get_workspace_for_user( app=app, user_id=user_id, @@ -80,8 +79,7 @@ async def remove_tag( ) ) project["accessRights"] = { - gid: access.model_dump() - for gid, access in workspace_db.access_rights.items() + gid: access.model_dump() for gid, access in workspace.access_rights.items() } return project diff --git a/services/web/server/src/simcore_service_webserver/projects/_trash_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_trash_rest.py similarity index 83% rename from services/web/server/src/simcore_service_webserver/projects/_trash_handlers.py rename to services/web/server/src/simcore_service_webserver/projects/_trash_rest.py index b20636121322..22368285efc6 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_trash_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_trash_rest.py @@ -17,7 +17,7 @@ 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 . import _trash_service from ._common.models import ProjectPathParams, RemoveQueryParams from .exceptions import ProjectRunningConflictError, ProjectStoppingError @@ -52,21 +52,6 @@ routes = web.RouteTableDef() -@routes.delete(f"/{VTAG}/trash", name="empty_trash") -@login_required -@permission_required("project.delete") -@_handle_exceptions -async def empty_trash(request: web.Request): - user_id = get_user_id(request) - product_name = get_product_name(request) - - await _trash_api.empty_trash( - request.app, product_name=product_name, user_id=user_id - ) - - return web.json_response(status=status.HTTP_204_NO_CONTENT) - - @routes.post(f"/{VTAG}/projects/{{project_id}}:trash", name="trash_project") @login_required @permission_required("project.delete") @@ -79,7 +64,7 @@ async def trash_project(request: web.Request): RemoveQueryParams, request ) - await _trash_api.trash_project( + await _trash_service.trash_project( request.app, product_name=product_name, user_id=user_id, @@ -100,7 +85,7 @@ async def untrash_project(request: web.Request): product_name = get_product_name(request) path_params = parse_request_path_parameters_as(ProjectPathParams, request) - await _trash_api.untrash_project( + await _trash_service.untrash_project( request.app, product_name=product_name, user_id=user_id, diff --git a/services/web/server/src/simcore_service_webserver/projects/_trash_api.py b/services/web/server/src/simcore_service_webserver/projects/_trash_service.py similarity index 81% rename from services/web/server/src/simcore_service_webserver/projects/_trash_api.py rename to services/web/server/src/simcore_service_webserver/projects/_trash_service.py index e15a98423c79..13e07c514756 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_trash_api.py +++ b/services/web/server/src/simcore_service_webserver/projects/_trash_service.py @@ -1,6 +1,5 @@ import asyncio import logging -from datetime import timedelta import arrow from aiohttp import web @@ -17,35 +16,10 @@ from ._access_rights_api import check_user_project_permission from .exceptions import ProjectRunningConflictError from .models import ProjectPatchExtended -from .settings import get_plugin_settings _logger = logging.getLogger(__name__) -async def empty_trash(app: web.Application, product_name: ProductName, user_id: UserID): - assert app # nosec - # filter trashed=True and set them to False - _logger.debug( - "CODE PLACEHOLDER: all projects marked as trashed of %s in %s are deleted", - f"{user_id=}", - f"{product_name=}", - ) - raise NotImplementedError - - -async def prune_all_trashes(app: web.Application) -> list[str]: - settings = get_plugin_settings(app) - retention = timedelta(days=settings.PROJECTS_TRASH_RETENTION_DAYS) - - _logger.debug( - "CODE PLACEHOLDER: **ALL** projects marked as trashed during %s days are deleted", - retention, - ) - await asyncio.sleep(5) - - return [] - - async def _is_project_running( app: web.Application, *, 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 7890537a9843..47704f74e2fd 100644 --- a/services/web/server/src/simcore_service_webserver/projects/db.py +++ b/services/web/server/src/simcore_service_webserver/projects/db.py @@ -567,12 +567,12 @@ async def list_projects( # pylint: disable=too-many-arguments,too-many-statemen attributes_filters.append( # marked explicitly as trashed ( - projects.c.trashed_at.is_not(None) + projects.c.trashed.is_not(None) & projects.c.trashed_explicitly.is_(True) ) if filter_trashed # not marked as trashed - else projects.c.trashed_at.is_(None) + else projects.c.trashed.is_(None) ) if filter_by_text is not None: attributes_filters.append( 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 dca631ba39a1..8354bdff549d 100644 --- a/services/web/server/src/simcore_service_webserver/projects/models.py +++ b/services/web/server/src/simcore_service_webserver/projects/models.py @@ -3,6 +3,7 @@ from typing import Any, TypeAlias from aiopg.sa.result import RowProxy +from common_library.dict_tools import remap_keys from models_library.api_schemas_webserver.projects import ProjectPatch from models_library.folders import FolderID from models_library.projects import ClassifierID, ProjectID @@ -51,7 +52,7 @@ class ProjectDB(BaseModel): published: bool hidden: bool workspace_id: WorkspaceID | None - trashed_at: datetime | None + trashed: datetime | None trashed_explicitly: bool = False model_config = ConfigDict(from_attributes=True, arbitrary_types_allowed=True) @@ -94,12 +95,18 @@ class UserProjectAccessRightsWithWorkspace(BaseModel): class ProjectPatchExtended(ProjectPatch): - # Only used internally + # ONLY used internally trashed_at: datetime | None trashed_explicitly: bool model_config = ConfigDict(populate_by_name=True, extra="forbid") + def to_domain_model(self) -> dict[str, Any]: + return remap_keys( + self.model_dump(exclude_unset=True, by_alias=False), + rename={"trashed_at": "trashed"}, + ) + __all__: tuple[str, ...] = ( "ProjectDict", diff --git a/services/web/server/src/simcore_service_webserver/projects/plugin.py b/services/web/server/src/simcore_service_webserver/projects/plugin.py index b72c4a90b9ac..5cba65b8a2b6 100644 --- a/services/web/server/src/simcore_service_webserver/projects/plugin.py +++ b/services/web/server/src/simcore_service_webserver/projects/plugin.py @@ -20,7 +20,7 @@ _projects_nodes_pricing_unit_handlers, _states_handlers, _tags_handlers, - _trash_handlers, + _trash_rest, _wallets_handlers, _workspaces_handlers, ) @@ -62,6 +62,6 @@ def setup_projects(app: web.Application) -> bool: app.router.add_routes(_folders_handlers.routes) app.router.add_routes(_projects_nodes_pricing_unit_handlers.routes) app.router.add_routes(_workspaces_handlers.routes) - app.router.add_routes(_trash_handlers.routes) + app.router.add_routes(_trash_rest.routes) return True diff --git a/services/web/server/src/simcore_service_webserver/projects/projects_api.py b/services/web/server/src/simcore_service_webserver/projects/projects_api.py index d4507c565e43..fa46afffed2a 100644 --- a/services/web/server/src/simcore_service_webserver/projects/projects_api.py +++ b/services/web/server/src/simcore_service_webserver/projects/projects_api.py @@ -63,7 +63,7 @@ from models_library.users import UserID from models_library.utils.fastapi_encoders import jsonable_encoder from models_library.wallets import ZERO_CREDITS, WalletID, WalletInfo -from models_library.workspaces import UserWorkspaceAccessRightsDB +from models_library.workspaces import UserWorkspaceWithAccessRights from pydantic import ByteSize, TypeAdapter from servicelib.aiohttp.application_keys import APP_FIRE_AND_FORGET_TASKS_KEY from servicelib.common_headers import ( @@ -120,7 +120,7 @@ ) from ..wallets import api as wallets_api from ..wallets.errors import WalletNotEnoughCreditsError -from ..workspaces import _workspaces_db as workspaces_db +from ..workspaces import _workspaces_repository as workspaces_db from . import _crud_api_delete, _nodes_api, _projects_db from ._access_rights_api import ( check_user_project_permission, @@ -209,7 +209,7 @@ async def get_project_for_user( ) if project["workspaceId"] is not None: - workspace_db: UserWorkspaceAccessRightsDB = ( + workspace: UserWorkspaceWithAccessRights = ( await workspaces_db.get_workspace_for_user( app=app, user_id=user_id, @@ -219,7 +219,7 @@ async def get_project_for_user( ) project["accessRights"] = { f"{gid}": access.model_dump() - for gid, access in workspace_db.access_rights.items() + for gid, access in workspace.access_rights.items() } Project.model_validate(project) # NOTE: only validates @@ -255,9 +255,7 @@ async def patch_project( project_patch: ProjectPatch | ProjectPatchExtended, product_name: ProductName, ): - _project_patch_exclude_unset = project_patch.model_dump( - exclude_unset=True, by_alias=False - ) + patch_project_data = project_patch.to_domain_model() db: ProjectDBAPI = app[APP_PROJECT_DBAPI] # 1. Get project @@ -273,7 +271,7 @@ async def patch_project( ) # 3. If patching access rights - if new_prj_access_rights := _project_patch_exclude_unset.get("access_rights"): + if new_prj_access_rights := patch_project_data.get("access_rights"): # 3.1 Check if user is Owner and therefore can modify access rights if not _user_project_access_rights.delete: raise ProjectInvalidRightsError(user_id=user_id, project_uuid=project_uuid) @@ -294,7 +292,7 @@ async def patch_project( await _projects_db.patch_project( app=app, project_uuid=project_uuid, - new_partial_project_data=_project_patch_exclude_unset, + new_partial_project_data=patch_project_data, ) diff --git a/services/web/server/src/simcore_service_webserver/projects/settings.py b/services/web/server/src/simcore_service_webserver/projects/settings.py index 57c03f915efe..ace293856023 100644 --- a/services/web/server/src/simcore_service_webserver/projects/settings.py +++ b/services/web/server/src/simcore_service_webserver/projects/settings.py @@ -23,10 +23,6 @@ class ProjectsSettings(BaseCustomSettings): description="interval after which services need to be idle in order to be considered inactive", ) - PROJECTS_TRASH_RETENTION_DAYS: NonNegativeInt = Field( - default=7, description="Trashed items will be deleted after this time" - ) - def get_plugin_settings(app: web.Application) -> ProjectsSettings: settings = app[APP_SETTINGS_KEY].WEBSERVER_PROJECTS diff --git a/services/web/server/src/simcore_service_webserver/tags/_rest.py b/services/web/server/src/simcore_service_webserver/tags/_rest.py index ff812486e0bf..7550c8343edd 100644 --- a/services/web/server/src/simcore_service_webserver/tags/_rest.py +++ b/services/web/server/src/simcore_service_webserver/tags/_rest.py @@ -148,7 +148,7 @@ async def list_tag_groups(request: web.Request): caller_user_id=req_ctx.user_id, tag_id=path_params.tag_id, ) - return envelope_json_response([TagGroupGet.from_model(md) for md in got]) + return envelope_json_response([TagGroupGet.from_domain_model(md) for md in got]) @routes.post(f"/{VTAG}/tags/{{tag_id}}/groups/{{group_id}}", name="create_tag_group") @@ -165,11 +165,11 @@ async def create_tag_group(request: web.Request): caller_user_id=req_ctx.user_id, tag_id=path_params.tag_id, group_id=path_params.group_id, - access_rights=body_params.to_model(), + access_rights=body_params.to_domain_model(), ) return envelope_json_response( - TagGroupGet.from_model(got), status_cls=web.HTTPCreated + TagGroupGet.from_domain_model(got), status_cls=web.HTTPCreated ) @@ -187,10 +187,10 @@ async def replace_tag_group(request: web.Request): caller_user_id=req_ctx.user_id, tag_id=path_params.tag_id, group_id=path_params.group_id, - access_rights=body_params.to_model(), + access_rights=body_params.to_domain_model(), ) - return envelope_json_response(TagGroupGet.from_model(got)) + return envelope_json_response(TagGroupGet.from_domain_model(got)) @routes.delete(f"/{VTAG}/tags/{{tag_id}}/groups/{{group_id}}", name="delete_tag_group") diff --git a/services/web/server/src/simcore_service_webserver/tags/_service.py b/services/web/server/src/simcore_service_webserver/tags/_service.py index 36dcbdfcd46e..be73441f224d 100644 --- a/services/web/server/src/simcore_service_webserver/tags/_service.py +++ b/services/web/server/src/simcore_service_webserver/tags/_service.py @@ -35,7 +35,7 @@ async def create_tag( delete=True, **new_tag.model_dump(exclude_unset=True), ) - return TagGet.from_model(tag) + return TagGet.from_domain_model(tag) async def list_tags( @@ -45,7 +45,7 @@ async def list_tags( engine: AsyncEngine = get_async_engine(app) repo = TagsRepo(engine) tags = await repo.list_all(user_id=user_id) - return [TagGet.from_model(t) for t in tags] + return [TagGet.from_domain_model(t) for t in tags] async def update_tag( @@ -59,7 +59,7 @@ async def update_tag( tag_id=tag_id, **tag_updates.model_dump(exclude_unset=True), ) - return TagGet.from_model(tag) + return TagGet.from_domain_model(tag) async def delete_tag(app: web.Application, user_id: UserID, tag_id: IdInt): diff --git a/services/web/server/src/simcore_service_webserver/tags/schemas.py b/services/web/server/src/simcore_service_webserver/tags/schemas.py index 95fdd8e717d1..5f83e3032ecd 100644 --- a/services/web/server/src/simcore_service_webserver/tags/schemas.py +++ b/services/web/server/src/simcore_service_webserver/tags/schemas.py @@ -55,7 +55,7 @@ class TagGet(OutputSchema): access_rights: TagAccessRights = Field(..., alias="accessRights") @classmethod - def from_model(cls, tag: TagDict) -> Self: + def from_domain_model(cls, tag: TagDict) -> Self: # NOTE: cls(access_rights=tag, **tag) would also work because of Config return cls( id=tag["id"], @@ -84,7 +84,7 @@ class TagGroupCreate(InputSchema): write: bool delete: bool - def to_model(self) -> AccessRightsDict: + def to_domain_model(self) -> AccessRightsDict: data = self.model_dump() return AccessRightsDict( read=data["read"], @@ -101,7 +101,7 @@ class TagGroupGet(OutputSchema): delete: bool @classmethod - def from_model(cls, data: TagAccessRightsDict) -> Self: + def from_domain_model(cls, data: TagAccessRightsDict) -> Self: return cls( gid=data["group_id"], read=data["read"], diff --git a/services/web/server/src/simcore_service_webserver/trash/__init__.py b/services/web/server/src/simcore_service_webserver/trash/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/services/web/server/src/simcore_service_webserver/trash/_rest.py b/services/web/server/src/simcore_service_webserver/trash/_rest.py new file mode 100644 index 000000000000..f5912b042fe8 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/trash/_rest.py @@ -0,0 +1,60 @@ +import logging + +from aiohttp import web +from servicelib.aiohttp import status + +from .._meta import API_VTAG as VTAG +from ..exception_handling import ( + ExceptionToHttpErrorMap, + HttpErrorInfo, + exception_handling_decorator, + to_exceptions_handlers_map, +) +from ..login.decorators import get_user_id, login_required +from ..products.api import get_product_name +from ..projects.exceptions import ProjectRunningConflictError, ProjectStoppingError +from ..security.decorators import permission_required +from . import _service + +_logger = logging.getLogger(__name__) + +# +# EXCEPTIONS HANDLING +# + + +_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", + ), + ProjectStoppingError: HttpErrorInfo( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Something went wrong while stopping services before trashing. Aborting trash.", + ), +} + + +_handle_exceptions = exception_handling_decorator( + to_exceptions_handlers_map(_TO_HTTP_ERROR_MAP) +) + + +# +# ROUTES +# + +routes = web.RouteTableDef() + + +@routes.delete(f"/{VTAG}/trash", name="empty_trash") +@login_required +@permission_required("project.delete") +@_handle_exceptions +async def empty_trash(request: web.Request): + user_id = get_user_id(request) + product_name = get_product_name(request) + + await _service.empty_trash(request.app, product_name=product_name, user_id=user_id) + + return web.json_response(status=status.HTTP_204_NO_CONTENT) diff --git a/services/web/server/src/simcore_service_webserver/trash/_service.py b/services/web/server/src/simcore_service_webserver/trash/_service.py new file mode 100644 index 000000000000..cc94d680d644 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/trash/_service.py @@ -0,0 +1,36 @@ +import asyncio +import logging +from datetime import timedelta + +from aiohttp import web +from models_library.products import ProductName +from models_library.users import UserID + +from .settings import get_plugin_settings + +_logger = logging.getLogger(__name__) + + +async def empty_trash(app: web.Application, product_name: ProductName, user_id: UserID): + assert app # nosec + # filter trashed=True and set them to False + _logger.debug( + "CODE PLACEHOLDER: all projects marked as trashed of %s in %s are deleted", + f"{user_id=}", + f"{product_name=}", + ) + raise NotImplementedError + + +async def prune_trash(app: web.Application) -> list[str]: + """Deletes expired items in the trash""" + settings = get_plugin_settings(app) + retention = timedelta(days=settings.TRASH_RETENTION_DAYS) + + _logger.debug( + "CODE PLACEHOLDER: **ALL** projects marked as trashed during %s days are deleted", + retention, + ) + await asyncio.sleep(5) + + return [] diff --git a/services/web/server/src/simcore_service_webserver/trash/plugin.py b/services/web/server/src/simcore_service_webserver/trash/plugin.py new file mode 100644 index 000000000000..a4cde6415966 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/trash/plugin.py @@ -0,0 +1,33 @@ +""" projects management subsystem + + A project is a document defining a osparc study + It contains metadata about the study (e.g. name, description, owner, etc) and a workbench section that describes the study pipeline +""" +import logging + +from aiohttp import web +from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup + +from .._constants import APP_SETTINGS_KEY +from ..folders.plugin import setup_folders +from ..projects.plugin import setup_projects +from ..workspaces.plugin import setup_workspaces +from . import _rest + +_logger = logging.getLogger(__name__) + + +@app_module_setup( + __name__, + ModuleCategory.ADDON, + settings_name="WEBSERVER_TRASH", + logger=_logger, +) +def setup_trash(app: web.Application): + assert app[APP_SETTINGS_KEY].WEBSERVER_TRASH # nosec + + setup_projects(app) + setup_folders(app) + setup_workspaces(app) + + app.router.add_routes(_rest.routes) diff --git a/services/web/server/src/simcore_service_webserver/trash/settings.py b/services/web/server/src/simcore_service_webserver/trash/settings.py new file mode 100644 index 000000000000..38d4f91fdcb6 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/trash/settings.py @@ -0,0 +1,19 @@ +from aiohttp import web +from pydantic import Field, NonNegativeInt +from settings_library.base import BaseCustomSettings + +from .._constants import APP_SETTINGS_KEY + + +class TrashSettings(BaseCustomSettings): + TRASH_RETENTION_DAYS: NonNegativeInt = Field( + default=7, + description="Number of days that trashed items are kept in the bin before finally deleting them", + ) + + +def get_plugin_settings(app: web.Application) -> TrashSettings: + settings = app[APP_SETTINGS_KEY].WEBSERVER_TRASH + assert settings, "setup_settings not called?" # nosec + assert isinstance(settings, TrashSettings) # nosec + return settings diff --git a/services/web/server/src/simcore_service_webserver/users/_notifications_rest.py b/services/web/server/src/simcore_service_webserver/users/_notifications_rest.py index fb7e02b08da4..2e243d4da90e 100644 --- a/services/web/server/src/simcore_service_webserver/users/_notifications_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_notifications_rest.py @@ -130,5 +130,5 @@ async def list_user_permissions(request: web.Request) -> web.Response: request.app, user_id=req_ctx.user_id, product_name=req_ctx.product_name ) return envelope_json_response( - [MyPermissionGet.from_model(p) for p in list_permissions] + [MyPermissionGet.from_domain_model(p) for p in list_permissions] ) diff --git a/services/web/server/src/simcore_service_webserver/users/_tokens_rest.py b/services/web/server/src/simcore_service_webserver/users/_tokens_rest.py index 64c971761a7b..ef38c61eb295 100644 --- a/services/web/server/src/simcore_service_webserver/users/_tokens_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_tokens_rest.py @@ -46,7 +46,7 @@ async def _wrapper(request: web.Request) -> web.StreamResponse: async def list_tokens(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) all_tokens = await _tokens_service.list_tokens(request.app, req_ctx.user_id) - return envelope_json_response([MyTokenGet.from_model(t) for t in all_tokens]) + return envelope_json_response([MyTokenGet.from_domain_model(t) for t in all_tokens]) @routes.post(f"/{API_VTAG}/me/tokens", name="create_token") @@ -58,10 +58,10 @@ async def create_token(request: web.Request) -> web.Response: token_create = await parse_request_body_as(MyTokenCreate, request) token = await _tokens_service.create_token( - request.app, req_ctx.user_id, token_create.to_model() + request.app, req_ctx.user_id, token_create.to_domain_model() ) - return envelope_json_response(MyTokenGet.from_model(token), web.HTTPCreated) + return envelope_json_response(MyTokenGet.from_domain_model(token), web.HTTPCreated) class _TokenPathParams(BaseModel): @@ -80,7 +80,7 @@ async def get_token(request: web.Request) -> web.Response: request.app, req_ctx.user_id, req_path_params.service ) - return envelope_json_response(MyTokenGet.from_model(token)) + return envelope_json_response(MyTokenGet.from_domain_model(token)) @routes.delete(f"/{API_VTAG}/me/tokens/{{service}}", name="delete_token") diff --git a/services/web/server/src/simcore_service_webserver/users/_users_repository.py b/services/web/server/src/simcore_service_webserver/users/_users_repository.py index 5fcc88af4a1e..16730437394b 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_repository.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_repository.py @@ -196,13 +196,13 @@ async def get_user_fullname(app: web.Application, *, user_id: UserID) -> FullNam user_id = _parse_as_user(user_id) async with pass_or_acquire_connection(engine=get_asyncpg_engine(app)) as conn: - result = await conn.stream( + result = await conn.execute( sa.select( users.c.first_name, users.c.last_name, ).where(users.c.id == user_id) ) - user = await result.first() + user = result.first() if not user: raise UserNotFoundError(user_id=user_id) diff --git a/services/web/server/src/simcore_service_webserver/users/_users_rest.py b/services/web/server/src/simcore_service_webserver/users/_users_rest.py index 688b024b40a7..d2ece6885148 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_rest.py @@ -106,7 +106,7 @@ async def get_my_profile(request: web.Request) -> web.Response: request.app, user_id=req_ctx.user_id, product_name=req_ctx.product_name ) - profile = MyProfileGet.from_model( + profile = MyProfileGet.from_domain_model( my_profile, groups_by_type, my_product_group, preferences ) @@ -153,7 +153,7 @@ async def search_users(request: web.Request) -> web.Response: limit=search_params.limit, ) - return envelope_json_response([UserGet.from_model(user) for user in found]) + return envelope_json_response([UserGet.from_domain_model(user) for user in found]) # diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_common/__init__.py b/services/web/server/src/simcore_service_webserver/workspaces/_common/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_exceptions_handlers.py b/services/web/server/src/simcore_service_webserver/workspaces/_common/exceptions_handlers.py similarity index 90% rename from services/web/server/src/simcore_service_webserver/workspaces/_exceptions_handlers.py rename to services/web/server/src/simcore_service_webserver/workspaces/_common/exceptions_handlers.py index 1bb16355b80f..32bb81224a79 100644 --- a/services/web/server/src/simcore_service_webserver/workspaces/_exceptions_handlers.py +++ b/services/web/server/src/simcore_service_webserver/workspaces/_common/exceptions_handlers.py @@ -2,14 +2,14 @@ from servicelib.aiohttp import status -from ..exception_handling import ( +from ...exception_handling import ( ExceptionToHttpErrorMap, HttpErrorInfo, exception_handling_decorator, to_exceptions_handlers_map, ) -from ..projects.exceptions import ProjectRunningConflictError, ProjectStoppingError -from .errors import ( +from ...projects.exceptions import ProjectRunningConflictError, ProjectStoppingError +from ..errors import ( WorkspaceAccessForbiddenError, WorkspaceGroupNotFoundError, WorkspaceNotFoundError, diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_models.py b/services/web/server/src/simcore_service_webserver/workspaces/_common/models.py similarity index 98% rename from services/web/server/src/simcore_service_webserver/workspaces/_models.py rename to services/web/server/src/simcore_service_webserver/workspaces/_common/models.py index d2f22a3c8789..a94ec063f15a 100644 --- a/services/web/server/src/simcore_service_webserver/workspaces/_models.py +++ b/services/web/server/src/simcore_service_webserver/workspaces/_common/models.py @@ -18,7 +18,7 @@ from pydantic import BaseModel, BeforeValidator, ConfigDict, Field from servicelib.request_keys import RQT_USERID_KEY -from .._constants import RQ_PRODUCT_KEY +from ..._constants import RQ_PRODUCT_KEY _logger = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_groups_db.py b/services/web/server/src/simcore_service_webserver/workspaces/_groups_repository.py similarity index 100% rename from services/web/server/src/simcore_service_webserver/workspaces/_groups_db.py rename to services/web/server/src/simcore_service_webserver/workspaces/_groups_repository.py diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_groups_handlers.py b/services/web/server/src/simcore_service_webserver/workspaces/_groups_rest.py similarity index 88% rename from services/web/server/src/simcore_service_webserver/workspaces/_groups_handlers.py rename to services/web/server/src/simcore_service_webserver/workspaces/_groups_rest.py index 599305c3f81f..0ccaf3371396 100644 --- a/services/web/server/src/simcore_service_webserver/workspaces/_groups_handlers.py +++ b/services/web/server/src/simcore_service_webserver/workspaces/_groups_rest.py @@ -11,15 +11,15 @@ from ..login.decorators import login_required from ..security.decorators import permission_required from ..utils_aiohttp import envelope_json_response -from . import _groups_api -from ._exceptions_handlers import handle_plugin_requests_exceptions -from ._groups_api import WorkspaceGroupGet -from ._models import ( +from . import _groups_service +from ._common.exceptions_handlers import handle_plugin_requests_exceptions +from ._common.models import ( WorkspacesGroupsBodyParams, WorkspacesGroupsPathParams, WorkspacesPathParams, WorkspacesRequestContext, ) +from ._groups_service import WorkspaceGroupGet _logger = logging.getLogger(__name__) @@ -43,7 +43,7 @@ async def create_workspace_group(request: web.Request): path_params = parse_request_path_parameters_as(WorkspacesGroupsPathParams, request) body_params = await parse_request_body_as(WorkspacesGroupsBodyParams, request) - workspace_groups: WorkspaceGroupGet = await _groups_api.create_workspace_group( + workspace_groups: WorkspaceGroupGet = await _groups_service.create_workspace_group( request.app, user_id=req_ctx.user_id, workspace_id=path_params.workspace_id, @@ -67,7 +67,7 @@ async def list_workspace_groups(request: web.Request): workspaces_groups: list[ WorkspaceGroupGet - ] = await _groups_api.list_workspace_groups_by_user_and_workspace( + ] = await _groups_service.list_workspace_groups_by_user_and_workspace( request.app, user_id=req_ctx.user_id, workspace_id=path_params.workspace_id, @@ -89,7 +89,7 @@ async def replace_workspace_group(request: web.Request): path_params = parse_request_path_parameters_as(WorkspacesGroupsPathParams, request) body_params = await parse_request_body_as(WorkspacesGroupsBodyParams, request) - workspace_group = await _groups_api.update_workspace_group( + workspace_group = await _groups_service.update_workspace_group( app=request.app, user_id=req_ctx.user_id, workspace_id=path_params.workspace_id, @@ -113,7 +113,7 @@ async def delete_workspace_group(request: web.Request): req_ctx = WorkspacesRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(WorkspacesGroupsPathParams, request) - await _groups_api.delete_workspace_group( + await _groups_service.delete_workspace_group( app=request.app, user_id=req_ctx.user_id, workspace_id=path_params.workspace_id, diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_groups_api.py b/services/web/server/src/simcore_service_webserver/workspaces/_groups_service.py similarity index 86% rename from services/web/server/src/simcore_service_webserver/workspaces/_groups_api.py rename to services/web/server/src/simcore_service_webserver/workspaces/_groups_service.py index 2ca935c89671..37737e735903 100644 --- a/services/web/server/src/simcore_service_webserver/workspaces/_groups_api.py +++ b/services/web/server/src/simcore_service_webserver/workspaces/_groups_service.py @@ -5,14 +5,14 @@ from models_library.groups import GroupID from models_library.products import ProductName from models_library.users import UserID -from models_library.workspaces import UserWorkspaceAccessRightsDB, WorkspaceID +from models_library.workspaces import UserWorkspaceWithAccessRights, WorkspaceID from pydantic import BaseModel, ConfigDict from ..users import api as users_api -from . import _groups_db as workspaces_groups_db -from . import _workspaces_db as workspaces_db -from ._groups_db import WorkspaceGroupGetDB -from ._workspaces_api import check_user_workspace_access +from . import _groups_repository as workspaces_groups_db +from . import _workspaces_repository as workspaces_db +from ._groups_repository import WorkspaceGroupGetDB +from ._workspaces_service import check_user_workspace_access from .errors import WorkspaceAccessForbiddenError log = logging.getLogger(__name__) @@ -124,8 +124,13 @@ async def update_workspace_group( delete: bool, product_name: ProductName, ) -> WorkspaceGroupGet: - workspace: UserWorkspaceAccessRightsDB = await workspaces_db.get_workspace_for_user( - app=app, user_id=user_id, workspace_id=workspace_id, product_name=product_name + workspace: UserWorkspaceWithAccessRights = ( + await workspaces_db.get_workspace_for_user( + app=app, + user_id=user_id, + workspace_id=workspace_id, + product_name=product_name, + ) ) if workspace.my_access_rights.write is False: raise WorkspaceAccessForbiddenError( @@ -165,8 +170,13 @@ async def delete_workspace_group( product_name: ProductName, ) -> None: user: dict = await users_api.get_user(app, user_id=user_id) - workspace: UserWorkspaceAccessRightsDB = await workspaces_db.get_workspace_for_user( - app=app, user_id=user_id, workspace_id=workspace_id, product_name=product_name + workspace: UserWorkspaceWithAccessRights = ( + await workspaces_db.get_workspace_for_user( + app=app, + user_id=user_id, + workspace_id=workspace_id, + product_name=product_name, + ) ) if user["primary_gid"] != group_id and workspace.my_access_rights.delete is False: raise WorkspaceAccessForbiddenError( diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_trash_handlers.py b/services/web/server/src/simcore_service_webserver/workspaces/_trash_rest.py similarity index 87% rename from services/web/server/src/simcore_service_webserver/workspaces/_trash_handlers.py rename to services/web/server/src/simcore_service_webserver/workspaces/_trash_rest.py index eafabb8d0774..fd7b708c1ddf 100644 --- a/services/web/server/src/simcore_service_webserver/workspaces/_trash_handlers.py +++ b/services/web/server/src/simcore_service_webserver/workspaces/_trash_rest.py @@ -11,9 +11,9 @@ 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 WorkspacesPathParams, WorkspaceTrashQueryParams +from . import _trash_services +from ._common.exceptions_handlers import handle_plugin_requests_exceptions +from ._common.models import WorkspacesPathParams, WorkspaceTrashQueryParams _logger = logging.getLogger(__name__) @@ -33,7 +33,7 @@ async def trash_workspace(request: web.Request): WorkspaceTrashQueryParams, request ) - await _trash_api.trash_workspace( + await _trash_services.trash_workspace( request.app, product_name=product_name, user_id=user_id, @@ -53,7 +53,7 @@ async def untrash_workspace(request: web.Request): product_name = get_product_name(request) path_params = parse_request_path_parameters_as(WorkspacesPathParams, request) - await _trash_api.untrash_workspace( + await _trash_services.untrash_workspace( request.app, product_name=product_name, user_id=user_id, diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_trash_api.py b/services/web/server/src/simcore_service_webserver/workspaces/_trash_services.py similarity index 73% rename from services/web/server/src/simcore_service_webserver/workspaces/_trash_api.py rename to services/web/server/src/simcore_service_webserver/workspaces/_trash_services.py index 18c3ae93b88a..59f88f7ad908 100644 --- a/services/web/server/src/simcore_service_webserver/workspaces/_trash_api.py +++ b/services/web/server/src/simcore_service_webserver/workspaces/_trash_services.py @@ -6,14 +6,14 @@ from models_library.products import ProductName from models_library.projects import ProjectID from models_library.users import UserID -from models_library.workspaces import WorkspaceID, WorkspaceUpdateDB +from models_library.workspaces import WorkspaceID, WorkspaceUpdates from simcore_postgres_database.utils_repos import transaction_context from ..db.plugin import get_asyncpg_engine -from ..folders._trash_api import trash_folder, untrash_folder -from ..projects._trash_api import trash_project, untrash_project -from ._workspaces_api import check_user_workspace_access -from ._workspaces_db import update_workspace +from ..folders._trash_service import trash_folder, untrash_folder +from ..projects._trash_service import trash_project, untrash_project +from ._workspaces_repository import update_workspace +from ._workspaces_service import check_user_workspace_access _logger = logging.getLogger(__name__) @@ -55,11 +55,14 @@ async def trash_workspace( connection, product_name=product_name, workspace_id=workspace_id, - updates=WorkspaceUpdateDB(trashed=trashed_at, trashed_by=user_id), + updates=WorkspaceUpdates(trashed=trashed_at, trashed_by=user_id), ) # IMPLICIT trash - child_folders: list[FolderID] = [] # TODO: find children. Check with MD + child_folders: list[FolderID] = ( + [] + # NOTE: follows up with https://github.com/ITISFoundation/osparc-simcore/issues/7034 + ) for folder_id in child_folders: await trash_folder( @@ -70,7 +73,10 @@ async def trash_workspace( force_stop_first=force_stop_first, ) - child_projects: list[ProjectID] = [] # TODO: find children. Check with MD + child_projects: list[ProjectID] = ( + [] + # NOTE: follows up with https://github.com/ITISFoundation/osparc-simcore/issues/7034 + ) for project_id in child_projects: await trash_project( @@ -101,10 +107,13 @@ async def untrash_workspace( connection, product_name=product_name, workspace_id=workspace_id, - updates=WorkspaceUpdateDB(trashed=None, trashed_by=None), + updates=WorkspaceUpdates(trashed=None, trashed_by=None), ) - child_folders: list[FolderID] = [] # TODO: find children. Check with MD + child_folders: list[FolderID] = ( + [] + # NOTE: follows up with https://github.com/ITISFoundation/osparc-simcore/issues/7034 + ) for folder_id in child_folders: await untrash_folder( @@ -114,7 +123,10 @@ async def untrash_workspace( folder_id=folder_id, ) - child_projects: list[ProjectID] = [] # TODO: find children. Check with MD + child_projects: list[ProjectID] = ( + [] + # NOTE: follows up with https://github.com/ITISFoundation/osparc-simcore/issues/7034 + ) for project_id in child_projects: await untrash_project( diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_db.py b/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_repository.py similarity index 91% rename from services/web/server/src/simcore_service_webserver/workspaces/_workspaces_db.py rename to services/web/server/src/simcore_service_webserver/workspaces/_workspaces_repository.py index b88bdd918ae4..63907713bccc 100644 --- a/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_db.py +++ b/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_repository.py @@ -13,10 +13,10 @@ from models_library.rest_ordering import OrderBy, OrderDirection from models_library.users import UserID from models_library.workspaces import ( - UserWorkspaceAccessRightsDB, - WorkspaceDB, + UserWorkspaceWithAccessRights, + Workspace, WorkspaceID, - WorkspaceUpdateDB, + WorkspaceUpdates, ) from pydantic import NonNegativeInt from simcore_postgres_database.models.workspaces import workspaces @@ -52,8 +52,8 @@ workspaces.c.trashed_by, ) -assert set(WorkspaceDB.model_fields) == {c.name for c in _SELECTION_ARGS} # nosec -assert set(WorkspaceUpdateDB.model_fields).issubset( # nosec +assert set(Workspace.model_fields) == {c.name for c in _SELECTION_ARGS} # nosec +assert set(WorkspaceUpdates.model_fields).issubset( # nosec c.name for c in workspaces.columns ) @@ -67,7 +67,7 @@ async def create_workspace( name: str, description: str | None, thumbnail: str | None, -) -> WorkspaceDB: +) -> Workspace: async with transaction_context(get_asyncpg_engine(app), connection) as conn: result = await conn.stream( workspaces.insert() @@ -83,7 +83,7 @@ async def create_workspace( .returning(*_SELECTION_ARGS) ) row = await result.first() - return WorkspaceDB.model_validate(row) + return Workspace.model_validate(row) _access_rights_subquery = ( @@ -119,7 +119,7 @@ async def list_workspaces_for_user( offset: NonNegativeInt, limit: NonNegativeInt, order_by: OrderBy, -) -> tuple[int, list[UserWorkspaceAccessRightsDB]]: +) -> tuple[int, list[UserWorkspaceWithAccessRights]]: my_access_rights_subquery = create_my_workspace_access_rights_subquery( user_id=user_id ) @@ -163,8 +163,8 @@ async def list_workspaces_for_user( total_count = await conn.scalar(count_query) result = await conn.stream(list_query) - items: list[UserWorkspaceAccessRightsDB] = [ - UserWorkspaceAccessRightsDB.model_validate(row) async for row in result + items: list[UserWorkspaceWithAccessRights] = [ + UserWorkspaceWithAccessRights.model_validate(row) async for row in result ] return cast(int, total_count), items @@ -177,7 +177,7 @@ async def get_workspace_for_user( user_id: UserID, workspace_id: WorkspaceID, product_name: ProductName, -) -> UserWorkspaceAccessRightsDB: +) -> UserWorkspaceWithAccessRights: my_access_rights_subquery = create_my_workspace_access_rights_subquery( user_id=user_id ) @@ -204,7 +204,7 @@ async def get_workspace_for_user( raise WorkspaceAccessForbiddenError( reason=f"User {user_id} does not have access to the workspace {workspace_id}. Or workspace does not exist.", ) - return UserWorkspaceAccessRightsDB.model_validate(row) + return UserWorkspaceWithAccessRights.model_validate(row) async def update_workspace( @@ -213,8 +213,8 @@ async def update_workspace( *, product_name: ProductName, workspace_id: WorkspaceID, - updates: WorkspaceUpdateDB, -) -> WorkspaceDB: + updates: WorkspaceUpdates, +) -> Workspace: # NOTE: at least 'touch' if updated_values is empty _updates = { **updates.model_dump(exclude_unset=True), @@ -234,7 +234,7 @@ async def update_workspace( row = await result.first() if row is None: raise WorkspaceNotFoundError(reason=f"Workspace {workspace_id} not found.") - return WorkspaceDB.model_validate(row) + return Workspace.model_validate(row) async def delete_workspace( diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_handlers.py b/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_rest.py similarity index 78% rename from services/web/server/src/simcore_service_webserver/workspaces/_workspaces_handlers.py rename to services/web/server/src/simcore_service_webserver/workspaces/_workspaces_rest.py index c1f706f259af..eb40b338ef3c 100644 --- a/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_handlers.py +++ b/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_rest.py @@ -4,12 +4,12 @@ from models_library.api_schemas_webserver.workspaces import ( WorkspaceCreateBodyParams, WorkspaceGet, - WorkspaceGetPage, WorkspaceReplaceBodyParams, ) 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.workspaces import UserWorkspaceWithAccessRights from servicelib.aiohttp import status from servicelib.aiohttp.requests_validation import ( parse_request_body_as, @@ -23,9 +23,9 @@ from ..login.decorators import login_required from ..security.decorators import permission_required from ..utils_aiohttp import envelope_json_response -from . import _workspaces_api -from ._exceptions_handlers import handle_plugin_requests_exceptions -from ._models import ( +from . import _workspaces_service +from ._common.exceptions_handlers import handle_plugin_requests_exceptions +from ._common.models import ( WorkspacesFilters, WorkspacesListQueryParams, WorkspacesPathParams, @@ -46,16 +46,20 @@ async def create_workspace(request: web.Request): req_ctx = WorkspacesRequestContext.model_validate(request) body_params = await parse_request_body_as(WorkspaceCreateBodyParams, request) - workspace: WorkspaceGet = await _workspaces_api.create_workspace( - request.app, - user_id=req_ctx.user_id, - name=body_params.name, - description=body_params.description, - thumbnail=body_params.thumbnail, - product_name=req_ctx.product_name, + workspace: UserWorkspaceWithAccessRights = ( + await _workspaces_service.create_workspace( + request.app, + user_id=req_ctx.user_id, + name=body_params.name, + description=body_params.description, + thumbnail=body_params.thumbnail, + product_name=req_ctx.product_name, + ) ) - return envelope_json_response(workspace, web.HTTPCreated) + return envelope_json_response( + WorkspaceGet.from_domain_model(workspace), web.HTTPCreated + ) @routes.get(f"/{VTAG}/workspaces", name="list_workspaces") @@ -72,7 +76,7 @@ async def list_workspaces(request: web.Request): query_params.filters = WorkspacesFilters() assert query_params.filters - workspaces: WorkspaceGetPage = await _workspaces_api.list_workspaces( + total_count, workspaces = await _workspaces_service.list_workspaces( app=request.app, user_id=req_ctx.user_id, product_name=req_ctx.product_name, @@ -85,9 +89,9 @@ async def list_workspaces(request: web.Request): page = Page[WorkspaceGet].model_validate( paginate_data( - chunk=workspaces.items, + chunk=[WorkspaceGet.from_domain_model(w) for w in workspaces], request_url=request.url, - total=workspaces.total, + total=total_count, limit=query_params.limit, offset=query_params.offset, ) @@ -106,14 +110,14 @@ async def get_workspace(request: web.Request): req_ctx = WorkspacesRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(WorkspacesPathParams, request) - workspace: WorkspaceGet = await _workspaces_api.get_workspace( + workspace = await _workspaces_service.get_workspace( app=request.app, workspace_id=path_params.workspace_id, user_id=req_ctx.user_id, product_name=req_ctx.product_name, ) - return envelope_json_response(workspace) + return envelope_json_response(WorkspaceGet.from_domain_model(workspace)) @routes.put( @@ -128,14 +132,14 @@ async def replace_workspace(request: web.Request): path_params = parse_request_path_parameters_as(WorkspacesPathParams, request) body_params = await parse_request_body_as(WorkspaceReplaceBodyParams, request) - workspace: WorkspaceGet = await _workspaces_api.update_workspace( + workspace = await _workspaces_service.update_workspace( app=request.app, user_id=req_ctx.user_id, workspace_id=path_params.workspace_id, product_name=req_ctx.product_name, **body_params.model_dump(), ) - return envelope_json_response(workspace) + return envelope_json_response(WorkspaceGet.from_domain_model(workspace)) @routes.delete( @@ -149,7 +153,7 @@ async def delete_workspace(request: web.Request): req_ctx = WorkspacesRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(WorkspacesPathParams, request) - await _workspaces_api.delete_workspace( + await _workspaces_service.delete_workspace( app=request.app, user_id=req_ctx.user_id, workspace_id=path_params.workspace_id, diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_api.py b/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_service.py similarity index 58% rename from services/web/server/src/simcore_service_webserver/workspaces/_workspaces_api.py rename to services/web/server/src/simcore_service_webserver/workspaces/_workspaces_service.py index b4881c2816c2..e87dc72d054e 100644 --- a/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_api.py +++ b/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_service.py @@ -3,43 +3,24 @@ import logging from aiohttp import web -from models_library.api_schemas_webserver.workspaces import ( - WorkspaceGet, - WorkspaceGetPage, -) from models_library.products import ProductName from models_library.rest_ordering import OrderBy from models_library.users import UserID from models_library.workspaces import ( - UserWorkspaceAccessRightsDB, + UserWorkspaceWithAccessRights, WorkspaceID, - WorkspaceUpdateDB, + WorkspaceUpdates, ) from pydantic import NonNegativeInt from ..projects._db_utils import PermissionStr from ..users.api import get_user -from . import _workspaces_db as db +from . import _workspaces_repository as db from .errors import WorkspaceAccessForbiddenError _logger = logging.getLogger(__name__) -def _to_api_model(workspace_db: UserWorkspaceAccessRightsDB) -> WorkspaceGet: - return WorkspaceGet( - workspace_id=workspace_db.workspace_id, - name=workspace_db.name, - description=workspace_db.description, - thumbnail=workspace_db.thumbnail, - created_at=workspace_db.created, - modified_at=workspace_db.modified, - trashed_at=workspace_db.trashed, - trashed_by=workspace_db.trashed_by if workspace_db.trashed else None, - my_access_rights=workspace_db.my_access_rights, - access_rights=workspace_db.access_rights, - ) - - async def create_workspace( app: web.Application, *, @@ -48,10 +29,9 @@ async def create_workspace( description: str | None, thumbnail: str | None, product_name: ProductName, -) -> WorkspaceGet: +) -> UserWorkspaceWithAccessRights: user = await get_user(app, user_id=user_id) - - created_workspace_db = await db.create_workspace( + created = await db.create_workspace( app, product_name=product_name, owner_primary_gid=user["primary_gid"], @@ -59,13 +39,12 @@ async def create_workspace( description=description, thumbnail=thumbnail, ) - workspace_db = await db.get_workspace_for_user( + return await db.get_workspace_for_user( app, user_id=user_id, - workspace_id=created_workspace_db.workspace_id, + workspace_id=created.workspace_id, product_name=product_name, ) - return _to_api_model(workspace_db) async def get_workspace( @@ -74,15 +53,14 @@ async def get_workspace( user_id: UserID, workspace_id: WorkspaceID, product_name: ProductName, -) -> WorkspaceGet: - workspace_db = await check_user_workspace_access( +) -> UserWorkspaceWithAccessRights: + return await get_user_workspace( app=app, user_id=user_id, workspace_id=workspace_id, product_name=product_name, permission="read", ) - return _to_api_model(workspace_db) async def list_workspaces( @@ -95,7 +73,7 @@ async def list_workspaces( offset: NonNegativeInt, limit: int, order_by: OrderBy, -) -> WorkspaceGetPage: +) -> tuple[int, list[UserWorkspaceWithAccessRights]]: total_count, workspaces = await db.list_workspaces_for_user( app, user_id=user_id, @@ -107,10 +85,7 @@ async def list_workspaces( order_by=order_by, ) - return WorkspaceGetPage( - items=[_to_api_model(workspace_db) for workspace_db in workspaces], - total=total_count, - ) + return total_count, workspaces async def update_workspace( @@ -120,7 +95,7 @@ async def update_workspace( user_id: UserID, workspace_id: WorkspaceID, **updates, -) -> WorkspaceGet: +) -> UserWorkspaceWithAccessRights: await check_user_workspace_access( app=app, @@ -133,15 +108,14 @@ async def update_workspace( app, workspace_id=workspace_id, product_name=product_name, - updates=WorkspaceUpdateDB(**updates), + updates=WorkspaceUpdates(**updates), ) - workspace_db = await db.get_workspace_for_user( + return await db.get_workspace_for_user( app, user_id=user_id, workspace_id=workspace_id, product_name=product_name, ) - return _to_api_model(workspace_db) async def delete_workspace( @@ -159,28 +133,67 @@ async def delete_workspace( permission="delete", ) - await db.delete_workspace(app, workspace_id=workspace_id, product_name=product_name) + await db.delete_workspace( + app, + workspace_id=workspace_id, + product_name=product_name, + ) -async def check_user_workspace_access( +async def get_user_workspace( app: web.Application, *, user_id: UserID, workspace_id: WorkspaceID, product_name: ProductName, - permission: PermissionStr = "read", -) -> UserWorkspaceAccessRightsDB: + permission: PermissionStr | None, +) -> UserWorkspaceWithAccessRights: """ - Raises WorkspaceAccessForbiddenError if no access + + Here checking access is optional. A use case is when the caller has guarantees that + `user_id` has granted access and we do not want to re-check + + Raises: + WorkspaceAccessForbiddenError: if permission not None and user_id does not have access """ - workspace_db: UserWorkspaceAccessRightsDB = await db.get_workspace_for_user( + workspace: UserWorkspaceWithAccessRights = await db.get_workspace_for_user( app=app, user_id=user_id, workspace_id=workspace_id, product_name=product_name ) - if getattr(workspace_db.my_access_rights, permission, False) is False: - raise WorkspaceAccessForbiddenError( - user_id=user_id, - workspace_id=workspace_id, - product_name=product_name, - permission_checked=permission, + + # NOTE: check here is optional + if permission is not None: + has_user_granted_permission = getattr( + workspace.my_access_rights, permission, False ) - return workspace_db + if not has_user_granted_permission: + raise WorkspaceAccessForbiddenError( + user_id=user_id, + workspace_id=workspace_id, + product_name=product_name, + permission_checked=permission, + ) + return workspace + + +async def check_user_workspace_access( + app: web.Application, + *, + user_id: UserID, + workspace_id: WorkspaceID, + product_name: ProductName, + permission: PermissionStr, +) -> UserWorkspaceWithAccessRights: + """ + As `get_user_workspace` but here access check is required + + Raises: + WorkspaceAccessForbiddenError + """ + return await get_user_workspace( + app, + user_id=user_id, + workspace_id=workspace_id, + product_name=product_name, + # NOTE: check here is required + permission=permission, + ) diff --git a/services/web/server/src/simcore_service_webserver/workspaces/api.py b/services/web/server/src/simcore_service_webserver/workspaces/api.py index 2b3ca3bbd42f..117d8d6d43d4 100644 --- a/services/web/server/src/simcore_service_webserver/workspaces/api.py +++ b/services/web/server/src/simcore_service_webserver/workspaces/api.py @@ -1,10 +1,15 @@ # mypy: disable-error-code=truthy-function -from ._workspaces_api import check_user_workspace_access, get_workspace -assert get_workspace # nosec -assert check_user_workspace_access # nosec +from ._workspaces_service import ( + check_user_workspace_access, + get_user_workspace, + get_workspace, +) __all__: tuple[str, ...] = ( - "get_workspace", "check_user_workspace_access", + "get_user_workspace", + "get_workspace", ) + +# nopycln: file diff --git a/services/web/server/src/simcore_service_webserver/workspaces/plugin.py b/services/web/server/src/simcore_service_webserver/workspaces/plugin.py index d67a9167c927..b5936e128db4 100644 --- a/services/web/server/src/simcore_service_webserver/workspaces/plugin.py +++ b/services/web/server/src/simcore_service_webserver/workspaces/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 _groups_handlers, _trash_handlers, _workspaces_handlers +from . import _groups_rest, _trash_rest, _workspaces_rest _logger = logging.getLogger(__name__) @@ -23,6 +23,6 @@ def setup_workspaces(app: web.Application): assert app[APP_SETTINGS_KEY].WEBSERVER_WORKSPACES # nosec # routes - app.router.add_routes(_workspaces_handlers.routes) - app.router.add_routes(_groups_handlers.routes) - app.router.add_routes(_trash_handlers.routes) + app.router.add_routes(_workspaces_rest.routes) + app.router.add_routes(_groups_rest.routes) + app.router.add_routes(_trash_rest.routes) diff --git a/services/web/server/tests/conftest.py b/services/web/server/tests/conftest.py index c474c48ab572..ab862056e038 100644 --- a/services/web/server/tests/conftest.py +++ b/services/web/server/tests/conftest.py @@ -18,6 +18,7 @@ from aiohttp.test_utils import TestClient from common_library.json_serialization import json_dumps from faker import Faker +from models_library.api_schemas_webserver.projects import ProjectGet from models_library.projects import ProjectID from models_library.projects_nodes_io import NodeID from models_library.projects_state import ProjectState @@ -216,7 +217,7 @@ async def _setup( project_data: ProjectDict = {} expected_data: ProjectDict = { "classifiers": [], - "accessRights": [], + "accessRights": {}, "tags": [], "lastChangeDate": None, "creationDate": None, @@ -235,12 +236,16 @@ async def _setup( "trashedAt": None, } if from_study: - # access rights are replaced - expected_data = deepcopy(from_study) - expected_data["accessRights"] = {} + from_study_wo_access_rights = deepcopy(from_study) + from_study_wo_access_rights.pop("accessRights") + expected_data = {**expected_data, **from_study_wo_access_rights} if not as_template: expected_data["name"] = f"{from_study['name']} (Copy)" + expected_data = ProjectGet.from_domain_model(expected_data).model_dump( + mode="json", by_alias=True + ) + if not from_study or project: assert NEW_PROJECT.request_payload project_data = deepcopy(NEW_PROJECT.request_payload) diff --git a/services/web/server/tests/unit/isolated/test_groups_models.py b/services/web/server/tests/unit/isolated/test_groups_models.py index 2e5201422e9e..a6711f6329a0 100644 --- a/services/web/server/tests/unit/isolated/test_groups_models.py +++ b/services/web/server/tests/unit/isolated/test_groups_models.py @@ -57,7 +57,7 @@ def test_output_schemas_from_models(faker: Faker): group_type=GroupType.STANDARD, thumbnail=None, ) - output_schema = GroupGet.from_model( + output_schema = GroupGet.from_domain_model( domain_model, access_rights=AccessRightsDict(read=True, write=False, delete=False), ) @@ -73,7 +73,7 @@ def test_output_schemas_from_models(faker: Faker): primary_gid=13, access_rights=AccessRightsDict(read=True, write=False, delete=False), ) - output_schema = GroupUserGet.from_model(user=domain_model) + output_schema = GroupUserGet.from_domain_model(user=domain_model) assert output_schema.user_name == domain_model.name @@ -82,13 +82,13 @@ def test_input_schemas_to_models(faker: Faker): input_schema = GroupCreate( label=faker.word(), description=faker.sentence(), thumbnail=faker.url() ) - domain_model = input_schema.to_model() + domain_model = input_schema.to_domain_model() assert isinstance(domain_model, StandardGroupCreate) assert domain_model.name == input_schema.label # input : scheam -> model input_schema = GroupUpdate(label=faker.word()) - domain_model = input_schema.to_model() + domain_model = input_schema.to_domain_model() assert isinstance(domain_model, StandardGroupUpdate) assert domain_model.name == input_schema.label 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 dcf954d2b544..5345d7e41b86 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 @@ -208,6 +208,7 @@ async def test_list_projects( folder_id = got.pop("folderId") assert got == {k: template_project[k] for k in got} + assert not ProjectState( **project_state ).locked.value, "Templates are not locked" @@ -220,6 +221,7 @@ async def test_list_projects( folder_id = got.pop("folderId") assert got == {k: user_project[k] for k in got} + assert ProjectState(**project_state) assert project_permalink is None assert folder_id is None diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers__clone_in_workspace_and_folder.py b/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers__clone_in_workspace_and_folder.py index 8ba05ea870c1..8d302b4a3364 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers__clone_in_workspace_and_folder.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers__clone_in_workspace_and_folder.py @@ -3,9 +3,8 @@ # pylint: disable=unused-argument # pylint: disable=unused-variable -from collections.abc import Iterator from copy import deepcopy -from typing import Any +from typing import Any, AsyncIterator import pytest import sqlalchemy as sa @@ -20,17 +19,19 @@ from simcore_postgres_database.models.folders_v2 import folders_v2 from simcore_postgres_database.models.workspaces import workspaces from simcore_service_webserver.db.models import UserRole -from simcore_service_webserver.folders._folders_api import create_folder +from simcore_service_webserver.folders._folders_service import create_folder from simcore_service_webserver.projects._folders_api import move_project_into_folder from simcore_service_webserver.projects.models import ProjectDict -from simcore_service_webserver.workspaces._workspaces_api import create_workspace +from simcore_service_webserver.workspaces._workspaces_service import create_workspace from yarl import URL @pytest.fixture async def create_workspace_and_folder( client: TestClient, logged_user: UserInfoDict, postgres_db: sa.engine.Engine -) -> Iterator[tuple[WorkspaceID, FolderID]]: +) -> AsyncIterator[tuple[WorkspaceID, FolderID]]: + assert client.app + workspace = await create_workspace( client.app, user_id=logged_user["id"], 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 6fcd177f37b2..f1d1933f6963 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 @@ -1368,7 +1368,7 @@ async def test_open_shared_project_at_same_time( client: TestClient, client_on_running_server_factory: Callable, logged_user: dict, - shared_project: dict, + shared_project: ProjectDict, socketio_client_factory: Callable, client_session_id_factory: Callable, user_role: UserRole, @@ -1444,7 +1444,7 @@ async def test_open_shared_project_at_same_time( elif data: project_status = ProjectState(**data.pop("state")) data.pop("folderId") - assert data == shared_project + assert data == {k: shared_project[k] for k in data} assert project_status.locked.value assert project_status.locked.owner assert project_status.locked.owner.first_name in [ 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 098a4a783888..0d9ef69bcbea 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 @@ -16,7 +16,7 @@ import pytest import sqlalchemy as sa from aiohttp.test_utils import TestClient -from common_library.dict_tools import copy_from_dict_ex +from common_library.dict_tools import copy_from_dict_ex, remap_keys from faker import Faker from models_library.projects import ProjectID from models_library.projects_nodes_io import NodeID, NodeIDStr @@ -97,7 +97,8 @@ def _assert_added_project( "creationDate", "lastChangeDate", "accessRights", # NOTE: access rights were moved away from the projects table - "trashedAt", + "trashed", + "trashedBy", "trashedExplicitly", ] assert {k: v for k, v in expected_prj.items() if k in _DIFFERENT_KEYS} != { @@ -184,6 +185,7 @@ async def insert_project_in_db( client: TestClient, ) -> AsyncIterator[Callable[..., Awaitable[dict[str, Any]]]]: inserted_projects = [] + assert client.app async def _inserter(prj: dict[str, Any], **overrides) -> dict[str, Any]: # add project without user id -> by default creates a template @@ -708,7 +710,11 @@ async def test_replace_user_project( aiopg_engine: aiopg.sa.engine.Engine, ): PROJECT_DICT_IGNORE_FIELDS = {"lastChangeDate"} - original_project = user_project + original_project = remap_keys( + user_project, + rename={"trashedAt": "trashed"}, + ) + # replace the project with the same should do nothing working_project = await db_api.replace_project( original_project, 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 9080eb74fd82..6c38f65770d5 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 @@ -76,20 +76,20 @@ async def test_trash_projects( # noqa: PLR0915 # this test should have no errors stopping services mock_remove_dynamic_services = mocker.patch( - "simcore_service_webserver.projects._trash_api.projects_api.remove_project_dynamic_services", + "simcore_service_webserver.projects._trash_service.projects_api.remove_project_dynamic_services", autospec=True, ) mock_stop_pipeline = mocker.patch( - "simcore_service_webserver.projects._trash_api.director_v2_api.stop_pipeline", + "simcore_service_webserver.projects._trash_service.director_v2_api.stop_pipeline", autospec=True, ) mocker.patch( - "simcore_service_webserver.projects._trash_api.director_v2_api.is_pipeline_running", + "simcore_service_webserver.projects._trash_service.director_v2_api.is_pipeline_running", return_value=is_project_running, autospec=True, ) mocker.patch( - "simcore_service_webserver.projects._trash_api.dynamic_scheduler_api.list_dynamic_services", + "simcore_service_webserver.projects._trash_service.dynamic_scheduler_api.list_dynamic_services", return_value=[mocker.MagicMock()] if is_project_running else [], autospec=True, ) diff --git a/services/web/server/tests/unit/with_dbs/03/version_control/conftest.py b/services/web/server/tests/unit/with_dbs/03/version_control/conftest.py index 53bdeb18c5b0..d9fc7213a63f 100644 --- a/services/web/server/tests/unit/with_dbs/03/version_control/conftest.py +++ b/services/web/server/tests/unit/with_dbs/03/version_control/conftest.py @@ -10,6 +10,7 @@ import aiohttp import pytest from aiohttp.test_utils import TestClient +from common_library.dict_tools import remap_keys from faker import Faker from models_library.projects import ProjectID from models_library.projects_nodes import Node @@ -198,11 +199,13 @@ async def _go(client: TestClient, project_uuid: UUID) -> None: project["workbench"] = {node_id: jsonable_encoder(node)} db: ProjectDBAPI = ProjectDBAPI.get_from_app_context(client.app) - project.pop("state") + project_db = remap_keys(project, rename={"trashedAt": "trashed"}) + project_db.pop("state") + await db.replace_project( - project, + project_db, logged_user["id"], - project_uuid=project["uuid"], + project_uuid=project_db["uuid"], product_name="osparc", ) diff --git a/services/web/server/tests/unit/with_dbs/03/version_control/test_version_control_handlers.py b/services/web/server/tests/unit/with_dbs/03/version_control/test_version_control_handlers.py index ac229a3b410d..a1faf49d35f5 100644 --- a/services/web/server/tests/unit/with_dbs/03/version_control/test_version_control_handlers.py +++ b/services/web/server/tests/unit/with_dbs/03/version_control/test_version_control_handlers.py @@ -11,7 +11,8 @@ import aiohttp import pytest from aiohttp.test_utils import TestClient -from models_library.projects import Project, ProjectID +from models_library.api_schemas_webserver.projects import ProjectGet +from models_library.projects import ProjectID from models_library.rest_pagination import Page from models_library.users import UserID from pydantic.main import BaseModel @@ -62,7 +63,7 @@ async def test_workflow( # get existing project resp = await client.get(f"/{VX}/projects/{project_uuid}") data, _ = await assert_status(resp, status.HTTP_200_OK) - project = Project.model_validate(data) + project = ProjectGet.model_validate(data) assert project.uuid == UUID(project_uuid) # @@ -179,7 +180,7 @@ async def test_workflow( # get working copy resp = await client.get(f"/{VX}/projects/{project_uuid}") data, _ = await assert_status(resp, status.HTTP_200_OK) - project_wc = Project.model_validate(data) + project_wc = ProjectGet.model_validate(data) assert project_wc.uuid == UUID(project_uuid) assert project_wc != project 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 582376778fa9..bcbcbb4c6f79 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 @@ -55,14 +55,16 @@ async def _get_user_projects(client) -> list[ProjectDict]: def _assert_same_projects(got: dict, expected: dict): exclude = { + "accessRights", "creationDate", "lastChangeDate", "prjOwner", + "trashedAt", + "trashedBy", + "trashedExplicitly", + "ui", "uuid", "workbench", - "accessRights", - "ui", - "trashedExplicitly", } for key in expected: if key not in exclude: diff --git a/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces.py b/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces.py index 56753822a6df..aa3d8bb367e4 100644 --- a/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces.py +++ b/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces.py @@ -20,7 +20,7 @@ from servicelib.aiohttp import status from simcore_service_webserver.db.models import UserRole from simcore_service_webserver.projects.models import ProjectDict -from simcore_service_webserver.workspaces._workspaces_handlers import ( +from simcore_service_webserver.workspaces._workspaces_rest import ( WorkspacesListQueryParams, ) diff --git a/services/web/server/tests/unit/with_dbs/conftest.py b/services/web/server/tests/unit/with_dbs/conftest.py index 12d34d5be78c..6de000d96868 100644 --- a/services/web/server/tests/unit/with_dbs/conftest.py +++ b/services/web/server/tests/unit/with_dbs/conftest.py @@ -527,7 +527,17 @@ def postgres_db( with engine.begin() as conn: conn.execute(sa.DDL("DROP TABLE IF EXISTS alembic_version")) - orm.metadata.drop_all(engine) + conn.execute( + # NOTE: terminates all open transactions before droping all tables + # This solves https://github.com/ITISFoundation/osparc-simcore/issues/7008 + sa.DDL( + "SELECT pg_terminate_backend(pid) " + "FROM pg_stat_activity " + "WHERE state = 'idle in transaction';" + ) + ) + orm.metadata.drop_all(bind=conn) + engine.dispose() diff --git a/services/web/server/tests/unit/with_dbs/docker-compose-devel.yml b/services/web/server/tests/unit/with_dbs/docker-compose-devel.yml index 198e2d6e462e..2a4402c85a28 100644 --- a/services/web/server/tests/unit/with_dbs/docker-compose-devel.yml +++ b/services/web/server/tests/unit/with_dbs/docker-compose-devel.yml @@ -20,11 +20,20 @@ services: # - net.ipv4.tcp_keepalive_intvl=600 # - net.ipv4.tcp_keepalive_probes=9 # - net.ipv4.tcp_keepalive_time=600 - command: | - postgres - -c tcp_keepalives_idle=600 - -c tcp_keepalives_interval=600 - -c tcp_keepalives_count=5 + command: + - "postgres" + - "-c" + - "tcp_keepalives_idle=600" + - "-c" + - "tcp_keepalives_interval=600" + - "-c" + - "tcp_keepalives_count=5" + - "-c" + - "log_statement=all" + - "-c" + - "log_min_duration_statement=500" + - "-c" + - "log_lock_waits=on" adminer: image: adminer:4.8.1 init: true diff --git a/services/web/server/tests/unit/with_dbs/docker-compose.yml b/services/web/server/tests/unit/with_dbs/docker-compose.yml index a5b504af55f6..6fde4baab748 100644 --- a/services/web/server/tests/unit/with_dbs/docker-compose.yml +++ b/services/web/server/tests/unit/with_dbs/docker-compose.yml @@ -19,8 +19,16 @@ services: # - net.ipv4.tcp_keepalive_intvl=600 # - net.ipv4.tcp_keepalive_probes=9 # - net.ipv4.tcp_keepalive_time=600 - command: postgres -c tcp_keepalives_idle=600 -c tcp_keepalives_interval=600 -c tcp_keepalives_count=5 - + command: + - "postgres" + - "-c" + - "tcp_keepalives_idle=600" + - "-c" + - "tcp_keepalives_interval=600" + - "-c" + - "tcp_keepalives_count=5" + - "-c" + - "log_lock_waits=on" redis: image: "redis:6.2.6@sha256:4bed291aa5efb9f0d77b76ff7d4ab71eee410962965d052552db1fb80576431d" init: true diff --git a/tests/e2e/tutorials/sleepers_project_template_sql.csv b/tests/e2e/tutorials/sleepers_project_template_sql.csv index 6dbcd7d2a265..17f406b0aa03 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,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 +id,type,uuid,name,description,thumbnail,prj_owner,creation_date,last_change_date,workbench,published,access_rights,dev,classifiers,ui,quality,hidden,workspace_id,trashed,trashed_explicitly,trashed_by +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}}}",t,"{""1"": {""read"": true, ""write"": false, ""delete"": false}}",{},{},{},{},f,,,f,