diff --git a/api/specs/web-server/_folders.py b/api/specs/web-server/_folders.py index c2e75579b26..f98b5e98308 100644 --- a/api/specs/web-server/_folders.py +++ b/api/specs/web-server/_folders.py @@ -12,9 +12,9 @@ from _common import as_query from fastapi import APIRouter, Depends, status from models_library.api_schemas_webserver.folders_v2 import ( - CreateFolderBodyParams, + FolderCreateBodyParams, FolderGet, - PutFolderBodyParams, + FolderReplaceBodyParams, ) from models_library.generics import Envelope from simcore_service_webserver._meta import API_VTAG @@ -38,7 +38,7 @@ status_code=status.HTTP_201_CREATED, ) async def create_folder( - _body: CreateFolderBodyParams, + _body: FolderCreateBodyParams, ): ... @@ -79,7 +79,7 @@ async def get_folder( ) async def replace_folder( _path: Annotated[FoldersPathParams, Depends()], - _body: PutFolderBodyParams, + _body: FolderReplaceBodyParams, ): ... diff --git a/api/specs/web-server/_trash.py b/api/specs/web-server/_trash.py index 9aa23b8b288..6eb39f6593c 100644 --- a/api/specs/web-server/_trash.py +++ b/api/specs/web-server/_trash.py @@ -8,14 +8,16 @@ from typing import Annotated 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 ( FoldersPathParams, - RemoveQueryParams, + FolderTrashQueryParams, ) from simcore_service_webserver.projects._trash_handlers import ProjectPathParams -from simcore_service_webserver.projects._trash_handlers import ( - RemoveQueryParams as RemoveQueryParams_duplicated, +from simcore_service_webserver.workspaces._models import ( + WorkspacesPathParams, + WorkspaceTrashQueryParams, ) router = APIRouter( @@ -75,14 +77,14 @@ def untrash_project( responses={ status.HTTP_404_NOT_FOUND: {"description": "Not such a folder"}, status.HTTP_409_CONFLICT: { - "description": "One or more projects is in use and cannot be trashed" + "description": "One or more projects in the folder are in use and cannot be trashed" }, status.HTTP_503_SERVICE_UNAVAILABLE: {"description": "Trash service error"}, }, ) def trash_folder( _path: Annotated[FoldersPathParams, Depends()], - _query: Annotated[RemoveQueryParams_duplicated, Depends()], + _query: Annotated[FolderTrashQueryParams, Depends()], ): ... @@ -96,3 +98,36 @@ def untrash_folder( _path: Annotated[FoldersPathParams, Depends()], ): ... + + +_extra_tags = ["workspaces"] + + +@router.post( + "/workspaces/{workspace_id}:trash", + tags=_extra_tags, + status_code=status.HTTP_204_NO_CONTENT, + responses={ + status.HTTP_404_NOT_FOUND: {"description": "Not such a workspace"}, + status.HTTP_409_CONFLICT: { + "description": "One or more projects in the workspace are in use and cannot be trashed" + }, + status.HTTP_503_SERVICE_UNAVAILABLE: {"description": "Trash service error"}, + }, +) +def trash_workspace( + _path: Annotated[WorkspacesPathParams, Depends()], + _query: Annotated[WorkspaceTrashQueryParams, Depends()], +): + ... + + +@router.post( + "/workspaces/{workspace_id}:untrash", + tags=_extra_tags, + status_code=status.HTTP_204_NO_CONTENT, +) +def untrash_workspace( + _path: Annotated[WorkspacesPathParams, Depends()], +): + ... diff --git a/api/specs/web-server/_workspaces.py b/api/specs/web-server/_workspaces.py index e3f1b4ebc5c..958b8457bca 100644 --- a/api/specs/web-server/_workspaces.py +++ b/api/specs/web-server/_workspaces.py @@ -6,23 +6,24 @@ # pylint: disable=unused-variable # pylint: disable=too-many-arguments - from enum import Enum from typing import Annotated +from _common import as_query from fastapi import APIRouter, Depends, status from models_library.api_schemas_webserver.workspaces import ( - CreateWorkspaceBodyParams, - PutWorkspaceBodyParams, + WorkspaceCreateBodyParams, WorkspaceGet, + WorkspaceReplaceBodyParams, ) from models_library.generics import Envelope -from models_library.workspaces import WorkspaceID from simcore_service_webserver._meta import API_VTAG from simcore_service_webserver.workspaces._groups_api import WorkspaceGroupGet -from simcore_service_webserver.workspaces._groups_handlers import ( - _WorkspacesGroupsBodyParams, - _WorkspacesGroupsPathParams, +from simcore_service_webserver.workspaces._models import ( + WorkspacesGroupsBodyParams, + WorkspacesGroupsPathParams, + WorkspacesListQueryParams, + WorkspacesPathParams, ) router = APIRouter( @@ -32,15 +33,15 @@ ], ) -### Workspaces - @router.post( "/workspaces", response_model=Envelope[WorkspaceGet], status_code=status.HTTP_201_CREATED, ) -async def create_workspace(_body: CreateWorkspaceBodyParams): +async def create_workspace( + _body: WorkspaceCreateBodyParams, +): ... @@ -48,7 +49,9 @@ async def create_workspace(_body: CreateWorkspaceBodyParams): "/workspaces", response_model=Envelope[list[WorkspaceGet]], ) -async def list_workspaces(): +async def list_workspaces( + _query: Annotated[as_query(WorkspacesListQueryParams), Depends()], +): ... @@ -56,7 +59,9 @@ async def list_workspaces(): "/workspaces/{workspace_id}", response_model=Envelope[WorkspaceGet], ) -async def get_workspace(workspace_id: WorkspaceID): +async def get_workspace( + _path: Annotated[WorkspacesPathParams, Depends()], +): ... @@ -64,7 +69,10 @@ async def get_workspace(workspace_id: WorkspaceID): "/workspaces/{workspace_id}", response_model=Envelope[WorkspaceGet], ) -async def replace_workspace(workspace_id: WorkspaceID, _body: PutWorkspaceBodyParams): +async def replace_workspace( + _path: Annotated[WorkspacesPathParams, Depends()], + _body: WorkspaceReplaceBodyParams, +): ... @@ -72,7 +80,9 @@ async def replace_workspace(workspace_id: WorkspaceID, _body: PutWorkspaceBodyPa "/workspaces/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT, ) -async def delete_workspace(workspace_id: WorkspaceID): +async def delete_workspace( + _path: Annotated[WorkspacesPathParams, Depends()], +): ... @@ -87,8 +97,8 @@ async def delete_workspace(workspace_id: WorkspaceID): tags=_extra_tags, ) async def create_workspace_group( - _path_parms: Annotated[_WorkspacesGroupsPathParams, Depends()], - _body: _WorkspacesGroupsBodyParams, + _path: Annotated[WorkspacesGroupsPathParams, Depends()], + _body: WorkspacesGroupsBodyParams, ): ... @@ -98,7 +108,9 @@ async def create_workspace_group( response_model=Envelope[list[WorkspaceGroupGet]], tags=_extra_tags, ) -async def list_workspace_groups(workspace_id: WorkspaceID): +async def list_workspace_groups( + _path: Annotated[WorkspacesPathParams, Depends()], +): ... @@ -108,8 +120,8 @@ async def list_workspace_groups(workspace_id: WorkspaceID): tags=_extra_tags, ) async def replace_workspace_group( - _path_parms: Annotated[_WorkspacesGroupsPathParams, Depends()], - _body: _WorkspacesGroupsBodyParams, + _path: Annotated[WorkspacesGroupsPathParams, Depends()], + _body: WorkspacesGroupsBodyParams, ): ... @@ -120,6 +132,6 @@ async def replace_workspace_group( tags=_extra_tags, ) async def delete_workspace_group( - _path_parms: Annotated[_WorkspacesGroupsPathParams, Depends()] + _path: Annotated[WorkspacesGroupsPathParams, Depends()], ): ... diff --git a/packages/models-library/src/models_library/api_schemas_webserver/folders_v2.py b/packages/models-library/src/models_library/api_schemas_webserver/folders_v2.py index cd574893d94..4a88532848a 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/folders_v2.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/folders_v2.py @@ -29,7 +29,7 @@ class FolderGetPage(NamedTuple): total: PositiveInt -class CreateFolderBodyParams(InputSchema): +class FolderCreateBodyParams(InputSchema): name: IDStr parent_folder_id: FolderID | None = None workspace_id: WorkspaceID | None = None @@ -44,7 +44,7 @@ class CreateFolderBodyParams(InputSchema): )(null_or_none_str_to_none_validator) -class PutFolderBodyParams(InputSchema): +class FolderReplaceBodyParams(InputSchema): name: IDStr parent_folder_id: FolderID | None = None model_config = ConfigDict(extra="forbid") 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 32f17200ee4..73fb684d3aa 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 @@ -7,6 +7,7 @@ from pydantic import ConfigDict, PositiveInt from ..access_rights import AccessRights +from ..users import UserID from ._base import InputSchema, OutputSchema @@ -17,6 +18,8 @@ class WorkspaceGet(OutputSchema): thumbnail: str | None created_at: datetime modified_at: datetime + trashed_at: datetime | None + trashed_by: UserID | None my_access_rights: AccessRights access_rights: dict[GroupID, AccessRights] @@ -26,7 +29,7 @@ class WorkspaceGetPage(NamedTuple): total: PositiveInt -class CreateWorkspaceBodyParams(InputSchema): +class WorkspaceCreateBodyParams(InputSchema): name: str description: str | None = None thumbnail: str | None = None @@ -34,7 +37,7 @@ class CreateWorkspaceBodyParams(InputSchema): model_config = ConfigDict(extra="forbid") -class PutWorkspaceBodyParams(InputSchema): +class WorkspaceReplaceBodyParams(InputSchema): name: IDStr description: str | None = None thumbnail: str | None = None diff --git a/packages/models-library/src/models_library/trash.py b/packages/models-library/src/models_library/trash.py new file mode 100644 index 00000000000..306787ab60f --- /dev/null +++ b/packages/models-library/src/models_library/trash.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel, Field + + +class RemoveQueryParams(BaseModel): + force: bool = Field( + default=False, description="Force removal (even if resource is active)" + ) diff --git a/packages/models-library/src/models_library/workspaces.py b/packages/models-library/src/models_library/workspaces.py index 5d1d206e8dd..6c34efbf790 100644 --- a/packages/models-library/src/models_library/workspaces.py +++ b/packages/models-library/src/models_library/workspaces.py @@ -12,7 +12,7 @@ ) from .access_rights import AccessRights -from .users import GroupID +from .users import GroupID, UserID from .utils.enums import StrAutoEnum WorkspaceID: TypeAlias = PositiveInt @@ -33,10 +33,10 @@ class WorkspaceQuery(BaseModel): def validate_workspace_id(cls, value, info: ValidationInfo): scope = info.data.get("workspace_scope") if scope == WorkspaceScope.SHARED and value is None: - msg = "workspace_id must be provided when workspace_scope is SHARED." + msg = f"workspace_id must be provided when workspace_scope is SHARED. Got {scope=}, {value=}" raise ValueError(msg) if scope != WorkspaceScope.SHARED and value is not None: - msg = "workspace_id should be None when workspace_scope is not SHARED." + msg = f"workspace_id should be None when workspace_scope is not SHARED. Got {scope=}, {value=}" raise ValueError(msg) return value @@ -63,6 +63,8 @@ class WorkspaceDB(BaseModel): ..., description="Timestamp of last modification", ) + trashed: datetime | None + trashed_by: UserID | None model_config = ConfigDict(from_attributes=True) @@ -72,3 +74,11 @@ class UserWorkspaceAccessRightsDB(WorkspaceDB): access_rights: dict[GroupID, AccessRights] model_config = ConfigDict(from_attributes=True) + + +class WorkspaceUpdateDB(BaseModel): + name: str | None = None + description: str | None = None + thumbnail: str | None = None + trashed: datetime | None = None + trashed_by: UserID | None = None diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/c9db8bf5091e_trash_columns_in_workspaces.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/c9db8bf5091e_trash_columns_in_workspaces.py new file mode 100644 index 00000000000..b61a9e21009 --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/c9db8bf5091e_trash_columns_in_workspaces.py @@ -0,0 +1,57 @@ +"""trash columns in workspaces + +Revision ID: c9db8bf5091e +Revises: 8e1f83486be7 +Create Date: 2024-11-20 16:42:43.784855+00:00 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "c9db8bf5091e" +down_revision = "8e1f83486be7" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "workspaces", + sa.Column( + "trashed", + sa.DateTime(timezone=True), + nullable=True, + comment="The date and time when the workspace was marked as trashed. Null if the workspace has not been trashed [default].", + ), + ) + op.add_column( + "workspaces", + sa.Column( + "trashed_by", + sa.BigInteger(), + nullable=True, + comment="User who trashed the workspace, or null if not trashed or user is unknown.", + ), + ) + op.create_foreign_key( + "fk_workspace_trashed_by_user_id", + "workspaces", + "users", + ["trashed_by"], + ["id"], + onupdate="CASCADE", + ondelete="SET NULL", + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint( + "fk_workspace_trashed_by_user_id", "workspaces", type_="foreignkey" + ) + op.drop_column("workspaces", "trashed_by") + op.drop_column("workspaces", "trashed") + # ### end Alembic commands ### diff --git a/packages/postgres-database/src/simcore_postgres_database/models/_common.py b/packages/postgres-database/src/simcore_postgres_database/models/_common.py index c3f671d244a..47bfeb6ebf0 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/_common.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/_common.py @@ -5,6 +5,17 @@ from ..constants import DECIMAL_PLACES +class RefActions: + """Referential actions for `ON UPDATE`, `ON DELETE`""" + + # SEE https://docs.sqlalchemy.org/en/20/core/constraints.html#on-update-on-delete + CASCADE: Final[str] = "CASCADE" + SET_NULL: Final[str] = "SET NULL" + SET_DEFAULT: Final[str] = "SET DEFAULT" + RESTRICT: Final[str] = "RESTRICT" + NO_ACTION: Final[str] = "NO ACTION" + + def column_created_datetime(*, timezone: bool = True) -> sa.Column: return sa.Column( "created", @@ -34,8 +45,8 @@ def column_created_by_user( sa.Integer, sa.ForeignKey( users_table.c.id, - onupdate="CASCADE", - ondelete="SET NULL", + onupdate=RefActions.CASCADE, + ondelete=RefActions.SET_NULL, ), nullable=not required, doc="Who created this row at `created`", @@ -50,14 +61,39 @@ def column_modified_by_user( sa.Integer, sa.ForeignKey( users_table.c.id, - onupdate="CASCADE", - ondelete="SET NULL", + onupdate=RefActions.CASCADE, + ondelete=RefActions.SET_NULL, ), nullable=not required, doc="Who modified this row at `modified`", ) +def column_trashed_datetime(resource_name: str) -> sa.Column: + return sa.Column( + "trashed", + sa.DateTime(timezone=True), + nullable=True, + comment=f"The date and time when the {resource_name} was marked as trashed. " + f"Null if the {resource_name} has not been trashed [default].", + ) + + +def column_trashed_by_user(resource_name: str, users_table: sa.Table) -> sa.Column: + return sa.Column( + "trashed_by", + sa.BigInteger, + sa.ForeignKey( + users_table.c.id, + onupdate=RefActions.CASCADE, + ondelete=RefActions.SET_NULL, + name=f"fk_{resource_name}_trashed_by_user_id", + ), + nullable=True, + comment=f"User who trashed the {resource_name}, or null if not trashed or user is unknown.", + ) + + _TRIGGER_NAME: Final[str] = "auto_update_modified_timestamp" diff --git a/packages/postgres-database/src/simcore_postgres_database/models/api_keys.py b/packages/postgres-database/src/simcore_postgres_database/models/api_keys.py index 5db5416b677..416e29d4e2d 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/api_keys.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/api_keys.py @@ -13,6 +13,7 @@ import sqlalchemy as sa from sqlalchemy.sql import func +from ._common import RefActions from .base import metadata from .users import users @@ -35,7 +36,7 @@ sa.Column( "user_id", sa.BigInteger(), - sa.ForeignKey(users.c.id, ondelete="CASCADE"), + sa.ForeignKey(users.c.id, ondelete=RefActions.CASCADE), nullable=False, doc="Identified user", ), @@ -44,8 +45,8 @@ sa.String, sa.ForeignKey( "products.name", - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, name="fk_api_keys_product_name", ), nullable=False, diff --git a/packages/postgres-database/src/simcore_postgres_database/models/classifiers.py b/packages/postgres-database/src/simcore_postgres_database/models/classifiers.py index 238c7b04f8c..7e4a2cf39df 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/classifiers.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/classifiers.py @@ -11,6 +11,7 @@ from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.sql import func +from ._common import RefActions from .base import metadata group_classifiers = sa.Table( @@ -32,8 +33,8 @@ sa.ForeignKey( "groups.gid", name="fk_group_classifiers_gid_to_groups_gid", - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, ), unique=True, # Every Group can ONLY have one set of classifiers ), diff --git a/packages/postgres-database/src/simcore_postgres_database/models/cluster_to_groups.py b/packages/postgres-database/src/simcore_postgres_database/models/cluster_to_groups.py index 6f563926200..63996c1404b 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/cluster_to_groups.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/cluster_to_groups.py @@ -1,6 +1,7 @@ import sqlalchemy as sa from sqlalchemy.sql import expression, func +from ._common import RefActions from .base import metadata from .clusters import clusters from .groups import groups @@ -14,8 +15,8 @@ sa.ForeignKey( clusters.c.id, name="fk_cluster_to_groups_id_clusters", - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, ), doc="Cluster unique ID", ), @@ -25,8 +26,8 @@ sa.ForeignKey( groups.c.gid, name="fk_cluster_to_groups_gid_groups", - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, ), doc="Group unique IDentifier", ), diff --git a/packages/postgres-database/src/simcore_postgres_database/models/clusters.py b/packages/postgres-database/src/simcore_postgres_database/models/clusters.py index a4ddc53123c..39536ae241b 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/clusters.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/clusters.py @@ -4,6 +4,7 @@ from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.sql import func +from ._common import RefActions from .base import metadata @@ -36,8 +37,8 @@ class ClusterType(Enum): sa.ForeignKey( "groups.gid", name="fk_clusters_gid_groups", - onupdate="CASCADE", - ondelete="RESTRICT", + onupdate=RefActions.CASCADE, + ondelete=RefActions.RESTRICT, ), nullable=False, doc="Identifier of the group that owns this cluster", diff --git a/packages/postgres-database/src/simcore_postgres_database/models/comp_runs.py b/packages/postgres-database/src/simcore_postgres_database/models/comp_runs.py index eb84cefaa76..3975cb91eee 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/comp_runs.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/comp_runs.py @@ -5,6 +5,7 @@ from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.sql import func +from ._common import RefActions from .base import metadata from .comp_pipeline import StateType @@ -26,8 +27,8 @@ sa.ForeignKey( "projects.uuid", name="fk_comp_runs_project_uuid_projects", - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, ), nullable=False, doc="The project uuid with which the run entry is associated", @@ -38,8 +39,8 @@ sa.ForeignKey( "users.id", name="fk_comp_runs_user_id_users", - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, ), nullable=False, doc="The user id with which the run entry is associated", @@ -50,8 +51,8 @@ sa.ForeignKey( "clusters.id", name="fk_comp_runs_cluster_id_clusters", - onupdate="CASCADE", - ondelete="SET NULL", + onupdate=RefActions.CASCADE, + ondelete=RefActions.SET_NULL, ), nullable=True, doc="The cluster id on which the run entry is associated, if NULL or 0 uses the default", diff --git a/packages/postgres-database/src/simcore_postgres_database/models/confirmations.py b/packages/postgres-database/src/simcore_postgres_database/models/confirmations.py index 3b9b1274804..6fd56e8c8e0 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/confirmations.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/confirmations.py @@ -9,6 +9,7 @@ import sqlalchemy as sa +from ._common import RefActions from .base import metadata from .users import users @@ -57,6 +58,9 @@ class ConfirmationAction(enum.Enum): # constraints ---------------- sa.PrimaryKeyConstraint("code", name="confirmation_code"), sa.ForeignKeyConstraint( - ["user_id"], [users.c.id], name="user_confirmation_fkey", ondelete="CASCADE" + ["user_id"], + [users.c.id], + name="user_confirmation_fkey", + ondelete=RefActions.CASCADE, ), ) 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 fcad0ada76c..78f3de8bdf9 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,7 +1,7 @@ import sqlalchemy as sa from sqlalchemy.sql import expression -from ._common import column_created_datetime, column_modified_datetime +from ._common import RefActions, column_created_datetime, column_modified_datetime from .base import metadata from .workspaces import workspaces @@ -35,8 +35,8 @@ sa.String, sa.ForeignKey( "products.name", - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, name="fk_new_folders_to_products_name", ), nullable=False, @@ -46,8 +46,8 @@ sa.BigInteger, sa.ForeignKey( "users.id", - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, name="fk_folders_to_user_id", ), nullable=True, @@ -57,8 +57,8 @@ sa.BigInteger, sa.ForeignKey( workspaces.c.workspace_id, - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, name="fk_folders_to_workspace_id", ), nullable=True, @@ -69,7 +69,7 @@ sa.ForeignKey( "groups.gid", name="fk_new_folders_to_groups_gid", - ondelete="SET NULL", + ondelete=RefActions.SET_NULL, ), nullable=True, ), diff --git a/packages/postgres-database/src/simcore_postgres_database/models/groups.py b/packages/postgres-database/src/simcore_postgres_database/models/groups.py index 0aec758a6c6..a70e9fa8db4 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/groups.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/groups.py @@ -10,6 +10,7 @@ from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.sql import func +from ._common import RefActions from .base import metadata @@ -86,8 +87,8 @@ class GroupType(enum.Enum): sa.ForeignKey( "users.id", name="fk_user_to_groups_id_users", - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, ), doc="User unique IDentifier", ), @@ -97,8 +98,8 @@ class GroupType(enum.Enum): sa.ForeignKey( "groups.gid", name="fk_user_to_groups_gid_groups", - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, ), doc="Group unique IDentifier", ), diff --git a/packages/postgres-database/src/simcore_postgres_database/models/groups_extra_properties.py b/packages/postgres-database/src/simcore_postgres_database/models/groups_extra_properties.py index 93ffe8cd7f7..e25a1bd3b2b 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/groups_extra_properties.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/groups_extra_properties.py @@ -1,21 +1,19 @@ import sqlalchemy as sa -from ._common import column_created_datetime, column_modified_datetime +from ._common import RefActions, column_created_datetime, column_modified_datetime from .base import metadata -# -# groups_extra_properties: Maps internet access permissions to groups -# groups_extra_properties = sa.Table( "groups_extra_properties", + # groups_extra_properties: Maps internet access permissions to groups metadata, sa.Column( "group_id", sa.BigInteger, sa.ForeignKey( "groups.gid", - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, name="fk_groups_extra_properties_to_group_group_id", ), nullable=False, @@ -26,8 +24,8 @@ sa.VARCHAR, sa.ForeignKey( "products.name", - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, name="fk_groups_extra_properties_to_products_name", ), nullable=False, diff --git a/packages/postgres-database/src/simcore_postgres_database/models/payments_autorecharge.py b/packages/postgres-database/src/simcore_postgres_database/models/payments_autorecharge.py index 3b46e217fcc..df30251c50c 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/payments_autorecharge.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/payments_autorecharge.py @@ -2,6 +2,7 @@ from ._common import ( NUMERIC_KWARGS, + RefActions, column_created_datetime, column_modified_datetime, register_modified_datetime_auto_update_trigger, @@ -49,8 +50,8 @@ sa.ForeignKey( payments_methods.c.payment_method_id, name="fk_payments_autorecharge_primary_payment_method_id", - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, ), nullable=False, unique=True, diff --git a/packages/postgres-database/src/simcore_postgres_database/models/products.py b/packages/postgres-database/src/simcore_postgres_database/models/products.py index 70ed22911d7..344326fbf48 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/products.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/products.py @@ -15,6 +15,7 @@ TypedDict, ) +from ._common import RefActions from .base import metadata from .groups import groups from .jinja2_templates import jinja2_templates @@ -199,8 +200,8 @@ class ProductLoginSettingsDict(TypedDict, total=False): sa.ForeignKey( jinja2_templates.c.name, name="fk_jinja2_templates_name", - ondelete="SET NULL", - onupdate="CASCADE", + ondelete=RefActions.SET_NULL, + onupdate=RefActions.CASCADE, ), nullable=True, doc="Custom jinja2 template for registration email", @@ -238,8 +239,8 @@ class ProductLoginSettingsDict(TypedDict, total=False): sa.ForeignKey( groups.c.gid, name="fk_products_group_id", - ondelete="SET NULL", - onupdate="CASCADE", + ondelete=RefActions.SET_NULL, + onupdate=RefActions.CASCADE, ), unique=True, nullable=True, diff --git a/packages/postgres-database/src/simcore_postgres_database/models/products_prices.py b/packages/postgres-database/src/simcore_postgres_database/models/products_prices.py index 2784969149b..b0b652cd310 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/products_prices.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/products_prices.py @@ -1,6 +1,6 @@ import sqlalchemy as sa -from ._common import NUMERIC_KWARGS +from ._common import NUMERIC_KWARGS, RefActions from .base import metadata from .products import products @@ -20,8 +20,8 @@ sa.ForeignKey( products.c.name, name="fk_products_prices_product_name", - ondelete="RESTRICT", - onupdate="CASCADE", + ondelete=RefActions.RESTRICT, + onupdate=RefActions.CASCADE, ), nullable=False, doc="Product name", diff --git a/packages/postgres-database/src/simcore_postgres_database/models/products_to_templates.py b/packages/postgres-database/src/simcore_postgres_database/models/products_to_templates.py index 4236d95864e..44115660735 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/products_to_templates.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/products_to_templates.py @@ -1,6 +1,7 @@ import sqlalchemy as sa from ._common import ( + RefActions, column_created_datetime, column_modified_datetime, register_modified_datetime_auto_update_trigger, @@ -16,8 +17,8 @@ sa.String, sa.ForeignKey( "products.name", - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, name="fk_products_to_templates_product_name", ), nullable=False, @@ -28,8 +29,8 @@ sa.ForeignKey( jinja2_templates.c.name, name="fk_products_to_templates_template_name", - ondelete="CASCADE", - onupdate="CASCADE", + ondelete=RefActions.CASCADE, + onupdate=RefActions.CASCADE, ), nullable=True, doc="Custom jinja2 template", diff --git a/packages/postgres-database/src/simcore_postgres_database/models/project_to_groups.py b/packages/postgres-database/src/simcore_postgres_database/models/project_to_groups.py index f27c6081f6c..162a51d4d24 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/project_to_groups.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/project_to_groups.py @@ -1,7 +1,7 @@ import sqlalchemy as sa from sqlalchemy.sql import expression -from ._common import column_created_datetime, column_modified_datetime +from ._common import RefActions, column_created_datetime, column_modified_datetime from .base import metadata from .groups import groups from .projects import projects @@ -15,8 +15,8 @@ sa.ForeignKey( projects.c.uuid, name="fk_project_to_groups_project_uuid", - ondelete="CASCADE", - onupdate="CASCADE", + ondelete=RefActions.CASCADE, + onupdate=RefActions.CASCADE, ), index=True, nullable=False, @@ -28,8 +28,8 @@ sa.ForeignKey( groups.c.gid, name="fk_project_to_groups_gid_groups", - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, ), nullable=False, doc="Group unique IDentifier", 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 778d2b80eb5..93ff3a74ea3 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/projects.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/projects.py @@ -7,6 +7,7 @@ from sqlalchemy.dialects.postgresql import ARRAY, JSONB from sqlalchemy.sql import expression, func +from ._common import RefActions from .base import metadata @@ -64,8 +65,8 @@ class ProjectType(enum.Enum): sa.ForeignKey( "users.id", name="fk_projects_prj_owner_users", - onupdate="CASCADE", - ondelete="RESTRICT", + onupdate=RefActions.CASCADE, + ondelete=RefActions.RESTRICT, ), nullable=True, doc="Project's owner", @@ -161,8 +162,8 @@ class ProjectType(enum.Enum): sa.BigInteger, sa.ForeignKey( "workspaces.workspace_id", - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, name="fk_projects_to_workspaces_id", ), nullable=True, diff --git a/packages/postgres-database/src/simcore_postgres_database/models/projects_comments.py b/packages/postgres-database/src/simcore_postgres_database/models/projects_comments.py index fb07f56a202..919b143bff3 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/projects_comments.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/projects_comments.py @@ -1,6 +1,6 @@ import sqlalchemy as sa -from ._common import column_created_datetime, column_modified_datetime +from ._common import RefActions, column_created_datetime, column_modified_datetime from .base import metadata from .projects import projects from .users import users @@ -22,8 +22,8 @@ sa.ForeignKey( projects.c.uuid, name="fk_projects_comments_project_uuid", - ondelete="CASCADE", - onupdate="CASCADE", + ondelete=RefActions.CASCADE, + onupdate=RefActions.CASCADE, ), index=True, nullable=False, @@ -36,7 +36,7 @@ sa.ForeignKey( users.c.id, name="fk_projects_comments_user_id", - ondelete="SET NULL", + ondelete=RefActions.SET_NULL, ), doc="user who created the comment", nullable=True, diff --git a/packages/postgres-database/src/simcore_postgres_database/models/projects_metadata.py b/packages/postgres-database/src/simcore_postgres_database/models/projects_metadata.py index b2d2d4f640a..c5379f407d4 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/projects_metadata.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/projects_metadata.py @@ -7,6 +7,7 @@ from sqlalchemy.dialects.postgresql import JSONB from ._common import ( + RefActions, column_created_datetime, column_modified_datetime, register_modified_datetime_auto_update_trigger, @@ -42,8 +43,8 @@ sa.String, sa.ForeignKey( projects.c.uuid, - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, name="fk_projects_metadata_project_uuid", ), nullable=False, @@ -88,15 +89,15 @@ sa.ForeignKeyConstraint( ("parent_project_uuid", "parent_node_id"), (projects_nodes.c.project_uuid, projects_nodes.c.node_id), - onupdate="CASCADE", - ondelete="SET NULL", + onupdate=RefActions.CASCADE, + ondelete=RefActions.SET_NULL, name="fk_projects_metadata_parent_node_id", ), sa.ForeignKeyConstraint( ("root_parent_project_uuid", "root_parent_node_id"), (projects_nodes.c.project_uuid, projects_nodes.c.node_id), - onupdate="CASCADE", - ondelete="SET NULL", + onupdate=RefActions.CASCADE, + ondelete=RefActions.SET_NULL, name="fk_projects_metadata_root_parent_node_id", ), ) diff --git a/packages/postgres-database/src/simcore_postgres_database/models/projects_networks.py b/packages/postgres-database/src/simcore_postgres_database/models/projects_networks.py index efac321a539..905c1aa4cf0 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/projects_networks.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/projects_networks.py @@ -1,6 +1,7 @@ import sqlalchemy as sa from sqlalchemy.dialects.postgresql import JSONB +from ._common import RefActions from .base import metadata from .projects import projects @@ -13,8 +14,8 @@ sa.ForeignKey( projects.c.uuid, name="fk_projects_networks_project_uuid_projects", - ondelete="CASCADE", - onupdate="CASCADE", + ondelete=RefActions.CASCADE, + onupdate=RefActions.CASCADE, ), primary_key=True, doc="project reference and primary key for this table", diff --git a/packages/postgres-database/src/simcore_postgres_database/models/projects_node_to_pricing_unit.py b/packages/postgres-database/src/simcore_postgres_database/models/projects_node_to_pricing_unit.py index aa0e42a35a3..903466c3f93 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/projects_node_to_pricing_unit.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/projects_node_to_pricing_unit.py @@ -7,6 +7,7 @@ import sqlalchemy as sa from ._common import ( + RefActions, column_created_datetime, column_modified_datetime, register_modified_datetime_auto_update_trigger, @@ -22,8 +23,8 @@ sa.BIGINT, sa.ForeignKey( projects_nodes.c.project_node_id, - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, name="fk_projects_nodes__project_node_to_pricing_unit__uuid", ), nullable=False, diff --git a/packages/postgres-database/src/simcore_postgres_database/models/projects_nodes.py b/packages/postgres-database/src/simcore_postgres_database/models/projects_nodes.py index 1da6f138cdb..f4b569270c4 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/projects_nodes.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/projects_nodes.py @@ -8,6 +8,7 @@ from sqlalchemy.dialects.postgresql import JSONB from ._common import ( + RefActions, column_created_datetime, column_modified_datetime, register_modified_datetime_auto_update_trigger, @@ -31,8 +32,8 @@ sa.String, sa.ForeignKey( projects.c.uuid, - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, name="fk_projects_to_projects_nodes_to_projects_uuid", ), nullable=False, diff --git a/packages/postgres-database/src/simcore_postgres_database/models/projects_tags.py b/packages/postgres-database/src/simcore_postgres_database/models/projects_tags.py index 223271872b7..3507fc8d239 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/projects_tags.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/projects_tags.py @@ -1,5 +1,6 @@ import sqlalchemy as sa +from ._common import RefActions from .base import metadata from .projects import projects from .tags import tags @@ -15,8 +16,8 @@ sa.BigInteger, sa.ForeignKey( projects.c.id, - onupdate="CASCADE", - ondelete="SET NULL", + onupdate=RefActions.CASCADE, + ondelete=RefActions.SET_NULL, name="project_tags_project_id_fkey", ), nullable=True, # <-- NULL means that project was deleted @@ -25,7 +26,9 @@ sa.Column( "tag_id", sa.BigInteger, - sa.ForeignKey(tags.c.id, onupdate="CASCADE", ondelete="CASCADE"), + sa.ForeignKey( + tags.c.id, onupdate=RefActions.CASCADE, ondelete=RefActions.CASCADE + ), nullable=False, ), sa.Column( diff --git a/packages/postgres-database/src/simcore_postgres_database/models/projects_to_folders.py b/packages/postgres-database/src/simcore_postgres_database/models/projects_to_folders.py index ba2b7334621..f86725a8119 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/projects_to_folders.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/projects_to_folders.py @@ -1,6 +1,6 @@ import sqlalchemy as sa -from ._common import column_created_datetime, column_modified_datetime +from ._common import RefActions, column_created_datetime, column_modified_datetime from .base import metadata from .folders_v2 import folders_v2 @@ -13,8 +13,8 @@ sa.ForeignKey( "projects.uuid", name="fk_projects_to_folders_to_projects_uuid", - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, ), ), sa.Column( @@ -23,8 +23,8 @@ sa.ForeignKey( folders_v2.c.folder_id, name="fk_projects_to_folders_to_folders_id", - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, ), ), sa.Column( @@ -32,8 +32,8 @@ sa.BigInteger, sa.ForeignKey( "users.id", - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, name="fk_projects_to_folders_to_user_id", ), nullable=True, diff --git a/packages/postgres-database/src/simcore_postgres_database/models/projects_to_products.py b/packages/postgres-database/src/simcore_postgres_database/models/projects_to_products.py index e6a1d1cd530..47a430f0a00 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/projects_to_products.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/projects_to_products.py @@ -1,6 +1,6 @@ import sqlalchemy as sa -from ._common import column_created_datetime, column_modified_datetime +from ._common import RefActions, column_created_datetime, column_modified_datetime from .base import metadata projects_to_products = sa.Table( @@ -11,8 +11,8 @@ sa.String, sa.ForeignKey( "projects.uuid", - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, name="fk_projects_to_products_product_uuid", ), nullable=False, @@ -23,8 +23,8 @@ sa.String, sa.ForeignKey( "products.name", - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, name="fk_projects_to_products_product_name", ), nullable=False, diff --git a/packages/postgres-database/src/simcore_postgres_database/models/projects_to_wallet.py b/packages/postgres-database/src/simcore_postgres_database/models/projects_to_wallet.py index dfe3108a845..74e7a7ef635 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/projects_to_wallet.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/projects_to_wallet.py @@ -1,6 +1,6 @@ import sqlalchemy as sa -from ._common import column_created_datetime, column_modified_datetime +from ._common import RefActions, column_created_datetime, column_modified_datetime from .base import metadata from .projects import projects from .wallets import wallets @@ -14,8 +14,8 @@ sa.ForeignKey( projects.c.uuid, name="fk_projects_comments_project_uuid", - ondelete="CASCADE", - onupdate="CASCADE", + ondelete=RefActions.CASCADE, + onupdate=RefActions.CASCADE, ), index=True, primary_key=True, @@ -28,8 +28,8 @@ sa.ForeignKey( wallets.c.wallet_id, name="fk_projects_wallet_wallets_id", - ondelete="CASCADE", - onupdate="CASCADE", + ondelete=RefActions.CASCADE, + onupdate=RefActions.CASCADE, ), nullable=False, ), diff --git a/packages/postgres-database/src/simcore_postgres_database/models/projects_version_control.py b/packages/postgres-database/src/simcore_postgres_database/models/projects_version_control.py index c0fd0816e51..7d183f03942 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/projects_version_control.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/projects_version_control.py @@ -6,6 +6,7 @@ from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.sql import func +from ._common import RefActions from .base import metadata from .projects import projects @@ -32,8 +33,8 @@ sa.ForeignKey( projects.c.uuid, name="fk_projects_vc_repos_project_uuid", - ondelete="CASCADE", # if project is deleted, all references in project_vc_* tables are deleted except for projects_vc_snapshots. - onupdate="CASCADE", + ondelete=RefActions.CASCADE, # if project is deleted, all references in project_vc_* tables are deleted except for projects_vc_snapshots. + onupdate=RefActions.CASCADE, ), nullable=False, unique=True, @@ -113,8 +114,8 @@ sa.ForeignKey( projects_vc_repos.c.id, name="fk_projects_vc_commits_repo_id", - ondelete="CASCADE", - onupdate="CASCADE", + ondelete=RefActions.CASCADE, + onupdate=RefActions.CASCADE, ), nullable=False, doc="Repository to which this commit belongs", @@ -125,7 +126,7 @@ sa.ForeignKey( "projects_vc_commits.id", name="fk_projects_vc_commits_parent_commit_id", - onupdate="CASCADE", + onupdate=RefActions.CASCADE, ), nullable=True, doc="Preceding commit", @@ -136,8 +137,8 @@ sa.ForeignKey( projects_vc_snapshots.c.checksum, name="fk_projects_vc_commits_snapshot_checksum", - ondelete="RESTRICT", - onupdate="CASCADE", + ondelete=RefActions.RESTRICT, + onupdate=RefActions.CASCADE, ), nullable=False, doc="SHA-1 checksum of snapshot." @@ -175,8 +176,8 @@ sa.ForeignKey( projects_vc_repos.c.id, name="fk_projects_vc_tags_repo_id", - ondelete="CASCADE", - onupdate="CASCADE", + ondelete=RefActions.CASCADE, + onupdate=RefActions.CASCADE, ), nullable=False, doc="Repository to which this commit belongs", @@ -187,8 +188,8 @@ sa.ForeignKey( projects_vc_commits.c.id, name="fk_projects_vc_tags_commit_id", - ondelete="CASCADE", - onupdate="CASCADE", + ondelete=RefActions.CASCADE, + onupdate=RefActions.CASCADE, ), nullable=False, doc="Points to the tagged commit", @@ -243,8 +244,8 @@ sa.ForeignKey( projects_vc_repos.c.id, name="projects_vc_branches_repo_id", - ondelete="CASCADE", - onupdate="CASCADE", + ondelete=RefActions.CASCADE, + onupdate=RefActions.CASCADE, ), nullable=False, doc="Repository to which this branch belongs", @@ -255,8 +256,8 @@ sa.ForeignKey( projects_vc_commits.c.id, name="fk_projects_vc_branches_head_commit_id", - ondelete="RESTRICT", - onupdate="CASCADE", + ondelete=RefActions.RESTRICT, + onupdate=RefActions.CASCADE, ), nullable=True, doc="Points to the head commit of this branchNull heads are detached", @@ -299,8 +300,8 @@ sa.ForeignKey( projects_vc_repos.c.id, name="projects_vc_branches_repo_id", - ondelete="CASCADE", - onupdate="CASCADE", + ondelete=RefActions.CASCADE, + onupdate=RefActions.CASCADE, ), primary_key=True, nullable=False, @@ -312,8 +313,8 @@ sa.ForeignKey( projects_vc_branches.c.id, name="fk_projects_vc_heads_head_branch_id", - ondelete="CASCADE", - onupdate="CASCADE", + ondelete=RefActions.CASCADE, + onupdate=RefActions.CASCADE, ), unique=True, nullable=True, diff --git a/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_credit_transactions.py b/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_credit_transactions.py index d0a164c054d..d1501a42431 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_credit_transactions.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_credit_transactions.py @@ -5,7 +5,12 @@ import sqlalchemy as sa -from ._common import NUMERIC_KWARGS, column_created_datetime, column_modified_datetime +from ._common import ( + NUMERIC_KWARGS, + RefActions, + column_created_datetime, + column_modified_datetime, +) from .base import metadata @@ -130,7 +135,7 @@ class CreditTransactionClassification(str, enum.Enum): "resource_tracker_service_runs.service_run_id", ], name="resource_tracker_credit_trans_fkey", - onupdate="CASCADE", - ondelete="RESTRICT", + onupdate=RefActions.CASCADE, + ondelete=RefActions.RESTRICT, ), ) diff --git a/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_pricing_plan_to_service.py b/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_pricing_plan_to_service.py index 820ec42fc50..5fd77bbbaad 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_pricing_plan_to_service.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_pricing_plan_to_service.py @@ -4,7 +4,7 @@ import sqlalchemy as sa -from ._common import column_created_datetime, column_modified_datetime +from ._common import RefActions, column_created_datetime, column_modified_datetime from .base import metadata resource_tracker_pricing_plan_to_service = sa.Table( @@ -16,8 +16,8 @@ sa.ForeignKey( "resource_tracker_pricing_plans.pricing_plan_id", name="fk_resource_tracker_pricing_details_pricing_plan_id", - onupdate="CASCADE", - ondelete="RESTRICT", + onupdate=RefActions.CASCADE, + ondelete=RefActions.RESTRICT, ), nullable=False, doc="Identifier index", @@ -49,7 +49,7 @@ ["service_key", "service_version"], ["services_meta_data.key", "services_meta_data.version"], name="fk_rut_pricing_plan_to_service_key_and_version", - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, ), ) diff --git a/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_pricing_plans.py b/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_pricing_plans.py index 81d98ebcac1..e0e1c69efa6 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_pricing_plans.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_pricing_plans.py @@ -4,7 +4,7 @@ import sqlalchemy as sa -from ._common import column_created_datetime, column_modified_datetime +from ._common import RefActions, column_created_datetime, column_modified_datetime from .base import metadata @@ -35,8 +35,8 @@ class PricingPlanClassification(str, enum.Enum): sa.String, sa.ForeignKey( "products.name", - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, name="fk_rut_pricing_plans_product_name", ), nullable=False, diff --git a/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_pricing_unit_costs.py b/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_pricing_unit_costs.py index 46031532387..7a4c4e5f6a1 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_pricing_unit_costs.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_pricing_unit_costs.py @@ -6,7 +6,12 @@ """ import sqlalchemy as sa -from ._common import NUMERIC_KWARGS, column_created_datetime, column_modified_datetime +from ._common import ( + NUMERIC_KWARGS, + RefActions, + column_created_datetime, + column_modified_datetime, +) from .base import metadata resource_tracker_pricing_unit_costs = sa.Table( @@ -25,8 +30,8 @@ sa.ForeignKey( "resource_tracker_pricing_plans.pricing_plan_id", name="fk_resource_tracker_pricing_units_costs_pricing_plan_id", - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, ), nullable=False, doc="Foreign key to pricing plan", @@ -44,8 +49,8 @@ sa.ForeignKey( "resource_tracker_pricing_units.pricing_unit_id", name="fk_resource_tracker_pricing_units_costs_pricing_unit_id", - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, ), nullable=False, doc="Foreign key to pricing unit", diff --git a/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_pricing_units.py b/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_pricing_units.py index 6b047c2207a..aecbc1d07e1 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_pricing_units.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_pricing_units.py @@ -7,7 +7,7 @@ import sqlalchemy as sa from sqlalchemy.dialects.postgresql import JSONB -from ._common import column_created_datetime, column_modified_datetime +from ._common import RefActions, column_created_datetime, column_modified_datetime from .base import metadata resource_tracker_pricing_units = sa.Table( @@ -26,8 +26,8 @@ sa.ForeignKey( "resource_tracker_pricing_plans.pricing_plan_id", name="fk_resource_tracker_pricing_units_pricing_plan_id", - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, ), nullable=False, doc="Foreign key to pricing plan", diff --git a/packages/postgres-database/src/simcore_postgres_database/models/services.py b/packages/postgres-database/src/simcore_postgres_database/models/services.py index b329a1e8156..30fbf5af696 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/services.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/services.py @@ -2,6 +2,7 @@ from sqlalchemy.dialects.postgresql import ARRAY, JSONB from sqlalchemy.sql import expression +from ._common import RefActions from .base import metadata services_meta_data = sa.Table( @@ -33,8 +34,8 @@ sa.ForeignKey( "groups.gid", name="fk_services_meta_data_gid_groups", - onupdate="CASCADE", - ondelete="RESTRICT", + onupdate=RefActions.CASCADE, + ondelete=RefActions.RESTRICT, ), nullable=True, doc="Identifier of the group that owns this service (editable)", @@ -141,8 +142,8 @@ sa.ForeignKey( "groups.gid", name="fk_services_gid_groups", - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, ), doc="Group Identifier of user that get these access-rights", ), @@ -168,8 +169,8 @@ sa.ForeignKey( "products.name", name="fk_services_name_products", - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, ), doc="Product Identifier", ), @@ -192,8 +193,8 @@ sa.ForeignKeyConstraint( ["key", "version"], ["services_meta_data.key", "services_meta_data.version"], - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, ), sa.PrimaryKeyConstraint( "key", "version", "gid", "product_name", name="services_access_pk" diff --git a/packages/postgres-database/src/simcore_postgres_database/models/services_compatibility.py b/packages/postgres-database/src/simcore_postgres_database/models/services_compatibility.py index aa3929385e3..d151b665885 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/services_compatibility.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/services_compatibility.py @@ -11,6 +11,7 @@ from typing_extensions import NotRequired, Required from ._common import ( + RefActions, column_created_datetime, column_modified_by_user, column_modified_datetime, @@ -61,8 +62,8 @@ class CompatiblePolicyDict(typing_extensions.TypedDict, total=False): sa.ForeignKeyConstraint( ["key", "version"], ["services_meta_data.key", "services_meta_data.version"], - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, ), sa.PrimaryKeyConstraint("key", "version", name="services_compatibility_pk"), ) diff --git a/packages/postgres-database/src/simcore_postgres_database/models/services_consume_filetypes.py b/packages/postgres-database/src/simcore_postgres_database/models/services_consume_filetypes.py index e46df8670e8..65c6c8546b3 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/services_consume_filetypes.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/services_consume_filetypes.py @@ -7,6 +7,7 @@ """ import sqlalchemy as sa +from ._common import RefActions from .base import metadata # @@ -75,8 +76,8 @@ sa.ForeignKeyConstraint( ["service_key", "service_version"], ["services_meta_data.key", "services_meta_data.version"], - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, ), # This table stores services (key:version) that consume filetype by AT LEAST one input_port # if more ports can consume, then it should only be added once in this table diff --git a/packages/postgres-database/src/simcore_postgres_database/models/services_environments.py b/packages/postgres-database/src/simcore_postgres_database/models/services_environments.py index 3248191e7a1..498191b7267 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/services_environments.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/services_environments.py @@ -3,7 +3,7 @@ import sqlalchemy as sa from sqlalchemy.dialects.postgresql import JSONB -from ._common import column_created_datetime, column_modified_datetime +from ._common import RefActions, column_created_datetime, column_modified_datetime from .base import metadata # Intentionally includes the term "SECRET" to avoid leaking this value on a public domain @@ -36,8 +36,8 @@ sa.ForeignKey( "products.name", name="fk_services_name_products", - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, ), # NOTE: since this is part of the primary key this is required # NOTE: an alternative would be to not use this as a primary key @@ -59,8 +59,8 @@ sa.ForeignKeyConstraint( ["service_key", "service_base_version"], ["services_meta_data.key", "services_meta_data.version"], - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, # NOTE: this might be a problem: if a version in the metadata is deleted, # all versions above will take the secret_map for the previous one. ), diff --git a/packages/postgres-database/src/simcore_postgres_database/models/services_specifications.py b/packages/postgres-database/src/simcore_postgres_database/models/services_specifications.py index a8b16b06f91..452be5e25f0 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/services_specifications.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/services_specifications.py @@ -8,6 +8,7 @@ import sqlalchemy as sa from sqlalchemy.dialects.postgresql import JSONB +from ._common import RefActions from .base import metadata services_specifications = sa.Table( @@ -31,8 +32,8 @@ sa.ForeignKey( "groups.gid", name="fk_services_specifications_gid_groups", - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, ), doc="Group Identifier", ), @@ -52,8 +53,8 @@ sa.ForeignKeyConstraint( ["service_key", "service_version"], ["services_meta_data.key", "services_meta_data.version"], - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, ), # This table stores services (key:version) that consume filetype by AT LEAST one input_port # if more ports can consume, then it should only be added once in this table diff --git a/packages/postgres-database/src/simcore_postgres_database/models/services_tags.py b/packages/postgres-database/src/simcore_postgres_database/models/services_tags.py index c774cdcd317..083ea9f2807 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/services_tags.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/services_tags.py @@ -1,5 +1,6 @@ import sqlalchemy as sa +from ._common import RefActions from .base import metadata from .tags import tags @@ -26,7 +27,9 @@ sa.Column( "tag_id", sa.BigInteger, - sa.ForeignKey(tags.c.id, onupdate="CASCADE", ondelete="CASCADE"), + sa.ForeignKey( + tags.c.id, onupdate=RefActions.CASCADE, ondelete=RefActions.CASCADE + ), nullable=False, doc="Identifier of the tag assigned to this specific service (service_key, service_version).", ), @@ -34,8 +37,8 @@ sa.ForeignKeyConstraint( ["service_key", "service_version"], ["services_meta_data.key", "services_meta_data.version"], - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, ), sa.UniqueConstraint( "service_key", "service_version", "tag_id", name="services_tags_uc" diff --git a/packages/postgres-database/src/simcore_postgres_database/models/tags_access_rights.py b/packages/postgres-database/src/simcore_postgres_database/models/tags_access_rights.py index 9078a9254f1..b818c975817 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/tags_access_rights.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/tags_access_rights.py @@ -1,6 +1,6 @@ import sqlalchemy as sa -from ._common import column_created_datetime, column_modified_datetime +from ._common import RefActions, column_created_datetime, column_modified_datetime from .base import metadata from .groups import groups from .tags import tags @@ -17,8 +17,8 @@ sa.BigInteger(), sa.ForeignKey( tags.c.id, - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, name="fk_tag_to_group_tag_id", ), nullable=False, @@ -29,8 +29,8 @@ sa.BigInteger, sa.ForeignKey( groups.c.gid, - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, name="fk_tag_to_group_group_id", ), nullable=False, diff --git a/packages/postgres-database/src/simcore_postgres_database/models/user_preferences.py b/packages/postgres-database/src/simcore_postgres_database/models/user_preferences.py index 8d3cdde98d5..e380cd23b94 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/user_preferences.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/user_preferences.py @@ -1,5 +1,6 @@ import sqlalchemy as sa +from ._common import RefActions from .base import metadata from .products import products from .users import users @@ -12,8 +13,8 @@ def _user_id_column(fk_name: str) -> sa.Column: sa.ForeignKey( users.c.id, name=fk_name, - ondelete="CASCADE", - onupdate="CASCADE", + ondelete=RefActions.CASCADE, + onupdate=RefActions.CASCADE, ), nullable=False, ) @@ -26,8 +27,8 @@ def _product_name_column(fk_name: str) -> sa.Column: sa.ForeignKey( products.c.name, name=fk_name, - ondelete="CASCADE", - onupdate="CASCADE", + ondelete=RefActions.CASCADE, + onupdate=RefActions.CASCADE, ), nullable=False, ) diff --git a/packages/postgres-database/src/simcore_postgres_database/models/user_to_projects.py b/packages/postgres-database/src/simcore_postgres_database/models/user_to_projects.py index 45147bef610..4a66e0be611 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/user_to_projects.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/user_to_projects.py @@ -1,5 +1,6 @@ import sqlalchemy as sa +from ._common import RefActions from .base import metadata from .projects import projects from .users import users @@ -15,8 +16,8 @@ sa.ForeignKey( users.c.id, name="fk_user_to_projects_id_users", - ondelete="CASCADE", - onupdate="CASCADE", + ondelete=RefActions.CASCADE, + onupdate=RefActions.CASCADE, ), nullable=False, ), @@ -26,8 +27,8 @@ sa.ForeignKey( projects.c.id, name="fk_user_to_projects_id_projects", - ondelete="CASCADE", - onupdate="CASCADE", + ondelete=RefActions.CASCADE, + onupdate=RefActions.CASCADE, ), nullable=False, ), diff --git a/packages/postgres-database/src/simcore_postgres_database/models/users.py b/packages/postgres-database/src/simcore_postgres_database/models/users.py index 90d5e063662..61b8c321130 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/users.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/users.py @@ -3,6 +3,7 @@ import sqlalchemy as sa +from ._common import RefActions from .base import metadata _USER_ROLE_TO_LEVEL = { @@ -113,8 +114,8 @@ class UserStatus(str, Enum): sa.ForeignKey( "groups.gid", name="fk_users_gid_groups", - onupdate="CASCADE", - ondelete="RESTRICT", + onupdate=RefActions.CASCADE, + ondelete=RefActions.RESTRICT, ), doc="User's group ID", ), diff --git a/packages/postgres-database/src/simcore_postgres_database/models/users_details.py b/packages/postgres-database/src/simcore_postgres_database/models/users_details.py index 2e7a7ab79c5..555e623dbdc 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/users_details.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/users_details.py @@ -2,6 +2,7 @@ from sqlalchemy.dialects import postgresql from ._common import ( + RefActions, column_created_datetime, column_modified_datetime, register_modified_datetime_auto_update_trigger, @@ -22,8 +23,8 @@ sa.Integer, sa.ForeignKey( users.c.id, - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, ), nullable=True, doc="None if row was added during pre-registration or join column with `users` after registration", @@ -71,8 +72,8 @@ sa.Integer, sa.ForeignKey( users.c.id, - onupdate="CASCADE", - ondelete="SET NULL", + onupdate=RefActions.CASCADE, + ondelete=RefActions.SET_NULL, ), nullable=True, doc="PO user that issued this pre-registration", diff --git a/packages/postgres-database/src/simcore_postgres_database/models/wallet_to_groups.py b/packages/postgres-database/src/simcore_postgres_database/models/wallet_to_groups.py index a5d4016fa51..7679b5f5285 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/wallet_to_groups.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/wallet_to_groups.py @@ -1,7 +1,7 @@ import sqlalchemy as sa from sqlalchemy.sql import expression -from ._common import column_created_datetime, column_modified_datetime +from ._common import RefActions, column_created_datetime, column_modified_datetime from .base import metadata from .groups import groups from .wallets import wallets @@ -15,8 +15,8 @@ sa.ForeignKey( wallets.c.wallet_id, name="fk_wallet_to_groups_id_wallets", - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, ), doc="Wallet unique ID", ), @@ -26,8 +26,8 @@ sa.ForeignKey( groups.c.gid, name="fk_wallet_to_groups_gid_groups", - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, ), doc="Group unique IDentifier", ), diff --git a/packages/postgres-database/src/simcore_postgres_database/models/wallets.py b/packages/postgres-database/src/simcore_postgres_database/models/wallets.py index 3c765529976..27fa821a6bf 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/wallets.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/wallets.py @@ -2,7 +2,7 @@ import sqlalchemy as sa -from ._common import column_created_datetime, column_modified_datetime +from ._common import RefActions, column_created_datetime, column_modified_datetime from .base import metadata @@ -30,8 +30,8 @@ class WalletStatus(str, enum.Enum): sa.ForeignKey( "groups.gid", name="fk_wallets_gid_groups", - onupdate="CASCADE", - ondelete="RESTRICT", + onupdate=RefActions.CASCADE, + ondelete=RefActions.RESTRICT, ), nullable=False, doc="Identifier of the group that owns this wallet (Should be just PRIMARY GROUP)", @@ -55,8 +55,8 @@ class WalletStatus(str, enum.Enum): sa.String, sa.ForeignKey( "products.name", - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, name="fk_wallets_product_name", ), nullable=False, diff --git a/packages/postgres-database/src/simcore_postgres_database/models/workspaces.py b/packages/postgres-database/src/simcore_postgres_database/models/workspaces.py index 998c7676761..756bbe9642e 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/workspaces.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/workspaces.py @@ -1,7 +1,14 @@ import sqlalchemy as sa -from ._common import 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 workspaces = sa.Table( "workspaces", @@ -28,8 +35,8 @@ sa.ForeignKey( "groups.gid", name="fk_workspaces_gid_groups", - onupdate="CASCADE", - ondelete="RESTRICT", + onupdate=RefActions.CASCADE, + ondelete=RefActions.RESTRICT, ), nullable=False, doc="Identifier of the group that owns this workspace (Should be just PRIMARY GROUP)", @@ -39,8 +46,8 @@ sa.String, sa.ForeignKey( "products.name", - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, name="fk_workspaces_product_name", ), nullable=False, @@ -48,8 +55,11 @@ ), column_created_datetime(timezone=True), column_modified_datetime(timezone=True), + column_trashed_datetime("workspace"), + column_trashed_by_user("workspace", users_table=users), ) + # ------------------------ TRIGGERS new_workspace_trigger = sa.DDL( """ diff --git a/packages/postgres-database/src/simcore_postgres_database/models/workspaces_access_rights.py b/packages/postgres-database/src/simcore_postgres_database/models/workspaces_access_rights.py index 960ef643538..2a247fb477f 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/workspaces_access_rights.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/workspaces_access_rights.py @@ -1,7 +1,7 @@ import sqlalchemy as sa from sqlalchemy.sql import expression -from ._common import column_created_datetime, column_modified_datetime +from ._common import RefActions, column_created_datetime, column_modified_datetime from .base import metadata from .groups import groups from .workspaces import workspaces @@ -15,8 +15,8 @@ sa.ForeignKey( workspaces.c.workspace_id, name="fk_workspaces_access_rights_id_workspaces", - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, ), doc="Workspace unique ID", ), @@ -26,8 +26,8 @@ sa.ForeignKey( groups.c.gid, name="fk_workspaces_access_rights_gid_groups", - onupdate="CASCADE", - ondelete="CASCADE", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, ), doc="Group unique IDentifier", ), diff --git a/packages/postgres-database/tests/test_models_tags.py b/packages/postgres-database/tests/test_models_tags.py index 71a7ba4702d..7b129e5edc7 100644 --- a/packages/postgres-database/tests/test_models_tags.py +++ b/packages/postgres-database/tests/test_models_tags.py @@ -6,6 +6,7 @@ import pytest import sqlalchemy as sa +from simcore_postgres_database.models._common import RefActions from simcore_postgres_database.models.base import metadata from simcore_postgres_database.models.tags_access_rights import tags_access_rights from simcore_postgres_database.models.users import users @@ -23,7 +24,9 @@ def test_migration_downgrade_script(): sa.Column( "user_id", sa.BigInteger, - sa.ForeignKey("users.id", onupdate="CASCADE", ondelete="CASCADE"), + sa.ForeignKey( + "users.id", onupdate=RefActions.CASCADE, ondelete=RefActions.CASCADE + ), nullable=False, ), sa.Column("name", sa.String, nullable=False), diff --git a/services/web/server/VERSION b/services/web/server/VERSION index bcce5d06b8a..301092317fe 100644 --- a/services/web/server/VERSION +++ b/services/web/server/VERSION @@ -1 +1 @@ -0.45.0 +0.46.0 diff --git a/services/web/server/setup.cfg b/services/web/server/setup.cfg index 2b54478220b..0c5bbbcb6b3 100644 --- a/services/web/server/setup.cfg +++ b/services/web/server/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.45.0 +current_version = 0.46.0 commit = True message = services/webserver api version: {current_version} → {new_version} tag = False diff --git a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml index cc9a1f00e70..688d2187cb3 100644 --- a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml +++ b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml @@ -2,7 +2,7 @@ openapi: 3.1.0 info: title: simcore-service-webserver description: Main service with an interface (http-API & websockets) to the web front-end - version: 0.45.0 + version: 0.46.0 servers: - url: '' description: webserver @@ -2605,7 +2605,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/CreateFolderBodyParams' + $ref: '#/components/schemas/FolderCreateBodyParams' responses: '201': description: Successful Response @@ -2775,7 +2775,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/PutFolderBodyParams' + $ref: '#/components/schemas/FolderReplaceBodyParams' responses: '200': description: Successful Response @@ -5504,7 +5504,8 @@ paths: '404': description: Not such a folder '409': - description: One or more projects is in use and cannot be trashed + description: One or more projects in the folder are in use and cannot be + trashed '503': description: Trash service error /v0/folders/{folder_id}:untrash: @@ -5526,6 +5527,58 @@ paths: responses: '204': description: Successful Response + /v0/workspaces/{workspace_id}:trash: + post: + tags: + - trash + - workspaces + summary: Trash Workspace + operationId: trash_workspace + parameters: + - name: workspace_id + in: path + required: true + schema: + type: integer + exclusiveMinimum: true + title: Workspace Id + minimum: 0 + - name: force + in: query + required: false + schema: + type: boolean + default: false + title: Force + responses: + '204': + description: Successful Response + '404': + description: Not such a workspace + '409': + description: One or more projects in the workspace are in use and cannot + be trashed + '503': + description: Trash service error + /v0/workspaces/{workspace_id}:untrash: + post: + tags: + - trash + - workspaces + summary: Untrash Workspace + operationId: untrash_workspace + parameters: + - name: workspace_id + in: path + required: true + schema: + type: integer + exclusiveMinimum: true + title: Workspace Id + minimum: 0 + responses: + '204': + description: Successful Response /v0/repos/projects: get: tags: @@ -5750,29 +5803,17 @@ paths: schema: $ref: '#/components/schemas/Envelope_CheckpointApiModel_' /v0/workspaces: - get: - tags: - - workspaces - summary: List Workspaces - operationId: list_workspaces - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Envelope_list_WorkspaceGet__' post: tags: - workspaces summary: Create Workspace operationId: create_workspace requestBody: + required: true content: application/json: schema: - $ref: '#/components/schemas/CreateWorkspaceBodyParams' - required: true + $ref: '#/components/schemas/WorkspaceCreateBodyParams' responses: '201': description: Successful Response @@ -5780,6 +5821,52 @@ paths: application/json: schema: $ref: '#/components/schemas/Envelope_WorkspaceGet_' + get: + tags: + - workspaces + summary: List Workspaces + operationId: list_workspaces + parameters: + - name: order_by + in: query + required: false + schema: + type: string + contentMediaType: application/json + contentSchema: {} + default: '{"field":"modified","direction":"desc"}' + title: Order By + - name: filters + in: query + required: false + schema: + anyOf: + - type: string + contentMediaType: application/json + contentSchema: {} + - type: 'null' + title: Filters + - name: limit + in: query + required: false + schema: + type: integer + default: 20 + title: Limit + - name: offset + in: query + required: false + schema: + type: integer + default: 0 + title: Offset + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope_list_WorkspaceGet__' /v0/workspaces/{workspace_id}: get: tags: @@ -5821,7 +5908,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/PutWorkspaceBodyParams' + $ref: '#/components/schemas/WorkspaceReplaceBodyParams' responses: '200': description: Successful Response @@ -5875,7 +5962,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/_WorkspacesGroupsBodyParams' + $ref: '#/components/schemas/WorkspacesGroupsBodyParams' responses: '201': description: Successful Response @@ -5911,7 +5998,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/_WorkspacesGroupsBodyParams' + $ref: '#/components/schemas/WorkspacesGroupsBodyParams' responses: '200': description: Successful Response @@ -7112,32 +7199,6 @@ components: - name - alpha2 title: CountryInfoDict - CreateFolderBodyParams: - properties: - name: - type: string - maxLength: 100 - minLength: 1 - title: Name - parentFolderId: - anyOf: - - type: integer - exclusiveMinimum: true - minimum: 0 - - type: 'null' - title: Parentfolderid - workspaceId: - anyOf: - - type: integer - exclusiveMinimum: true - minimum: 0 - - type: 'null' - title: Workspaceid - additionalProperties: false - type: object - required: - - name - title: CreateFolderBodyParams CreatePricingPlanBodyParams: properties: displayName: @@ -7227,26 +7288,6 @@ components: required: - priceDollars title: CreateWalletPayment - CreateWorkspaceBodyParams: - properties: - name: - type: string - title: Name - description: - anyOf: - - type: string - - type: 'null' - title: Description - thumbnail: - anyOf: - - type: string - - type: 'null' - title: Thumbnail - additionalProperties: false - type: object - required: - - name - title: CreateWorkspaceBodyParams DatCoreFileLink: properties: store: @@ -9199,6 +9240,32 @@ components: - urls - links title: FileUploadSchema + FolderCreateBodyParams: + properties: + name: + type: string + maxLength: 100 + minLength: 1 + title: Name + parentFolderId: + anyOf: + - type: integer + exclusiveMinimum: true + minimum: 0 + - type: 'null' + title: Parentfolderid + workspaceId: + anyOf: + - type: integer + exclusiveMinimum: true + minimum: 0 + - type: 'null' + title: Workspaceid + additionalProperties: false + type: object + required: + - name + title: FolderCreateBodyParams FolderGet: properties: folderId: @@ -9255,6 +9322,25 @@ components: - workspaceId - myAccessRights title: FolderGet + FolderReplaceBodyParams: + properties: + name: + type: string + maxLength: 100 + minLength: 1 + title: Name + parentFolderId: + anyOf: + - type: integer + exclusiveMinimum: true + minimum: 0 + - type: 'null' + title: Parentfolderid + additionalProperties: false + type: object + required: + - name + title: FolderReplaceBodyParams GenerateInvitation: properties: guest: @@ -12051,25 +12137,6 @@ components: - created - modified title: ProjectsCommentsAPI - PutFolderBodyParams: - properties: - name: - type: string - maxLength: 100 - minLength: 1 - title: Name - parentFolderId: - anyOf: - - type: integer - exclusiveMinimum: true - minimum: 0 - - type: 'null' - title: Parentfolderid - additionalProperties: false - type: object - required: - - name - title: PutFolderBodyParams PutWalletBodyParams: properties: name: @@ -12094,28 +12161,6 @@ components: - thumbnail - status title: PutWalletBodyParams - PutWorkspaceBodyParams: - properties: - name: - type: string - maxLength: 100 - minLength: 1 - title: Name - description: - anyOf: - - type: string - - type: 'null' - title: Description - thumbnail: - anyOf: - - type: string - - type: 'null' - title: Thumbnail - additionalProperties: false - type: object - required: - - name - title: PutWorkspaceBodyParams RegisterBody: properties: email: @@ -14286,6 +14331,26 @@ components: - num_fds - task_counts title: WorkerMetrics + WorkspaceCreateBodyParams: + properties: + name: + type: string + title: Name + description: + anyOf: + - type: string + - type: 'null' + title: Description + thumbnail: + anyOf: + - type: string + - type: 'null' + title: Thumbnail + additionalProperties: false + type: object + required: + - name + title: WorkspaceCreateBodyParams WorkspaceGet: properties: workspaceId: @@ -14314,6 +14379,19 @@ components: type: string format: date-time title: Modifiedat + trashedAt: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Trashedat + trashedBy: + anyOf: + - type: integer + exclusiveMinimum: true + minimum: 0 + - type: 'null' + title: Trashedby myAccessRights: $ref: '#/components/schemas/AccessRights' accessRights: @@ -14329,6 +14407,8 @@ components: - thumbnail - createdAt - modifiedAt + - trashedAt + - trashedBy - myAccessRights - accessRights title: WorkspaceGet @@ -14365,6 +14445,46 @@ components: - created - modified title: WorkspaceGroupGet + WorkspaceReplaceBodyParams: + properties: + name: + type: string + maxLength: 100 + minLength: 1 + title: Name + description: + anyOf: + - type: string + - type: 'null' + title: Description + thumbnail: + anyOf: + - type: string + - type: 'null' + title: Thumbnail + additionalProperties: false + type: object + required: + - name + title: WorkspaceReplaceBodyParams + WorkspacesGroupsBodyParams: + properties: + read: + type: boolean + title: Read + write: + type: boolean + title: Write + delete: + type: boolean + title: Delete + additionalProperties: false + type: object + required: + - read + - write + - delete + title: WorkspacesGroupsBodyParams _ComputationStarted: properties: pipeline_id: @@ -14481,21 +14601,3 @@ components: - write - delete title: _WalletsGroupsBodyParams - _WorkspacesGroupsBodyParams: - properties: - read: - type: boolean - title: Read - write: - type: boolean - title: Write - delete: - type: boolean - title: Delete - additionalProperties: false - type: object - required: - - read - - write - - delete - title: _WorkspacesGroupsBodyParams diff --git a/services/web/server/src/simcore_service_webserver/folders/_exceptions_handlers.py b/services/web/server/src/simcore_service_webserver/folders/_exceptions_handlers.py index 4f83b5e1872..c611809decd 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_exceptions_handlers.py +++ b/services/web/server/src/simcore_service_webserver/folders/_exceptions_handlers.py @@ -57,13 +57,14 @@ status.HTTP_409_CONFLICT, "Invalid folder value set: {reason}", ), + # Trashing ProjectRunningConflictError: HttpErrorInfo( status.HTTP_409_CONFLICT, "One or more studies in this folder are in use and cannot be trashed. Please stop all services first and try again", ), ProjectStoppingError: HttpErrorInfo( status.HTTP_503_SERVICE_UNAVAILABLE, - "Something went wrong while stopping services before trashing. Aborting trash.", + "Something went wrong while stopping running services in studies within this folder before trashing. Aborting trash.", ), } diff --git a/services/web/server/src/simcore_service_webserver/folders/_folders_handlers.py b/services/web/server/src/simcore_service_webserver/folders/_folders_handlers.py index d28760a4c2b..eb72dfeffee 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_folders_handlers.py +++ b/services/web/server/src/simcore_service_webserver/folders/_folders_handlers.py @@ -2,10 +2,10 @@ from aiohttp import web from models_library.api_schemas_webserver.folders_v2 import ( - CreateFolderBodyParams, + FolderCreateBodyParams, FolderGet, FolderGetPage, - PutFolderBodyParams, + FolderReplaceBodyParams, ) from models_library.rest_ordering import OrderBy from models_library.rest_pagination import Page @@ -46,7 +46,7 @@ @handle_plugin_requests_exceptions async def create_folder(request: web.Request): req_ctx = FoldersRequestContext.model_validate(request) - body_params = await parse_request_body_as(CreateFolderBodyParams, request) + body_params = await parse_request_body_as(FolderCreateBodyParams, request) folder = await _folders_api.create_folder( request.app, @@ -167,7 +167,7 @@ async def get_folder(request: web.Request): async def replace_folder(request: web.Request): req_ctx = FoldersRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(FoldersPathParams, request) - body_params = await parse_request_body_as(PutFolderBodyParams, request) + body_params = await parse_request_body_as(FolderReplaceBodyParams, request) folder = await _folders_api.update_folder( app=request.app, diff --git a/services/web/server/src/simcore_service_webserver/folders/_models.py b/services/web/server/src/simcore_service_webserver/folders/_models.py index 2f0e87e3016..f2e23f58ea6 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_models.py +++ b/services/web/server/src/simcore_service_webserver/folders/_models.py @@ -11,13 +11,14 @@ create_ordering_query_model_classes, ) from models_library.rest_pagination import PageQueryParameters +from models_library.trash import RemoveQueryParams from models_library.users import UserID from models_library.utils.common_validators import ( empty_str_to_none_pre_validator, null_or_none_str_to_none_validator, ) from models_library.workspaces import WorkspaceID -from pydantic import BaseModel, BeforeValidator, ConfigDict, Field +from pydantic import BeforeValidator, ConfigDict, Field from servicelib.request_keys import RQT_USERID_KEY from .._constants import RQ_PRODUCT_KEY @@ -85,7 +86,5 @@ class FolderSearchQueryParams( model_config = ConfigDict(extra="forbid") -class RemoveQueryParams(BaseModel): - force: bool = Field( - default=False, description="Force removal (even if resource is active)" - ) +class FolderTrashQueryParams(RemoveQueryParams): + ... diff --git a/services/web/server/src/simcore_service_webserver/folders/_trash_handlers.py b/services/web/server/src/simcore_service_webserver/folders/_trash_handlers.py index 55b53fcd4ee..396ee0490fa 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_trash_handlers.py +++ b/services/web/server/src/simcore_service_webserver/folders/_trash_handlers.py @@ -14,7 +14,7 @@ from ..security.decorators import permission_required from . import _trash_api from ._exceptions_handlers import handle_plugin_requests_exceptions -from ._models import FoldersPathParams, RemoveQueryParams +from ._models import FoldersPathParams, FolderTrashQueryParams _logger = logging.getLogger(__name__) @@ -31,8 +31,8 @@ async def trash_folder(request: web.Request): user_id = get_user_id(request) product_name = get_product_name(request) path_params = parse_request_path_parameters_as(FoldersPathParams, request) - query_params: RemoveQueryParams = parse_request_query_parameters_as( - RemoveQueryParams, request + query_params: FolderTrashQueryParams = parse_request_query_parameters_as( + FolderTrashQueryParams, request ) await _trash_api.trash_folder( diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_exceptions_handlers.py b/services/web/server/src/simcore_service_webserver/workspaces/_exceptions_handlers.py new file mode 100644 index 00000000000..f6470f461f7 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/workspaces/_exceptions_handlers.py @@ -0,0 +1,53 @@ +import logging + +from servicelib.aiohttp import status + +from ..exceptions_handlers import ( + ExceptionToHttpErrorMap, + HttpErrorInfo, + create_exception_handlers_decorator, +) +from ..projects.exceptions import ( + BaseProjectError, + ProjectRunningConflictError, + ProjectStoppingError, +) +from .errors import ( + WorkspaceAccessForbiddenError, + WorkspaceGroupNotFoundError, + WorkspaceNotFoundError, + WorkspacesValueError, +) + +_logger = logging.getLogger(__name__) + + +_TO_HTTP_ERROR_MAP: ExceptionToHttpErrorMap = { + WorkspaceGroupNotFoundError: HttpErrorInfo( + status.HTTP_404_NOT_FOUND, + "Workspace {workspace_id} group {group_id} not found.", + ), + WorkspaceAccessForbiddenError: HttpErrorInfo( + status.HTTP_403_FORBIDDEN, + "Does not have access to this workspace", + ), + WorkspaceNotFoundError: HttpErrorInfo( + status.HTTP_404_NOT_FOUND, + "Workspace not found. {reason}", + ), + # Trashing + ProjectRunningConflictError: HttpErrorInfo( + status.HTTP_409_CONFLICT, + "One or more studies in this workspace are in use and cannot be trashed. Please stop all services first and try again", + ), + ProjectStoppingError: HttpErrorInfo( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Something went wrong while stopping running services in studies within this workspace before trashing. Aborting trash.", + ), +} + + +handle_plugin_requests_exceptions = create_exception_handlers_decorator( + exceptions_catch=(BaseProjectError, WorkspacesValueError), + exc_to_status_map=_TO_HTTP_ERROR_MAP, +) diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_groups_handlers.py b/services/web/server/src/simcore_service_webserver/workspaces/_groups_handlers.py index 292c4ed1615..599305c3f81 100644 --- a/services/web/server/src/simcore_service_webserver/workspaces/_groups_handlers.py +++ b/services/web/server/src/simcore_service_webserver/workspaces/_groups_handlers.py @@ -1,49 +1,29 @@ -""" Handlers for project comments operations - -""" - -import functools import logging from aiohttp import web -from models_library.users import GroupID -from models_library.workspaces import WorkspaceID -from pydantic import BaseModel, ConfigDict from servicelib.aiohttp import status from servicelib.aiohttp.requests_validation import ( parse_request_body_as, parse_request_path_parameters_as, ) -from servicelib.aiohttp.typing_extension import Handler from .._meta import api_version_prefix as VTAG from ..login.decorators import login_required -from ..models import RequestContext 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 ._workspaces_handlers import WorkspacesPathParams -from .errors import WorkspaceAccessForbiddenError, WorkspaceGroupNotFoundError +from ._models import ( + WorkspacesGroupsBodyParams, + WorkspacesGroupsPathParams, + WorkspacesPathParams, + WorkspacesRequestContext, +) _logger = logging.getLogger(__name__) -def _handle_workspaces_groups_exceptions(handler: Handler): - @functools.wraps(handler) - async def wrapper(request: web.Request) -> web.StreamResponse: - try: - return await handler(request) - - except WorkspaceGroupNotFoundError as exc: - raise web.HTTPNotFound(reason=f"{exc}") from exc - - except WorkspaceAccessForbiddenError as exc: - raise web.HTTPForbidden(reason=f"{exc}") from exc - - return wrapper - - # # workspaces groups COLLECTION ------------------------- # @@ -51,30 +31,17 @@ async def wrapper(request: web.Request) -> web.StreamResponse: routes = web.RouteTableDef() -class _WorkspacesGroupsPathParams(BaseModel): - workspace_id: WorkspaceID - group_id: GroupID - model_config = ConfigDict(extra="forbid") - - -class _WorkspacesGroupsBodyParams(BaseModel): - read: bool - write: bool - delete: bool - model_config = ConfigDict(extra="forbid") - - @routes.post( f"/{VTAG}/workspaces/{{workspace_id}}/groups/{{group_id}}", name="create_workspace_group", ) @login_required @permission_required("workspaces.*") -@_handle_workspaces_groups_exceptions +@handle_plugin_requests_exceptions async def create_workspace_group(request: web.Request): - req_ctx = RequestContext.model_validate(request) - path_params = parse_request_path_parameters_as(_WorkspacesGroupsPathParams, request) - body_params = await parse_request_body_as(_WorkspacesGroupsBodyParams, request) + req_ctx = WorkspacesRequestContext.model_validate(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( request.app, @@ -93,12 +60,12 @@ async def create_workspace_group(request: web.Request): @routes.get(f"/{VTAG}/workspaces/{{workspace_id}}/groups", name="list_workspace_groups") @login_required @permission_required("workspaces.*") -@_handle_workspaces_groups_exceptions +@handle_plugin_requests_exceptions async def list_workspace_groups(request: web.Request): - req_ctx = RequestContext.model_validate(request) + req_ctx = WorkspacesRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(WorkspacesPathParams, request) - workspaces: list[ + workspaces_groups: list[ WorkspaceGroupGet ] = await _groups_api.list_workspace_groups_by_user_and_workspace( request.app, @@ -107,7 +74,7 @@ async def list_workspace_groups(request: web.Request): product_name=req_ctx.product_name, ) - return envelope_json_response(workspaces, web.HTTPOk) + return envelope_json_response(workspaces_groups) @routes.put( @@ -116,13 +83,13 @@ async def list_workspace_groups(request: web.Request): ) @login_required @permission_required("workspaces.*") -@_handle_workspaces_groups_exceptions +@handle_plugin_requests_exceptions async def replace_workspace_group(request: web.Request): - req_ctx = RequestContext.model_validate(request) - path_params = parse_request_path_parameters_as(_WorkspacesGroupsPathParams, request) - body_params = await parse_request_body_as(_WorkspacesGroupsBodyParams, request) + req_ctx = WorkspacesRequestContext.model_validate(request) + path_params = parse_request_path_parameters_as(WorkspacesGroupsPathParams, request) + body_params = await parse_request_body_as(WorkspacesGroupsBodyParams, request) - return await _groups_api.update_workspace_group( + workspace_group = await _groups_api.update_workspace_group( app=request.app, user_id=req_ctx.user_id, workspace_id=path_params.workspace_id, @@ -132,6 +99,7 @@ async def replace_workspace_group(request: web.Request): delete=body_params.delete, product_name=req_ctx.product_name, ) + return envelope_json_response(workspace_group) @routes.delete( @@ -140,10 +108,10 @@ async def replace_workspace_group(request: web.Request): ) @login_required @permission_required("workspaces.*") -@_handle_workspaces_groups_exceptions +@handle_plugin_requests_exceptions async def delete_workspace_group(request: web.Request): - req_ctx = RequestContext.model_validate(request) - path_params = parse_request_path_parameters_as(_WorkspacesGroupsPathParams, request) + req_ctx = WorkspacesRequestContext.model_validate(request) + path_params = parse_request_path_parameters_as(WorkspacesGroupsPathParams, request) await _groups_api.delete_workspace_group( app=request.app, diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_models.py b/services/web/server/src/simcore_service_webserver/workspaces/_models.py new file mode 100644 index 00000000000..fec1d7d8fcb --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/workspaces/_models.py @@ -0,0 +1,73 @@ +import logging + +from models_library.basic_types import IDStr +from models_library.rest_base import RequestParameters, StrictRequestParameters +from models_library.rest_filters import Filters, FiltersQueryParameters +from models_library.rest_ordering import ( + OrderBy, + OrderDirection, + create_ordering_query_model_classes, +) +from models_library.rest_pagination import PageQueryParameters +from models_library.trash import RemoveQueryParams +from models_library.users import GroupID, UserID +from models_library.workspaces import WorkspaceID +from pydantic import BaseModel, ConfigDict, Field +from servicelib.request_keys import RQT_USERID_KEY + +from .._constants import RQ_PRODUCT_KEY + +_logger = logging.getLogger(__name__) + + +class WorkspacesRequestContext(RequestParameters): + user_id: UserID = Field(..., alias=RQT_USERID_KEY) # type: ignore[literal-required] + product_name: str = Field(..., alias=RQ_PRODUCT_KEY) # type: ignore[literal-required] + + +class WorkspacesPathParams(StrictRequestParameters): + workspace_id: WorkspaceID + + +_WorkspacesListOrderQueryParams: type[ + RequestParameters +] = create_ordering_query_model_classes( + ordering_fields={ + "modified_at", + "name", + }, + default=OrderBy(field=IDStr("modified_at"), direction=OrderDirection.DESC), + ordering_fields_api_to_column_map={"modified_at": "modified"}, +) + + +class WorkspacesFilters(Filters): + trashed: bool | None = Field( + default=False, + description="Set to true to list trashed, false to list non-trashed (default), None to list all", + ) + + +class WorkspacesListQueryParams( + PageQueryParameters, + FiltersQueryParameters[WorkspacesFilters], + _WorkspacesListOrderQueryParams, # type: ignore[misc, valid-type] +): + ... + + +class WorkspacesGroupsPathParams(BaseModel): + workspace_id: WorkspaceID + group_id: GroupID + model_config = ConfigDict(extra="forbid") + + +class WorkspacesGroupsBodyParams(BaseModel): + read: bool + write: bool + delete: bool + model_config = ConfigDict(extra="forbid") + + +class WorkspaceTrashQueryParams(RemoveQueryParams): + ... diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_trash_api.py b/services/web/server/src/simcore_service_webserver/workspaces/_trash_api.py new file mode 100644 index 00000000000..18c3ae93b88 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/workspaces/_trash_api.py @@ -0,0 +1,122 @@ +import logging + +import arrow +from aiohttp import web +from models_library.folders import FolderID +from models_library.products import ProductName +from models_library.projects import ProjectID +from models_library.users import UserID +from models_library.workspaces import WorkspaceID, WorkspaceUpdateDB +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 + +_logger = logging.getLogger(__name__) + + +async def _check_exists_and_access( + app: web.Application, + *, + product_name: ProductName, + user_id: UserID, + workspace_id: WorkspaceID, +): + await check_user_workspace_access( + app=app, + user_id=user_id, + workspace_id=workspace_id, + product_name=product_name, + permission="delete", + ) + + +async def trash_workspace( + app: web.Application, + *, + product_name: ProductName, + user_id: UserID, + workspace_id: WorkspaceID, + force_stop_first: bool, +): + await _check_exists_and_access( + app, product_name=product_name, user_id=user_id, workspace_id=workspace_id + ) + + trashed_at = arrow.utcnow().datetime + + async with transaction_context(get_asyncpg_engine(app)) as connection: + # EXPLICIT trash + await update_workspace( + app, + connection, + product_name=product_name, + workspace_id=workspace_id, + updates=WorkspaceUpdateDB(trashed=trashed_at, trashed_by=user_id), + ) + + # IMPLICIT trash + child_folders: list[FolderID] = [] # TODO: find children. Check with MD + + for folder_id in child_folders: + await trash_folder( + app, + product_name=product_name, + user_id=user_id, + folder_id=folder_id, + force_stop_first=force_stop_first, + ) + + child_projects: list[ProjectID] = [] # TODO: find children. Check with MD + + for project_id in child_projects: + await trash_project( + app, + product_name=product_name, + user_id=user_id, + project_id=project_id, + force_stop_first=force_stop_first, + explicit=False, + ) + + +async def untrash_workspace( + app: web.Application, + *, + product_name: ProductName, + user_id: UserID, + workspace_id: WorkspaceID, +): + await _check_exists_and_access( + app, product_name=product_name, user_id=user_id, workspace_id=workspace_id + ) + + async with transaction_context(get_asyncpg_engine(app)) as connection: + # EXPLICIT UNtrash + await update_workspace( + app, + connection, + product_name=product_name, + workspace_id=workspace_id, + updates=WorkspaceUpdateDB(trashed=None, trashed_by=None), + ) + + child_folders: list[FolderID] = [] # TODO: find children. Check with MD + + for folder_id in child_folders: + await untrash_folder( + app, + product_name=product_name, + user_id=user_id, + folder_id=folder_id, + ) + + child_projects: list[ProjectID] = [] # TODO: find children. Check with MD + + for project_id in child_projects: + await untrash_project( + app, product_name=product_name, user_id=user_id, project_id=project_id + ) diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_trash_handlers.py b/services/web/server/src/simcore_service_webserver/workspaces/_trash_handlers.py new file mode 100644 index 00000000000..b2d3a4b41a8 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/workspaces/_trash_handlers.py @@ -0,0 +1,66 @@ +import logging + +from aiohttp import web +from servicelib.aiohttp import status +from servicelib.aiohttp.requests_validation import ( + parse_request_path_parameters_as, + parse_request_query_parameters_as, +) + +from .._meta import API_VTAG as VTAG +from ..application_settings_utils import requires_dev_feature_enabled +from ..login.decorators import get_user_id, login_required +from ..products.api import get_product_name +from ..security.decorators import permission_required +from . import _trash_api +from ._exceptions_handlers import handle_plugin_requests_exceptions +from ._models import WorkspacesPathParams, WorkspaceTrashQueryParams + +_logger = logging.getLogger(__name__) + + +routes = web.RouteTableDef() + + +@routes.post(f"/{VTAG}/workspaces/{{workspace_id}}:trash", name="trash_workspace") +@requires_dev_feature_enabled +@login_required +@permission_required("workspaces.*") +@handle_plugin_requests_exceptions +async def trash_workspace(request: web.Request): + user_id = get_user_id(request) + product_name = get_product_name(request) + path_params = parse_request_path_parameters_as(WorkspacesPathParams, request) + query_params: WorkspaceTrashQueryParams = parse_request_query_parameters_as( + WorkspaceTrashQueryParams, request + ) + + await _trash_api.trash_workspace( + request.app, + product_name=product_name, + user_id=user_id, + workspace_id=path_params.workspace_id, + force_stop_first=query_params.force, + ) + + return web.json_response(status=status.HTTP_204_NO_CONTENT) + + +@routes.post(f"/{VTAG}/workspaces/{{workspace_id}}:untrash", name="untrash_workspace") +@requires_dev_feature_enabled +@login_required +@permission_required("workspaces.*") +@handle_plugin_requests_exceptions +async def untrash_workspace(request: web.Request): + user_id = get_user_id(request) + product_name = get_product_name(request) + path_params = parse_request_path_parameters_as(WorkspacesPathParams, request) + + await _trash_api.untrash_workspace( + request.app, + product_name=product_name, + user_id=user_id, + workspace_id=path_params.workspace_id, + ) + + return web.json_response(status=status.HTTP_204_NO_CONTENT) diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_api.py b/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_api.py index a645037f5a4..3fd6633bb06 100644 --- a/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_api.py +++ b/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_api.py @@ -10,7 +10,11 @@ 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, WorkspaceID +from models_library.workspaces import ( + UserWorkspaceAccessRightsDB, + WorkspaceID, + WorkspaceUpdateDB, +) from pydantic import NonNegativeInt from ..projects._db_utils import PermissionStr @@ -21,8 +25,24 @@ _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, + *, user_id: UserID, name: str, description: str | None, @@ -45,20 +65,12 @@ async def create_workspace( workspace_id=created_workspace_db.workspace_id, product_name=product_name, ) - 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, - my_access_rights=workspace_db.my_access_rights, - access_rights=workspace_db.access_rights, - ) + return _to_api_model(workspace_db) async def get_workspace( app: web.Application, + *, user_id: UserID, workspace_id: WorkspaceID, product_name: ProductName, @@ -70,22 +82,15 @@ async def get_workspace( product_name=product_name, permission="read", ) - 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, - my_access_rights=workspace_db.my_access_rights, - access_rights=workspace_db.access_rights, - ) + return _to_api_model(workspace_db) async def list_workspaces( app: web.Application, + *, user_id: UserID, product_name: ProductName, + filter_trashed: bool | None, offset: NonNegativeInt, limit: int, order_by: OrderBy, @@ -94,38 +99,27 @@ async def list_workspaces( app, user_id=user_id, product_name=product_name, + filter_trashed=filter_trashed, offset=offset, limit=limit, order_by=order_by, ) return WorkspaceGetPage( - items=[ - WorkspaceGet( - workspace_id=workspace.workspace_id, - name=workspace.name, - description=workspace.description, - thumbnail=workspace.thumbnail, - created_at=workspace.created, - modified_at=workspace.modified, - my_access_rights=workspace.my_access_rights, - access_rights=workspace.access_rights, - ) - for workspace in workspaces - ], + items=[_to_api_model(workspace_db) for workspace_db in workspaces], total=total_count, ) async def update_workspace( app: web.Application, + *, + product_name: ProductName, user_id: UserID, workspace_id: WorkspaceID, - name: str, - description: str | None, - thumbnail: str | None, - product_name: ProductName, + **updates, ) -> WorkspaceGet: + await check_user_workspace_access( app=app, user_id=user_id, @@ -136,10 +130,8 @@ async def update_workspace( await db.update_workspace( app, workspace_id=workspace_id, - name=name, - description=description, - thumbnail=thumbnail, product_name=product_name, + updates=WorkspaceUpdateDB(**updates), ) workspace_db = await db.get_workspace_for_user( app, @@ -147,20 +139,12 @@ async def update_workspace( workspace_id=workspace_id, product_name=product_name, ) - 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, - my_access_rights=workspace_db.my_access_rights, - access_rights=workspace_db.access_rights, - ) + return _to_api_model(workspace_db) async def delete_workspace( app: web.Application, + *, user_id: UserID, workspace_id: WorkspaceID, product_name: ProductName, @@ -191,5 +175,10 @@ async def check_user_workspace_access( 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 + raise WorkspaceAccessForbiddenError( + user_id=user_id, + workspace_id=workspace_id, + product_name=product_name, + permission_checked=permission, + ) return workspace_db diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_db.py b/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_db.py index 5f80868a27f..7c55e0a9428 100644 --- a/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_db.py +++ b/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_db.py @@ -15,6 +15,7 @@ UserWorkspaceAccessRightsDB, WorkspaceDB, WorkspaceID, + WorkspaceUpdateDB, ) from pydantic import NonNegativeInt from simcore_postgres_database.models.workspaces import workspaces @@ -46,6 +47,13 @@ workspaces.c.thumbnail, workspaces.c.created, workspaces.c.modified, + workspaces.c.trashed, + workspaces.c.trashed_by, +) + +assert set(WorkspaceDB.model_fields) == {c.name for c in _SELECTION_ARGS} # nosec +assert set(WorkspaceUpdateDB.model_fields).issubset( # nosec + c.name for c in workspaces.columns ) @@ -105,6 +113,7 @@ async def list_workspaces_for_user( *, user_id: UserID, product_name: ProductName, + filter_trashed: bool | None, offset: NonNegativeInt, limit: NonNegativeInt, order_by: OrderBy, @@ -125,6 +134,13 @@ async def list_workspaces_for_user( .where(workspaces.c.product_name == product_name) ) + if filter_trashed is not None: + base_query = base_query.where( + workspaces.c.trashed.is_not(None) + if filter_trashed + else workspaces.c.trashed.is_(None) + ) + # Select total count from base_query subquery = base_query.subquery() count_query = select(func.count()).select_from(subquery) @@ -188,21 +204,20 @@ async def update_workspace( app: web.Application, connection: AsyncConnection | None = None, *, - workspace_id: WorkspaceID, - name: str, - description: str | None, - thumbnail: str | None, product_name: ProductName, + workspace_id: WorkspaceID, + updates: WorkspaceUpdateDB, ) -> WorkspaceDB: + # NOTE: at least 'touch' if updated_values is empty + _updates = { + **updates.dict(exclude_unset=True), + "modified": func.now(), + } + async with transaction_context(get_asyncpg_engine(app), connection) as conn: result = await conn.stream( workspaces.update() - .values( - name=name, - description=description, - thumbnail=thumbnail, - modified=func.now(), - ) + .values(**_updates) .where( (workspaces.c.workspace_id == workspace_id) & (workspaces.c.product_name == product_name) diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_handlers.py b/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_handlers.py index a501253a382..fb2f7c9f1f6 100644 --- a/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_handlers.py +++ b/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_handlers.py @@ -1,104 +1,50 @@ -import functools import logging from aiohttp import web from models_library.api_schemas_webserver.workspaces import ( - CreateWorkspaceBodyParams, - PutWorkspaceBodyParams, + WorkspaceCreateBodyParams, WorkspaceGet, WorkspaceGetPage, + WorkspaceReplaceBodyParams, ) -from models_library.basic_types import IDStr -from models_library.rest_base import RequestParameters, StrictRequestParameters -from models_library.rest_ordering import ( - OrderBy, - OrderDirection, - create_ordering_query_model_classes, -) -from models_library.rest_pagination import Page, PageQueryParameters +from models_library.rest_ordering import OrderBy +from models_library.rest_pagination import Page from models_library.rest_pagination_utils import paginate_data -from models_library.users import UserID -from models_library.workspaces import WorkspaceID -from pydantic import Field from servicelib.aiohttp import status from servicelib.aiohttp.requests_validation import ( parse_request_body_as, parse_request_path_parameters_as, parse_request_query_parameters_as, ) -from servicelib.aiohttp.typing_extension import Handler from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON -from servicelib.request_keys import RQT_USERID_KEY from servicelib.rest_constants import RESPONSE_MODEL_POLICY -from .._constants import RQ_PRODUCT_KEY from .._meta import API_VTAG as VTAG from ..login.decorators import login_required from ..security.decorators import permission_required from ..utils_aiohttp import envelope_json_response from . import _workspaces_api -from .errors import WorkspaceAccessForbiddenError, WorkspaceNotFoundError +from ._exceptions_handlers import handle_plugin_requests_exceptions +from ._models import ( + WorkspacesFilters, + WorkspacesListQueryParams, + WorkspacesPathParams, + WorkspacesRequestContext, +) _logger = logging.getLogger(__name__) -def handle_workspaces_exceptions(handler: Handler): - @functools.wraps(handler) - async def wrapper(request: web.Request) -> web.StreamResponse: - try: - return await handler(request) - - except WorkspaceNotFoundError as exc: - raise web.HTTPNotFound(reason=f"{exc}") from exc - - except WorkspaceAccessForbiddenError as exc: - raise web.HTTPForbidden(reason=f"{exc}") from exc - - return wrapper - - -# -# workspaces COLLECTION ------------------------- -# - routes = web.RouteTableDef() -class WorkspacesRequestContext(RequestParameters): - user_id: UserID = Field(..., alias=RQT_USERID_KEY) # type: ignore[literal-required] - product_name: str = Field(..., alias=RQ_PRODUCT_KEY) # type: ignore[literal-required] - - -class WorkspacesPathParams(StrictRequestParameters): - workspace_id: WorkspaceID - - -WorkspacesListOrderQueryParams: type[ - RequestParameters -] = create_ordering_query_model_classes( - ordering_fields={ - "modified_at", - "name", - }, - default=OrderBy(field=IDStr("modified_at"), direction=OrderDirection.DESC), - ordering_fields_api_to_column_map={"modified_at": "modified"}, -) - - -class WorkspacesListQueryParams( - PageQueryParameters, - WorkspacesListOrderQueryParams, # type: ignore[misc, valid-type] -): - ... - - @routes.post(f"/{VTAG}/workspaces", name="create_workspace") @login_required @permission_required("workspaces.*") -@handle_workspaces_exceptions +@handle_plugin_requests_exceptions async def create_workspace(request: web.Request): req_ctx = WorkspacesRequestContext.model_validate(request) - body_params = await parse_request_body_as(CreateWorkspaceBodyParams, request) + body_params = await parse_request_body_as(WorkspaceCreateBodyParams, request) workspace: WorkspaceGet = await _workspaces_api.create_workspace( request.app, @@ -115,17 +61,22 @@ async def create_workspace(request: web.Request): @routes.get(f"/{VTAG}/workspaces", name="list_workspaces") @login_required @permission_required("workspaces.*") -@handle_workspaces_exceptions +@handle_plugin_requests_exceptions async def list_workspaces(request: web.Request): req_ctx = WorkspacesRequestContext.model_validate(request) query_params: WorkspacesListQueryParams = parse_request_query_parameters_as( WorkspacesListQueryParams, request ) + if not query_params.filters: + query_params.filters = WorkspacesFilters() + + assert query_params.filters workspaces: WorkspaceGetPage = await _workspaces_api.list_workspaces( app=request.app, user_id=req_ctx.user_id, product_name=req_ctx.product_name, + filter_trashed=query_params.filters.trashed, offset=query_params.offset, limit=query_params.limit, order_by=OrderBy.model_validate(query_params.order_by), @@ -149,7 +100,7 @@ async def list_workspaces(request: web.Request): @routes.get(f"/{VTAG}/workspaces/{{workspace_id}}", name="get_workspace") @login_required @permission_required("workspaces.*") -@handle_workspaces_exceptions +@handle_plugin_requests_exceptions async def get_workspace(request: web.Request): req_ctx = WorkspacesRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(WorkspacesPathParams, request) @@ -170,20 +121,18 @@ async def get_workspace(request: web.Request): ) @login_required @permission_required("workspaces.*") -@handle_workspaces_exceptions +@handle_plugin_requests_exceptions async def replace_workspace(request: web.Request): req_ctx = WorkspacesRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(WorkspacesPathParams, request) - body_params = await parse_request_body_as(PutWorkspaceBodyParams, request) + body_params = await parse_request_body_as(WorkspaceReplaceBodyParams, request) workspace: WorkspaceGet = await _workspaces_api.update_workspace( app=request.app, user_id=req_ctx.user_id, workspace_id=path_params.workspace_id, - name=body_params.name, - description=body_params.description, product_name=req_ctx.product_name, - thumbnail=body_params.thumbnail, + **body_params.model_dump(), ) return envelope_json_response(workspace) @@ -194,7 +143,7 @@ async def replace_workspace(request: web.Request): ) @login_required @permission_required("workspaces.*") -@handle_workspaces_exceptions +@handle_plugin_requests_exceptions async def delete_workspace(request: web.Request): req_ctx = WorkspacesRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(WorkspacesPathParams, request) 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 4773b056b49..d67a9167c92 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, _workspaces_handlers +from . import _groups_handlers, _trash_handlers, _workspaces_handlers _logger = logging.getLogger(__name__) @@ -25,3 +25,4 @@ def setup_workspaces(app: web.Application): # routes app.router.add_routes(_workspaces_handlers.routes) app.router.add_routes(_groups_handlers.routes) + app.router.add_routes(_trash_handlers.routes) 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 16a5f9dc147..2489ea6107c 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 @@ -7,7 +7,7 @@ import asyncio -from collections.abc import Callable +from collections.abc import AsyncIterable, Callable from uuid import UUID import arrow @@ -16,6 +16,7 @@ from aioresponses import aioresponses from models_library.api_schemas_webserver.folders_v2 import FolderGet from models_library.api_schemas_webserver.projects import ProjectGet, ProjectListItem +from models_library.api_schemas_webserver.workspaces import WorkspaceGet from models_library.rest_pagination import Page from pytest_mock import MockerFixture from pytest_simcore.helpers.assert_checks import assert_status @@ -395,3 +396,102 @@ async def test_trash_folder_with_content( data, _ = await assert_status(resp, status.HTTP_200_OK) got = ProjectGet.model_validate(data) assert got.trashed_at is None + + +@pytest.fixture +async def workspace( + client: TestClient, logged_user: UserInfoDict +) -> AsyncIterable[WorkspaceGet]: + + # CREATE a workspace + resp = await client.post("/v0/workspaces", json={"name": "My first workspace"}) + data, _ = await assert_status(resp, status.HTTP_201_CREATED) + workspace = WorkspaceGet.parse_obj(data) + + yield workspace + + # DELETE a workspace + resp = await client.delete(f"/v0/workspaces/{workspace.workspace_id}") + data, _ = await assert_status(resp, status.HTTP_204_NO_CONTENT) + + +@pytest.mark.acceptance_test( + "https://github.com/ITISFoundation/osparc-simcore/pull/6690" +) +async def test_trash_empty_workspace( + client: TestClient, logged_user: UserInfoDict, workspace: WorkspaceGet +): + assert client.app + + assert workspace.trashed_at is None + assert workspace.trashed_by is None + + # LIST NOT trashed (default) + resp = await client.get("/v0/workspaces") + await assert_status(resp, status.HTTP_200_OK) + + page = Page[WorkspaceGet].parse_obj(await resp.json()) + assert page.meta.total == 1 + assert page.data[0] == workspace + + # LIST trashed + resp = await client.get("/v0/workspaces", params={"filters": '{"trashed": true}'}) + await assert_status(resp, status.HTTP_200_OK) + + page = Page[WorkspaceGet].parse_obj(await resp.json()) + assert page.meta.total == 0 + + # ------------- + + _exclude_attrs = {"trashed_by", "trashed_at", "modified_at"} + + # TRASH + before_trash = arrow.utcnow().datetime + resp = await client.post(f"/v0/workspaces/{workspace.workspace_id}:trash") + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # LIST NOT trashed (default) + resp = await client.get("/v0/workspaces") + await assert_status(resp, status.HTTP_200_OK) + + page = Page[WorkspaceGet].parse_obj(await resp.json()) + assert page.meta.total == 0 + + # LIST trashed + resp = await client.get("/v0/workspaces", params={"filters": '{"trashed": true}'}) + await assert_status(resp, status.HTTP_200_OK) + + page = Page[WorkspaceGet].parse_obj(await resp.json()) + assert page.meta.total == 1 + assert page.data[0].dict(exclude=_exclude_attrs) == workspace.dict( + exclude=_exclude_attrs + ) + assert page.data[0].trashed_at is not None + assert before_trash < page.data[0].trashed_at + assert page.data[0].trashed_by == logged_user["id"] + + # -------- + + # UN_TRASH + resp = await client.post(f"/v0/workspaces/{workspace.workspace_id}:untrash") + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # LIST NOT trashed (default) + resp = await client.get("/v0/workspaces") + await assert_status(resp, status.HTTP_200_OK) + + page = Page[WorkspaceGet].parse_obj(await resp.json()) + assert page.meta.total == 1 + assert page.data[0].dict(exclude=_exclude_attrs) == workspace.dict( + exclude=_exclude_attrs + ) + + assert page.data[0].trashed_at is None + assert page.data[0].trashed_by is None + + # LIST trashed + resp = await client.get("/v0/workspaces", params={"filters": '{"trashed": true}'}) + await assert_status(resp, status.HTTP_200_OK) + + page = Page[WorkspaceGet].parse_obj(await resp.json()) + assert page.meta.total == 0 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 ed2054595dd..5e7d81afd2b 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 @@ -50,7 +50,7 @@ async def test_workspaces_user_role_permissions( assert client.app url = client.app.router["list_workspaces"].url_for() - resp = await client.get(url.path) + resp = await client.get(f"{url}") await assert_status(resp, expected.ok) @@ -65,78 +65,75 @@ async def test_workspaces_workflow( # list user workspaces url = client.app.router["list_workspaces"].url_for() - resp = await client.get(url.path) + resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) assert data == [] - # create a new workspace + # CREATE a new workspace url = client.app.router["create_workspace"].url_for() resp = await client.post( - url.path, + f"{url}", json={ "name": "My first workspace", "description": "Custom description", "thumbnail": None, }, ) - added_workspace, _ = await assert_status(resp, status.HTTP_201_CREATED) - assert WorkspaceGet.model_validate(added_workspace) + data, _ = await assert_status(resp, status.HTTP_201_CREATED) + added_workspace = WorkspaceGet.model_validate(data) - # list user workspaces + # LIST user workspaces url = client.app.router["list_workspaces"].url_for() - resp = await client.get(url.path) + resp = await client.get(f"{url}") data, _, meta, links = await assert_status( resp, status.HTTP_200_OK, include_meta=True, include_links=True ) assert len(data) == 1 - assert data[0]["workspaceId"] == added_workspace["workspaceId"] - assert data[0]["name"] == "My first workspace" - assert data[0]["description"] == "Custom description" + assert WorkspaceGet.parse_obj(data[0]) == added_workspace assert meta["count"] == 1 assert links - # get a user workspace + # GET a user workspace url = client.app.router["get_workspace"].url_for( - workspace_id=f"{added_workspace['workspaceId']}" + workspace_id=f"{added_workspace.workspace_id}" ) resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) - assert data["workspaceId"] == added_workspace["workspaceId"] + assert data["workspaceId"] == added_workspace.workspace_id assert data["name"] == "My first workspace" assert data["description"] == "Custom description" - # update a workspace + # REPLACE a workspace url = client.app.router["replace_workspace"].url_for( - workspace_id=f"{added_workspace['workspaceId']}" + workspace_id=f"{added_workspace.workspace_id}" ) resp = await client.put( - url.path, + f"{url}", json={ "name": "My Second workspace", "description": "", }, ) data, _ = await assert_status(resp, status.HTTP_200_OK) - assert WorkspaceGet.model_validate(data) + replaced_workspace = WorkspaceGet.model_validate(data) - # list user workspaces + # LIST user workspaces url = client.app.router["list_workspaces"].url_for() - resp = await client.get(url.path) + resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) assert len(data) == 1 - assert data[0]["name"] == "My Second workspace" - assert data[0]["description"] == "" + assert WorkspaceGet.parse_obj(data[0]) == replaced_workspace - # delete a workspace + # DELETE a workspace url = client.app.router["delete_workspace"].url_for( - workspace_id=f"{added_workspace['workspaceId']}" + workspace_id=f"{added_workspace.workspace_id}" ) - resp = await client.delete(url.path) + resp = await client.delete(f"{url}") data, _ = await assert_status(resp, status.HTTP_204_NO_CONTENT) - # list user workspaces + # LIST user workspaces url = client.app.router["list_workspaces"].url_for() - resp = await client.get(url.path) + resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) assert data == [] @@ -151,3 +148,4 @@ async def test_project_workspace_movement_full_workflow( assert client.app # NOTE: MD: not yet implemented + # SEE https://github.com/ITISFoundation/osparc-simcore/issues/6778 diff --git a/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__folders_and_projects_crud.py b/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__folders_and_projects_crud.py index c2bbab0616a..477413d274d 100644 --- a/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__folders_and_projects_crud.py +++ b/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__folders_and_projects_crud.py @@ -12,6 +12,7 @@ import pytest from aiohttp.test_utils import TestClient +from models_library.api_schemas_webserver.workspaces import WorkspaceGet from pytest_mock import MockerFixture from pytest_simcore.helpers.assert_checks import assert_status from pytest_simcore.helpers.webserver_login import LoggedUser, UserInfoDict @@ -54,21 +55,22 @@ async def test_workspaces_full_workflow_with_folders_and_projects( ): assert client.app - # create a new workspace + # Create a new workspace url = client.app.router["create_workspace"].url_for() resp = await client.post( - url.path, + f"{url}", json={ "name": "My first workspace", "description": "Custom description", "thumbnail": None, }, ) - added_workspace, _ = await assert_status(resp, status.HTTP_201_CREATED) + data, _ = await assert_status(resp, status.HTTP_201_CREATED) + added_workspace = WorkspaceGet.model_validate(data) - # Create project in workspace + # Create project **in workspace** project_data = deepcopy(fake_project) - project_data["workspace_id"] = f"{added_workspace['workspaceId']}" + project_data["workspace_id"] = f"{added_workspace.workspace_id}" project = await create_project( client.app, project_data, @@ -77,30 +79,33 @@ async def test_workspaces_full_workflow_with_folders_and_projects( ) # List project in workspace - base_url = client.app.router["list_projects"].url_for() - url = base_url.with_query({"workspace_id": f"{added_workspace['workspaceId']}"}) + url = ( + client.app.router["list_projects"] + .url_for() + .with_query({"workspace_id": f"{added_workspace.workspace_id}"}) + ) resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) assert len(data) == 1 assert data[0]["uuid"] == project["uuid"] - assert data[0]["workspaceId"] == added_workspace["workspaceId"] + assert data[0]["workspaceId"] == added_workspace.workspace_id assert data[0]["folderId"] is None # Get project in workspace - base_url = client.app.router["get_project"].url_for(project_id=project["uuid"]) - resp = await client.get(f"{base_url}") + url = client.app.router["get_project"].url_for(project_id=project["uuid"]) + resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) assert data["uuid"] == project["uuid"] - assert data["workspaceId"] == added_workspace["workspaceId"] + assert data["workspaceId"] == added_workspace.workspace_id assert data["folderId"] is None # Create folder in workspace url = client.app.router["create_folder"].url_for() resp = await client.post( - url.path, + f"{url}", json={ "name": "Original user folder", - "workspaceId": f"{added_workspace['workspaceId']}", + "workspaceId": f"{added_workspace.workspace_id}", }, ) first_folder, _ = await assert_status(resp, status.HTTP_201_CREATED) @@ -108,7 +113,7 @@ async def test_workspaces_full_workflow_with_folders_and_projects( # List folders in workspace base_url = client.app.router["list_folders"].url_for() url = base_url.with_query( - {"workspace_id": f"{added_workspace['workspaceId']}", "folder_id": "null"} + {"workspace_id": f"{added_workspace.workspace_id}", "folder_id": "null"} ) resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) @@ -120,14 +125,14 @@ async def test_workspaces_full_workflow_with_folders_and_projects( folder_id=f"{first_folder['folderId']}", project_id=f"{project['uuid']}", ) - resp = await client.put(url.path) + resp = await client.put(f"{url}") await assert_status(resp, status.HTTP_204_NO_CONTENT) # List projects in specific folder in workspace base_url = client.app.router["list_projects"].url_for() url = base_url.with_query( { - "workspace_id": f"{added_workspace['workspaceId']}", + "workspace_id": f"{added_workspace.workspace_id}", "folder_id": f"{first_folder['folderId']}", } ) @@ -140,8 +145,11 @@ async def test_workspaces_full_workflow_with_folders_and_projects( # Create new user async with LoggedUser(client) as new_logged_user: # Try to list folder that user doesn't have access to - base_url = client.app.router["list_projects"].url_for() - url = base_url.with_query({"workspace_id": f"{added_workspace['workspaceId']}"}) + url = ( + client.app.router["list_projects"] + .url_for() + .with_query({"workspace_id": f"{added_workspace.workspace_id}"}) + ) resp = await client.get(f"{url}") _, errors = await assert_status( resp, @@ -152,7 +160,7 @@ async def test_workspaces_full_workflow_with_folders_and_projects( # Now we will share the workspace with the new user await update_or_insert_workspace_group( client.app, - workspace_id=added_workspace["workspaceId"], + workspace_id=added_workspace.workspace_id, group_id=new_logged_user["primary_gid"], read=True, write=True, @@ -160,33 +168,42 @@ async def test_workspaces_full_workflow_with_folders_and_projects( ) # New user list root folders inside of workspace - base_url = client.app.router["list_folders"].url_for() - url = base_url.with_query( - {"workspace_id": f"{added_workspace['workspaceId']}", "folder_id": "null"} + url = ( + client.app.router["list_folders"] + .url_for() + .with_query( + {"workspace_id": f"{added_workspace.workspace_id}", "folder_id": "null"} + ) ) resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) assert len(data) == 1 # New user list root projects inside of workspace - base_url = client.app.router["list_projects"].url_for() - url = base_url.with_query( - { - "workspace_id": f"{added_workspace['workspaceId']}", - "folder_id": "none", - } + url = ( + client.app.router["list_projects"] + .url_for() + .with_query( + { + "workspace_id": f"{added_workspace.workspace_id}", + "folder_id": "none", + } + ) ) resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) assert len(data) == 0 # New user list projects in specific folder inside of workspace - base_url = client.app.router["list_projects"].url_for() - url = base_url.with_query( - { - "workspace_id": f"{added_workspace['workspaceId']}", - "folder_id": f"{first_folder['folderId']}", - } + url = ( + client.app.router["list_projects"] + .url_for() + .with_query( + { + "workspace_id": f"{added_workspace.workspace_id}", + "folder_id": f"{first_folder['folderId']}", + } + ) ) resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) @@ -196,10 +213,10 @@ async def test_workspaces_full_workflow_with_folders_and_projects( # New user with write permission creates a folder url = client.app.router["create_folder"].url_for() resp = await client.post( - url.path, + f"{url}", json={ "name": "New user folder", - "workspaceId": f"{added_workspace['workspaceId']}", + "workspaceId": f"{added_workspace.workspace_id}", }, ) await assert_status(resp, status.HTTP_201_CREATED) @@ -207,7 +224,7 @@ async def test_workspaces_full_workflow_with_folders_and_projects( # Now we will remove write permissions await update_or_insert_workspace_group( client.app, - workspace_id=added_workspace["workspaceId"], + workspace_id=added_workspace.workspace_id, group_id=new_logged_user["primary_gid"], read=True, write=False, @@ -217,10 +234,10 @@ async def test_workspaces_full_workflow_with_folders_and_projects( # Now error is raised on the creation of folder as user doesn't have write access url = client.app.router["create_folder"].url_for() resp = await client.post( - url.path, + f"{url}", json={ "name": "New user second folder", - "workspaceId": f"{added_workspace['workspaceId']}", + "workspaceId": f"{added_workspace.workspace_id}", }, ) await assert_status(resp, status.HTTP_403_FORBIDDEN) @@ -228,7 +245,7 @@ async def test_workspaces_full_workflow_with_folders_and_projects( # But user has still read permissions base_url = client.app.router["list_folders"].url_for() url = base_url.with_query( - {"workspace_id": f"{added_workspace['workspaceId']}", "folder_id": "null"} + {"workspace_id": f"{added_workspace.workspace_id}", "folder_id": "null"} ) resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) @@ -271,18 +288,19 @@ async def test_workspaces_delete_folders( # create a new workspace url = client.app.router["create_workspace"].url_for() resp = await client.post( - url.path, + f"{url}", json={ "name": "My first workspace", "description": "Custom description", "thumbnail": None, }, ) - added_workspace, _ = await assert_status(resp, status.HTTP_201_CREATED) + data, _ = await assert_status(resp, status.HTTP_201_CREATED) + added_workspace = WorkspaceGet.parse_obj(data) # Create project in workspace project_data = deepcopy(fake_project) - project_data["workspace_id"] = f"{added_workspace['workspaceId']}" + project_data["workspace_id"] = f"{added_workspace.workspace_id}" first_project = await create_project( client.app, project_data, @@ -297,8 +315,11 @@ async def test_workspaces_delete_folders( ) # List project in workspace - base_url = client.app.router["list_projects"].url_for() - url = base_url.with_query({"workspace_id": f"{added_workspace['workspaceId']}"}) + url = ( + client.app.router["list_projects"] + .url_for() + .with_query({"workspace_id": f"{added_workspace.workspace_id}"}) + ) resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) assert len(data) == 2 @@ -306,10 +327,10 @@ async def test_workspaces_delete_folders( # Create folder in workspace url = client.app.router["create_folder"].url_for() resp = await client.post( - url.path, + f"{url}", json={ "name": "Original user folder", - "workspaceId": f"{added_workspace['workspaceId']}", + "workspaceId": f"{added_workspace.workspace_id}", }, ) first_folder, _ = await assert_status(resp, status.HTTP_201_CREATED) @@ -317,10 +338,10 @@ async def test_workspaces_delete_folders( # Create sub folder of previous folder url = client.app.router["create_folder"].url_for() resp = await client.post( - url.path, + f"{url}", json={ "name": "Second user folder", - "workspaceId": f"{added_workspace['workspaceId']}", + "workspaceId": f"{added_workspace.workspace_id}", "parentFolderId": f"{first_folder['folderId']}", }, ) @@ -331,7 +352,7 @@ async def test_workspaces_delete_folders( folder_id=f"{first_folder['folderId']}", project_id=f"{first_project['uuid']}", ) - resp = await client.put(url.path) + resp = await client.put(f"{url}") await assert_status(resp, status.HTTP_204_NO_CONTENT) # Move second project in specific folder in workspace @@ -339,19 +360,19 @@ async def test_workspaces_delete_folders( folder_id=f"{second_folder['folderId']}", project_id=f"{second_project['uuid']}", ) - resp = await client.put(url.path) + resp = await client.put(f"{url}") await assert_status(resp, status.HTTP_204_NO_CONTENT) # Delete folder url = client.app.router["delete_folder"].url_for( folder_id=f"{first_folder['folderId']}" ) - resp = await client.delete(url.path) + resp = await client.delete(f"{url}") await assert_status(resp, status.HTTP_204_NO_CONTENT) - fire_and_forget_tasks = client.app[APP_FIRE_AND_FORGET_TASKS_KEY] - t1: asyncio.Task = list(fire_and_forget_tasks)[0] - t2: asyncio.Task = list(fire_and_forget_tasks)[1] + fire_and_forget_tasks = list(client.app[APP_FIRE_AND_FORGET_TASKS_KEY]) + t1: asyncio.Task = fire_and_forget_tasks[0] + t2: asyncio.Task = fire_and_forget_tasks[1] assert t1.get_name().startswith("fire_and_forget_task_delete_project_task_") assert t2.get_name().startswith("fire_and_forget_task_delete_project_task_") await t1 @@ -360,8 +381,11 @@ async def test_workspaces_delete_folders( assert len(client.app[APP_FIRE_AND_FORGET_TASKS_KEY]) == 0 # List project in workspace (The projects should have been deleted) - base_url = client.app.router["list_projects"].url_for() - url = base_url.with_query({"workspace_id": f"{added_workspace['workspaceId']}"}) + url = ( + client.app.router["list_projects"] + .url_for() + .with_query({"workspace_id": f"{added_workspace.workspace_id}"}) + ) resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) assert len(data) == 0 @@ -382,7 +406,7 @@ async def test_listing_folders_and_projects_in_workspace__multiple_workspaces_cr # create a new workspace url = client.app.router["create_workspace"].url_for() resp = await client.post( - url.path, + f"{url}", json={ "name": "My first workspace", "description": "Custom description", @@ -404,7 +428,7 @@ async def test_listing_folders_and_projects_in_workspace__multiple_workspaces_cr # Create folder in workspace url = client.app.router["create_folder"].url_for() resp = await client.post( - url.path, + f"{url}", json={ "name": "Original user folder", "workspaceId": f"{added_workspace_1['workspaceId']}", @@ -415,7 +439,7 @@ async def test_listing_folders_and_projects_in_workspace__multiple_workspaces_cr # create a new workspace url = client.app.router["create_workspace"].url_for() resp = await client.post( - url.path, + f"{url}", json={ "name": "My first workspace", "description": "Custom description", @@ -437,7 +461,7 @@ async def test_listing_folders_and_projects_in_workspace__multiple_workspaces_cr # Create folder in workspace url = client.app.router["create_folder"].url_for() resp = await client.post( - url.path, + f"{url}", json={ "name": "Original user folder", "workspaceId": f"{added_workspace_2['workspaceId']}", @@ -446,17 +470,23 @@ async def test_listing_folders_and_projects_in_workspace__multiple_workspaces_cr first_folder, _ = await assert_status(resp, status.HTTP_201_CREATED) # List projects in workspace 1 - base_url = client.app.router["list_projects"].url_for() - url = base_url.with_query({"workspace_id": f"{added_workspace_1['workspaceId']}"}) - resp = await client.get(url) + url = ( + client.app.router["list_projects"] + .url_for() + .with_query({"workspace_id": f"{added_workspace_1['workspaceId']}"}) + ) + resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) assert len(data) == 1 # List folders in workspace 1 - base_url = client.app.router["list_folders"].url_for() - url = base_url.with_query( - {"workspace_id": f"{added_workspace_1['workspaceId']}", "folder_id": "null"} + url = ( + client.app.router["list_folders"] + .url_for() + .with_query( + {"workspace_id": f"{added_workspace_1['workspaceId']}", "folder_id": "null"} + ) ) - resp = await client.get(url) + resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) assert len(data) == 1