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 adf0766442e..ed85519a00f 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 @@ -1,11 +1,11 @@ from datetime import datetime -from typing import NamedTuple +from typing import Annotated, Self -from pydantic import ConfigDict, PositiveInt, field_validator +from pydantic import ConfigDict, Field, field_validator from ..access_rights import AccessRights from ..basic_types import IDStr -from ..folders import FolderID +from ..folders import FolderDB, FolderID from ..groups import GroupID from ..utils.common_validators import null_or_none_str_to_none_validator from ..workspaces import WorkspaceID @@ -16,17 +16,33 @@ class FolderGet(OutputSchema): folder_id: FolderID parent_folder_id: FolderID | None = None name: str + created_at: datetime modified_at: datetime trashed_at: datetime | None + trashed_by: Annotated[ + GroupID | None, Field(description="The primary gid of the user who trashed") + ] owner: GroupID workspace_id: WorkspaceID | None my_access_rights: AccessRights - -class FolderGetPage(NamedTuple): - items: list[FolderGet] - total: PositiveInt + @classmethod + def from_domain_model( + cls, folder_db: FolderDB, user_folder_access_rights: AccessRights + ) -> Self: + return cls.model_construct( + folder_id=folder_db.folder_id, + parent_folder_id=folder_db.parent_folder_id, + name=folder_db.name, + created_at=folder_db.created, + modified_at=folder_db.modified, + trashed_at=folder_db.trashed, + trashed_by=folder_db.trashed_by_primary_gid, + owner=folder_db.created_by_gid, + workspace_id=folder_db.workspace_id, + my_access_rights=user_folder_access_rights, + ) class FolderCreateBodyParams(InputSchema): diff --git a/packages/models-library/src/models_library/api_schemas_webserver/projects.py b/packages/models-library/src/models_library/api_schemas_webserver/projects.py index 95b6b50805f..f0269367fa0 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/projects.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/projects.py @@ -9,9 +9,6 @@ from typing import Annotated, Any, Literal, Self, TypeAlias from common_library.dict_tools import remap_keys -from models_library.folders import FolderID -from models_library.utils._original_fastapi_encoders import jsonable_encoder -from models_library.workspaces import WorkspaceID from pydantic import ( BeforeValidator, ConfigDict, @@ -29,6 +26,7 @@ from ..projects_access import AccessRights, GroupIDStr from ..projects_state import ProjectState from ..projects_ui import StudyUI +from ..utils._original_fastapi_encoders import jsonable_encoder from ..utils.common_validators import ( empty_str_to_none_pre_validator, none_to_empty_str_pre_validator, @@ -36,6 +34,7 @@ ) from ..workspaces import WorkspaceID from ._base import EmptyModel, InputSchema, OutputSchema +from .groups import GroupID from .permalinks import ProjectPermalink @@ -98,6 +97,9 @@ class ProjectGet(OutputSchema): folder_id: FolderID | None trashed_at: datetime | None + trashed_by: Annotated[ + GroupID | None, Field(description="The primary gid of the user who trashed") + ] _empty_description = field_validator("description", mode="before")( none_to_empty_str_pre_validator @@ -109,8 +111,16 @@ class ProjectGet(OutputSchema): def from_domain_model(cls, project_data: dict[str, Any]) -> Self: return cls.model_validate( remap_keys( - project_data, - rename={"trashed": "trashed_at"}, + { + k: v + for k, v in project_data.items() + if k not in {"trashed_by", "trashedBy"} + }, + rename={ + "trashed": "trashed_at", + "trashed_by_primary_gid": "trashed_by", + "trashedByPrimaryGid": "trashedBy", + }, ) ) @@ -127,7 +137,8 @@ class ProjectReplace(InputSchema): name: ShortTruncatedStr description: LongTruncatedStr thumbnail: Annotated[ - HttpUrl | None, BeforeValidator(empty_str_to_none_pre_validator) + HttpUrl | None, + BeforeValidator(empty_str_to_none_pre_validator), ] = Field(default=None) creation_date: DateTimeStr last_change_date: DateTimeStr 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 17762e9efea..1305af4f345 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/workspaces.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/workspaces.py @@ -1,12 +1,11 @@ from datetime import datetime -from typing import Self +from typing import Annotated, Self -from pydantic import ConfigDict +from pydantic import ConfigDict, Field from ..access_rights import AccessRights from ..basic_types import IDStr from ..groups import GroupID -from ..users import UserID from ..workspaces import UserWorkspaceWithAccessRights, WorkspaceID from ._base import InputSchema, OutputSchema @@ -19,7 +18,9 @@ class WorkspaceGet(OutputSchema): created_at: datetime modified_at: datetime trashed_at: datetime | None - trashed_by: UserID | None + trashed_by: Annotated[ + GroupID | None, Field(description="The primary gid of the user who trashed") + ] my_access_rights: AccessRights access_rights: dict[GroupID, AccessRights] @@ -33,7 +34,7 @@ def from_domain_model(cls, wks: UserWorkspaceWithAccessRights) -> Self: created_at=wks.created, modified_at=wks.modified, trashed_at=wks.trashed, - trashed_by=wks.trashed_by if wks.trashed else None, + trashed_by=wks.trashed_by_primary_gid if wks.trashed else None, my_access_rights=wks.my_access_rights, access_rights=wks.access_rights, ) diff --git a/packages/models-library/src/models_library/folders.py b/packages/models-library/src/models_library/folders.py index 0a3821fc987..98405026d90 100644 --- a/packages/models-library/src/models_library/folders.py +++ b/packages/models-library/src/models_library/folders.py @@ -1,15 +1,8 @@ from datetime import datetime from enum import auto -from typing import TypeAlias +from typing import NamedTuple, TypeAlias -from pydantic import ( - BaseModel, - ConfigDict, - Field, - PositiveInt, - ValidationInfo, - field_validator, -) +from pydantic import BaseModel, ConfigDict, PositiveInt, ValidationInfo, field_validator from .access_rights import AccessRights from .groups import GroupID @@ -52,25 +45,18 @@ class FolderDB(BaseModel): folder_id: FolderID name: str parent_folder_id: FolderID | None - created_by_gid: GroupID = Field( - ..., - description="GID of the group that owns this wallet", - ) - created: datetime = Field( - ..., - description="Timestamp on creation", - ) - modified: datetime = Field( - ..., - description="Timestamp of last modification", - ) - trashed: datetime | None = Field( - ..., - ) - - user_id: UserID | None - workspace_id: WorkspaceID | None + created_by_gid: GroupID + created: datetime + modified: datetime + + trashed: datetime | None + trashed_by: UserID | None + trashed_by_primary_gid: GroupID | None = None + trashed_explicitly: bool + + user_id: UserID | None # owner? + workspace_id: WorkspaceID | None model_config = ConfigDict(from_attributes=True) @@ -78,3 +64,8 @@ class UserFolderAccessRightsDB(FolderDB): my_access_rights: AccessRights model_config = ConfigDict(from_attributes=True) + + +class Folder(NamedTuple): + folder_db: FolderDB + my_access_rights: AccessRights diff --git a/packages/models-library/src/models_library/projects.py b/packages/models-library/src/models_library/projects.py index 81f230768cc..50f38673e4b 100644 --- a/packages/models-library/src/models_library/projects.py +++ b/packages/models-library/src/models_library/projects.py @@ -15,6 +15,7 @@ from .basic_regex import DATE_RE, UUID_RE_BASE from .emails import LowerCaseEmailStr +from .groups import GroupID from .projects_access import AccessRights, GroupIDStr from .projects_nodes import Node from .projects_nodes_io import NodeIDStr @@ -106,7 +107,7 @@ class ProjectAtDB(BaseProjectModel): @field_validator("project_type", mode="before") @classmethod - def convert_sql_alchemy_enum(cls, v): + def _convert_sql_alchemy_enum(cls, v): if isinstance(v, Enum): return v.value return v @@ -185,8 +186,12 @@ class Project(BaseProjectModel): trashed: datetime | None = None trashed_by: Annotated[UserID | None, Field(alias="trashedBy")] = None + trashed_by_primary_gid: Annotated[ + GroupID | None, Field(alias="trashedByPrimaryGid") + ] = None trashed_explicitly: Annotated[bool, Field(alias="trashedExplicitly")] = False model_config = ConfigDict( + # NOTE: this is a security measure until we get rid of the ProjectDict variants extra="forbid", ) diff --git a/packages/models-library/src/models_library/workspaces.py b/packages/models-library/src/models_library/workspaces.py index 01f66685fa1..ca31304a869 100644 --- a/packages/models-library/src/models_library/workspaces.py +++ b/packages/models-library/src/models_library/workspaces.py @@ -47,7 +47,7 @@ class Workspace(BaseModel): workspace_id: WorkspaceID name: str description: str | None - owner_primary_gid: PositiveInt = Field( + owner_primary_gid: GroupID = Field( ..., description="GID of the group that owns this wallet", ) @@ -62,6 +62,14 @@ class Workspace(BaseModel): ) trashed: datetime | None trashed_by: UserID | None + trashed_by_primary_gid: GroupID | None = None + + model_config = ConfigDict(from_attributes=True) + + +class UserWorkspaceWithAccessRights(Workspace): + my_access_rights: AccessRights + access_rights: dict[GroupID, AccessRights] model_config = ConfigDict(from_attributes=True) @@ -72,10 +80,3 @@ class WorkspaceUpdates(BaseModel): thumbnail: str | None = None trashed: datetime | None = None trashed_by: UserID | None = None - - -class UserWorkspaceWithAccessRights(Workspace): - my_access_rights: AccessRights - access_rights: dict[GroupID, AccessRights] - - model_config = ConfigDict(from_attributes=True) diff --git a/packages/models-library/tests/test_api_schemas_webserver_projects.py b/packages/models-library/tests/test_api_schemas_webserver_projects.py index 295e9ee2304..acd7a5fa443 100644 --- a/packages/models-library/tests/test_api_schemas_webserver_projects.py +++ b/packages/models-library/tests/test_api_schemas_webserver_projects.py @@ -30,7 +30,7 @@ @pytest.mark.parametrize( "api_call", - (NEW_PROJECT, CREATE_FROM_SERVICE, CREATE_FROM_TEMPLATE), + [NEW_PROJECT, CREATE_FROM_SERVICE, CREATE_FROM_TEMPLATE], ids=lambda c: c.name, ) def test_create_project_schemas(api_call: HttpApiCallCapture): @@ -45,7 +45,7 @@ def test_create_project_schemas(api_call: HttpApiCallCapture): @pytest.mark.parametrize( "api_call", - (LIST_PROJECTS,), + [LIST_PROJECTS], ids=lambda c: c.name, ) def test_list_project_schemas(api_call: HttpApiCallCapture): @@ -59,7 +59,7 @@ def test_list_project_schemas(api_call: HttpApiCallCapture): @pytest.mark.parametrize( "api_call", - (GET_PROJECT, CREATE_FROM_TEMPLATE__TASK_RESULT), + [GET_PROJECT, CREATE_FROM_TEMPLATE__TASK_RESULT], ids=lambda c: c.name, ) def test_get_project_schemas(api_call: HttpApiCallCapture): @@ -74,7 +74,7 @@ def test_get_project_schemas(api_call: HttpApiCallCapture): @pytest.mark.parametrize( "api_call", - (REPLACE_PROJECT, REPLACE_PROJECT_ON_MODIFIED), + [REPLACE_PROJECT, REPLACE_PROJECT_ON_MODIFIED], ids=lambda c: c.name, ) def test_replace_project_schemas(api_call: HttpApiCallCapture): 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 e13f9bc4221..d72cfd9b74e 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/projects.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/projects.py @@ -93,7 +93,7 @@ class ProjectType(enum.Enum): JSONB, nullable=False, server_default=sa.text("'{}'::jsonb"), - doc="Read/write/delete access rights of each group (gid) on this project", + doc="DEPRECATED: Read/write/delete access rights of each group (gid) on this project", ), sa.Column( "workbench", diff --git a/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_projects_rest_api.py b/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_projects_rest_api.py index fd6dd234720..88538b6b21b 100644 --- a/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_projects_rest_api.py +++ b/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_projects_rest_api.py @@ -76,7 +76,8 @@ def request_desc(self) -> str: "dev": None, "workspace_id": None, "folder_id": None, - "trashed_at": None, + "trashedAt": None, + "trashed_by": None, }, "error": None, }, @@ -102,7 +103,6 @@ def request_desc(self) -> str: "workbench": {}, "workspaceId": 123, "folderId": 2, - "trashedAt": "2021-12-06T10:13:18.100Z", "accessRights": {"2": {"read": True, "write": True, "delete": True}}, "dev": {}, "classifiers": [], @@ -115,7 +115,8 @@ def request_desc(self) -> str: }, "workspace_id": None, "folder_id": None, - "trashed_at": None, + "trashedAt": "2021-12-06T10:13:18.100Z", + "trashedBy": 3, } }, ) @@ -157,7 +158,8 @@ def request_desc(self) -> str: }, "workspace_id": None, "folder_id": None, - "trashed_at": None, + "trashedAt": None, + "trashed_by": None, } }, ) @@ -287,7 +289,8 @@ def request_desc(self) -> str: }, "workspace_id": None, "folder_id": None, - "trashed_at": None, + "trashedAt": None, + "trashed_by": None, } }, ) @@ -480,7 +483,8 @@ def request_desc(self) -> str: "dev": {}, "workspace_id": None, "folder_id": None, - "trashed_at": None, + "trashedAt": None, + "trashed_by": None, "classifiers": [], "ui": { "mode": "workbench", @@ -681,7 +685,8 @@ def request_desc(self) -> str: "dev": {}, "workspace_id": None, "folder_id": None, - "trashed_at": None, + "trashedAt": None, + "trashed_by": None, "quality": { "enabled": True, "tsr_target": { @@ -931,7 +936,8 @@ def request_desc(self) -> str: "dev": {}, "workspace_id": None, "folder_id": None, - "trashed_at": None, + "trashedAt": None, + "trashedBy": None, "quality": { "enabled": True, "tsr_target": { diff --git a/services/api-server/tests/mocks/create_study_job.json b/services/api-server/tests/mocks/create_study_job.json index e2b522e66db..b0e234caede 100644 --- a/services/api-server/tests/mocks/create_study_job.json +++ b/services/api-server/tests/mocks/create_study_job.json @@ -85,6 +85,7 @@ "workspaceId": 23, "folderId": 4, "trashedAt": "2024-05-14T09:55:20.099Z", + "trashedBy": 2, "accessRights": { "3": { "read": true, diff --git a/services/api-server/tests/mocks/for_test_api_routes_studies.json b/services/api-server/tests/mocks/for_test_api_routes_studies.json index 9e5b0e679ae..71394134beb 100644 --- a/services/api-server/tests/mocks/for_test_api_routes_studies.json +++ b/services/api-server/tests/mocks/for_test_api_routes_studies.json @@ -92,6 +92,7 @@ "workspaceId": 278, "folderId": 123, "trashedAt": "2023-07-20T20:02:55.535Z", + "trashedBy": 2, "workbench": { "deea006c-a223-4103-b46e-7b677428de9f": { "key": "simcore/services/frontend/file-picker", @@ -314,6 +315,7 @@ "workspaceId": 278, "folderId": 123, "trashedAt": "2023-07-20T20:04:10.607Z", + "trashedBy": 2, "workbench": { "deea006c-a223-4103-b46e-7b677428de9f": { "key": "simcore/services/frontend/file-picker", diff --git a/services/api-server/tests/mocks/get_job_outputs.json b/services/api-server/tests/mocks/get_job_outputs.json index a53e1742e95..d7649ebc2c0 100644 --- a/services/api-server/tests/mocks/get_job_outputs.json +++ b/services/api-server/tests/mocks/get_job_outputs.json @@ -147,6 +147,7 @@ "workspaceId": 5, "folderId": 2, "trashedAt": null, + "trashedBy": null, "workbench": { "dd875b4f-7663-529f-bd7f-3716b19e28af": { "key": "simcore/services/comp/itis/sleeper", diff --git a/services/api-server/tests/mocks/get_job_pricing_unit_invalid_solver.json b/services/api-server/tests/mocks/get_job_pricing_unit_invalid_solver.json index a99eca3ed8f..53baa5b4f02 100644 --- a/services/api-server/tests/mocks/get_job_pricing_unit_invalid_solver.json +++ b/services/api-server/tests/mocks/get_job_pricing_unit_invalid_solver.json @@ -39,6 +39,7 @@ "workspaceId": 3, "folderId": 31, "trashedAt": null, + "trashedBy": null, "workbench": { "4b03863d-107a-5c77-a3ca-c5ba1d7048c0": { "key": "simcore/services/comp/isolve", diff --git a/services/api-server/tests/mocks/get_job_pricing_unit_success.json b/services/api-server/tests/mocks/get_job_pricing_unit_success.json index 3c10f684e46..ba4cae5d53c 100644 --- a/services/api-server/tests/mocks/get_job_pricing_unit_success.json +++ b/services/api-server/tests/mocks/get_job_pricing_unit_success.json @@ -39,6 +39,7 @@ "workspaceId": 3, "folderId": 1, "trashedAt": null, + "trashedBy": null, "workbench": { "4b03863d-107a-5c77-a3ca-c5ba1d7048c0": { "key": "simcore/services/comp/isolve", diff --git a/services/api-server/tests/mocks/get_solver_outputs.json b/services/api-server/tests/mocks/get_solver_outputs.json index adda3db505e..4971676029f 100644 --- a/services/api-server/tests/mocks/get_solver_outputs.json +++ b/services/api-server/tests/mocks/get_solver_outputs.json @@ -39,6 +39,7 @@ "workspaceId": 2, "folderId": 2, "trashedAt": null, + "trashedBy": null, "workbench": { "df42d273-b6f0-509c-bfb5-4abbc5bb0581": { "key": "simcore/services/comp/itis/sleeper", diff --git a/services/api-server/tests/mocks/on_list_jobs.json b/services/api-server/tests/mocks/on_list_jobs.json index d954da588fa..0e94cec3ee2 100644 --- a/services/api-server/tests/mocks/on_list_jobs.json +++ b/services/api-server/tests/mocks/on_list_jobs.json @@ -104,6 +104,7 @@ "workspaceId": 7, "folderId": 1, "trashedAt": "2023-06-22T18:42:36.506Z", + "trashedBy": 2, "workbench": { "05c7ed3b-0be1-5077-8065-fb55f5e59ff3": { "key": "simcore/services/comp/itis/sleeper", @@ -177,6 +178,7 @@ "workspaceId": 4, "folderId": 8, "trashedAt": "2023-06-22T18:42:33.201Z", + "trashedBy": 2, "workbench": { "34805d7e-c2d0-561f-831f-c74a28fc9bd1": { "key": "simcore/services/comp/itis/sleeper", diff --git a/services/api-server/tests/mocks/run_study_workflow.json b/services/api-server/tests/mocks/run_study_workflow.json index 8078a8cc155..9bfcf708650 100644 --- a/services/api-server/tests/mocks/run_study_workflow.json +++ b/services/api-server/tests/mocks/run_study_workflow.json @@ -304,6 +304,7 @@ "workspaceId": 3, "folderId": 3, "trashedAt": null, + "trashedBy": null, "workbench": { "ab014072-a95f-5775-bb34-5582a13245a6": { "key": "simcore/services/frontend/iterator-consumer/probe/file", diff --git a/services/api-server/tests/mocks/start_job_not_enough_credit.json b/services/api-server/tests/mocks/start_job_not_enough_credit.json index 19f54e53ca6..4ebe858881c 100644 --- a/services/api-server/tests/mocks/start_job_not_enough_credit.json +++ b/services/api-server/tests/mocks/start_job_not_enough_credit.json @@ -39,6 +39,7 @@ "workspaceId": 3, "folderId": 2, "trashedAt": null, + "trashedBy": null, "workbench": { "3b0b20e0-c860-51d9-9f82-d6b4bc5c2f24": { "key": "simcore/services/comp/itis/sleeper", diff --git a/services/api-server/tests/mocks/start_job_with_payment.json b/services/api-server/tests/mocks/start_job_with_payment.json index ac3aed74ecb..e4cbc7cc21f 100644 --- a/services/api-server/tests/mocks/start_job_with_payment.json +++ b/services/api-server/tests/mocks/start_job_with_payment.json @@ -39,6 +39,7 @@ "workspaceId": 12, "folderId": 2, "trashedAt": null, + "trashedBy": null, "workbench": { "657b124c-0697-5166-b820-a2ea2704ae84": { "key": "simcore/services/comp/itis/sleeper", diff --git a/services/api-server/tests/mocks/test_get_and_update_study_job_metadata.json b/services/api-server/tests/mocks/test_get_and_update_study_job_metadata.json index a8f690f0827..2500f764bc1 100644 --- a/services/api-server/tests/mocks/test_get_and_update_study_job_metadata.json +++ b/services/api-server/tests/mocks/test_get_and_update_study_job_metadata.json @@ -211,6 +211,7 @@ "workspaceId": 3, "folderId": 12, "trashedAt": "2024-05-30T10:30:54.137359", + "trashedBy": 2, "workbench": { "45043872-d6d3-530b-bf40-67bfde79191c": { "key": "simcore/services/dynamic/jupyter-math", diff --git a/services/api-server/tests/unit/conftest.py b/services/api-server/tests/unit/conftest.py index 2b994b2f612..0706a5b1d8f 100644 --- a/services/api-server/tests/unit/conftest.py +++ b/services/api-server/tests/unit/conftest.py @@ -521,6 +521,7 @@ def create_project_task(self, request: httpx.Request): "prjOwner": "owner@email.com", "dev": None, "trashed_at": None, + "trashed_by": None, **project_create, } ) diff --git a/services/web/server/VERSION b/services/web/server/VERSION index 564edf82ddf..c5d4cee36a1 100644 --- a/services/web/server/VERSION +++ b/services/web/server/VERSION @@ -1 +1 @@ -0.50.0 +0.51.0 diff --git a/services/web/server/setup.cfg b/services/web/server/setup.cfg index 486fe83406d..65736bec36e 100644 --- a/services/web/server/setup.cfg +++ b/services/web/server/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.50.0 +current_version = 0.51.0 commit = True message = services/webserver api version: {current_version} → {new_version} tag = False @@ -12,13 +12,13 @@ commit_args = --no-verify [tool:pytest] addopts = --strict-markers asyncio_mode = auto -markers = +markers = slow: marks tests as slow (deselect with '-m "not slow"') acceptance_test: "marks tests as 'acceptance tests' i.e. does the system do what the user expects? Typically those are workflows." testit: "marks test to run during development" heavy_load: "mark tests that require large amount of data" [mypy] -plugins = +plugins = pydantic.mypy sqlalchemy.ext.mypy.plugin 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 70e29c830d5..1e0863d9d4d 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.50.0 + version: 0.51.0 servers: - url: '' description: webserver @@ -9976,6 +9976,14 @@ components: format: date-time - type: 'null' title: Trashedat + trashedBy: + anyOf: + - type: integer + exclusiveMinimum: true + minimum: 0 + - type: 'null' + title: Trashedby + description: The primary gid of the user who trashed owner: type: integer exclusiveMinimum: true @@ -9997,6 +10005,7 @@ components: - createdAt - modifiedAt - trashedAt + - trashedBy - owner - workspaceId - myAccessRights @@ -12565,6 +12574,14 @@ components: format: date-time - type: 'null' title: Trashedat + trashedBy: + anyOf: + - type: integer + exclusiveMinimum: true + minimum: 0 + - type: 'null' + title: Trashedby + description: The primary gid of the user who trashed type: object required: - uuid @@ -12581,6 +12598,7 @@ components: - workspaceId - folderId - trashedAt + - trashedBy title: ProjectGet ProjectGroupGet: properties: @@ -12813,6 +12831,14 @@ components: format: date-time - type: 'null' title: Trashedat + trashedBy: + anyOf: + - type: integer + exclusiveMinimum: true + minimum: 0 + - type: 'null' + title: Trashedby + description: The primary gid of the user who trashed type: object required: - uuid @@ -12829,6 +12855,7 @@ components: - workspaceId - folderId - trashedAt + - trashedBy title: ProjectListItem ProjectLocked: properties: @@ -15152,6 +15179,7 @@ components: minimum: 0 - type: 'null' title: Trashedby + description: The primary gid of the user who trashed myAccessRights: $ref: '#/components/schemas/AccessRights' accessRights: diff --git a/services/web/server/src/simcore_service_webserver/folders/_folders_repository.py b/services/web/server/src/simcore_service_webserver/folders/_folders_repository.py index 243fba7e858..313101b954d 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_folders_repository.py +++ b/services/web/server/src/simcore_service_webserver/folders/_folders_repository.py @@ -28,6 +28,7 @@ from simcore_postgres_database.models.folders_v2 import folders_v2 from simcore_postgres_database.models.projects import projects from simcore_postgres_database.models.projects_to_folders import projects_to_folders +from simcore_postgres_database.models.users import users from simcore_postgres_database.utils_repos import ( pass_or_acquire_connection, transaction_context, @@ -48,7 +49,7 @@ _unset: Final = UnSet() -_SELECTION_ARGS = ( +_FOLDERS_SELECTION_COLS = ( folders_v2.c.folder_id, folders_v2.c.name, folders_v2.c.parent_folder_id, @@ -56,6 +57,8 @@ folders_v2.c.created, folders_v2.c.modified, folders_v2.c.trashed, + folders_v2.c.trashed_by, + folders_v2.c.trashed_explicitly, folders_v2.c.user_id, folders_v2.c.workspace_id, ) @@ -89,7 +92,7 @@ async def create( created=func.now(), modified=func.now(), ) - .returning(*_SELECTION_ARGS) + .returning(*_FOLDERS_SELECTION_COLS) ) row = await result.first() return FolderDB.model_validate(row) @@ -107,7 +110,7 @@ def _create_private_workspace_query( ) return ( select( - *_SELECTION_ARGS, + *_FOLDERS_SELECTION_COLS, func.json_build_object( "read", sa.text("true"), @@ -144,7 +147,8 @@ def _create_shared_workspace_query( shared_workspace_query = ( select( - *_SELECTION_ARGS, workspace_access_rights_subquery.c.my_access_rights + *_FOLDERS_SELECTION_COLS, + workspace_access_rights_subquery.c.my_access_rights, ) .select_from( folders_v2.join( @@ -268,6 +272,20 @@ async def list_( # pylint: disable=too-many-arguments,too-many-branches return cast(int, total_count), folders +def _create_base_select_query(folder_id: FolderID, product_name: ProductName): + return ( + select( + *_FOLDERS_SELECTION_COLS, + users.c.primary_gid.label("trashed_by_primary_gid"), + ) + .select_from(folders_v2.outerjoin(users, folders_v2.c.trashed_by == users.c.id)) + .where( + (folders_v2.c.product_name == product_name) + & (folders_v2.c.folder_id == folder_id) + ) + ) + + async def get( app: web.Application, connection: AsyncConnection | None = None, @@ -275,18 +293,11 @@ async def get( folder_id: FolderID, product_name: ProductName, ) -> FolderDB: - query = ( - select(*_SELECTION_ARGS) - .select_from(folders_v2) - .where( - (folders_v2.c.product_name == product_name) - & (folders_v2.c.folder_id == folder_id) - ) - ) + query = _create_base_select_query(folder_id=folder_id, product_name=product_name) async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: - result = await conn.stream(query) - row = await result.first() + result = await conn.execute(query) + row = result.first() if row is None: raise FolderAccessForbiddenError( reason=f"Folder {folder_id} does not exist.", @@ -307,16 +318,10 @@ async def get_for_user_or_workspace( user_id is not None and workspace_id is not None ), "Both user_id and workspace_id cannot be provided at the same time. Please provide only one." - query = ( - select(*_SELECTION_ARGS) - .select_from(folders_v2) - .where( - (folders_v2.c.product_name == product_name) - & (folders_v2.c.folder_id == folder_id) - ) - ) + query = _create_base_select_query(folder_id=folder_id, product_name=product_name) if user_id: + # ownership query = query.where(folders_v2.c.user_id == user_id) else: query = query.where(folders_v2.c.workspace_id == workspace_id) @@ -364,7 +369,7 @@ async def update( query = ( (folders_v2.update().values(modified=func.now(), **updated)) .where(folders_v2.c.product_name == product_name) - .returning(*_SELECTION_ARGS) + .returning(*_FOLDERS_SELECTION_COLS) ) if isinstance(folders_id_or_ids, set): @@ -581,4 +586,4 @@ async def get_folders_recursively( # Step 4: Execute the query to get all descendants final_query = select(folder_hierarchy_cte) result = await conn.stream(final_query) - return [FolderID(row[0]) async for row in result] + return cast(list[FolderID], [row.folder_id async for row in result]) diff --git a/services/web/server/src/simcore_service_webserver/folders/_folders_rest.py b/services/web/server/src/simcore_service_webserver/folders/_folders_rest.py index 80da8bd21e8..e3460eeb8ae 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_folders_rest.py +++ b/services/web/server/src/simcore_service_webserver/folders/_folders_rest.py @@ -4,9 +4,9 @@ from models_library.api_schemas_webserver.folders_v2 import ( FolderCreateBodyParams, FolderGet, - FolderGetPage, FolderReplaceBodyParams, ) +from models_library.folders import Folder from models_library.rest_ordering import OrderBy from models_library.rest_pagination import ItemT, Page from models_library.rest_pagination_utils import paginate_data @@ -54,7 +54,7 @@ async def create_folder(request: web.Request): req_ctx = FoldersRequestContext.model_validate(request) body_params = await parse_request_body_as(FolderCreateBodyParams, request) - folder = await _folders_service.create_folder( + folder: Folder = await _folders_service.create_folder( request.app, user_id=req_ctx.user_id, name=body_params.name, @@ -63,7 +63,10 @@ async def create_folder(request: web.Request): workspace_id=body_params.workspace_id, ) - return envelope_json_response(folder, web.HTTPCreated) + return envelope_json_response( + FolderGet.from_domain_model(folder.folder_db, folder.my_access_rights), + web.HTTPCreated, + ) @routes.get(f"/{VTAG}/folders", name="list_folders") @@ -79,7 +82,7 @@ async def list_folders(request: web.Request): if not query_params.filters: query_params.filters = FolderFilters() - folders: FolderGetPage = await _folders_service.list_folders( + folders, total_count = await _folders_service.list_folders( app=request.app, user_id=req_ctx.user_id, product_name=req_ctx.product_name, @@ -93,9 +96,9 @@ async def list_folders(request: web.Request): page = Page[FolderGet].model_validate( paginate_data( - chunk=folders.items, + chunk=[FolderGet.from_domain_model(*f) for f in folders], request_url=request.url, - total=folders.total, + total=total_count, limit=query_params.limit, offset=query_params.offset, ) @@ -116,7 +119,7 @@ async def list_folders_full_search(request: web.Request): if not query_params.filters: query_params.filters = FolderFilters() - folders: FolderGetPage = await _folders_service.list_folders_full_depth( + folders, total_count = await _folders_service.list_folders_full_depth( app=request.app, user_id=req_ctx.user_id, product_name=req_ctx.product_name, @@ -129,9 +132,9 @@ async def list_folders_full_search(request: web.Request): page = Page[FolderGet].model_validate( paginate_data( - chunk=folders.items, + chunk=[FolderGet.from_domain_model(*f) for f in folders], request_url=request.url, - total=folders.total, + total=total_count, limit=query_params.limit, offset=query_params.offset, ) @@ -147,14 +150,15 @@ async def get_folder(request: web.Request): req_ctx = FoldersRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(FoldersPathParams, request) - folder: FolderGet = await _folders_service.get_folder( + folder: Folder = await _folders_service.get_folder( app=request.app, folder_id=path_params.folder_id, user_id=req_ctx.user_id, product_name=req_ctx.product_name, ) - - return envelope_json_response(folder) + return envelope_json_response( + FolderGet.from_domain_model(folder.folder_db, folder.my_access_rights) + ) @routes.put( @@ -169,7 +173,7 @@ async def replace_folder(request: web.Request): path_params = parse_request_path_parameters_as(FoldersPathParams, request) body_params = await parse_request_body_as(FolderReplaceBodyParams, request) - folder = await _folders_service.update_folder( + folder: Folder = await _folders_service.update_folder( app=request.app, user_id=req_ctx.user_id, folder_id=path_params.folder_id, @@ -177,7 +181,9 @@ async def replace_folder(request: web.Request): parent_folder_id=body_params.parent_folder_id, product_name=req_ctx.product_name, ) - return envelope_json_response(folder) + return envelope_json_response( + FolderGet.from_domain_model(folder.folder_db, folder.my_access_rights) + ) @routes.delete( diff --git a/services/web/server/src/simcore_service_webserver/folders/_folders_service.py b/services/web/server/src/simcore_service_webserver/folders/_folders_service.py index e9960512558..3708c5a4afe 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_folders_service.py +++ b/services/web/server/src/simcore_service_webserver/folders/_folders_service.py @@ -4,8 +4,7 @@ from aiohttp import web from models_library.access_rights import AccessRights -from models_library.api_schemas_webserver.folders_v2 import FolderGet, FolderGetPage -from models_library.folders import FolderID, FolderQuery, FolderScope +from models_library.folders import Folder, FolderID, FolderQuery, FolderScope from models_library.products import ProductName from models_library.projects import ProjectID from models_library.rest_ordering import OrderBy @@ -36,7 +35,7 @@ async def create_folder( parent_folder_id: FolderID | None, product_name: ProductName, workspace_id: WorkspaceID | None, -) -> FolderGet: +) -> Folder: user = await get_user(app, user_id=user_id) workspace_is_private = True @@ -86,17 +85,7 @@ async def create_folder( user_id=user_id if workspace_is_private else None, workspace_id=workspace_id, ) - return FolderGet( - folder_id=folder_db.folder_id, - parent_folder_id=folder_db.parent_folder_id, - name=folder_db.name, - created_at=folder_db.created, - modified_at=folder_db.modified, - trashed_at=folder_db.trashed, - owner=folder_db.created_by_gid, - workspace_id=workspace_id, - my_access_rights=user_folder_access_rights, - ) + return Folder(folder_db=folder_db, my_access_rights=user_folder_access_rights) async def get_folder( @@ -104,7 +93,7 @@ async def get_folder( user_id: UserID, folder_id: FolderID, product_name: ProductName, -) -> FolderGet: +) -> Folder: folder_db = await folders_db.get( app, folder_id=folder_id, product_name=product_name ) @@ -129,17 +118,7 @@ async def get_folder( user_id=user_id if workspace_is_private else None, workspace_id=folder_db.workspace_id, ) - return FolderGet( - folder_id=folder_db.folder_id, - parent_folder_id=folder_db.parent_folder_id, - name=folder_db.name, - created_at=folder_db.created, - modified_at=folder_db.modified, - trashed_at=folder_db.trashed, - owner=folder_db.created_by_gid, - workspace_id=folder_db.workspace_id, - my_access_rights=user_folder_access_rights, - ) + return Folder(folder_db=folder_db, my_access_rights=user_folder_access_rights) async def list_folders( @@ -152,7 +131,7 @@ async def list_folders( offset: NonNegativeInt, limit: int, order_by: OrderBy, -) -> FolderGetPage: +) -> tuple[list[Folder], int]: # NOTE: Folder access rights for listing are checked within the listing DB function. total_count, folders = await folders_db.list_( @@ -177,22 +156,15 @@ async def list_folders( limit=limit, order_by=order_by, ) - return FolderGetPage( - items=[ - FolderGet( - folder_id=folder.folder_id, - parent_folder_id=folder.parent_folder_id, - name=folder.name, - created_at=folder.created, - modified_at=folder.modified, - trashed_at=folder.trashed, - owner=folder.created_by_gid, - workspace_id=folder.workspace_id, + return ( + [ + Folder( + folder_db=folder, my_access_rights=folder.my_access_rights, ) for folder in folders ], - total=total_count, + total_count, ) @@ -206,7 +178,7 @@ async def list_folders_full_depth( offset: NonNegativeInt, limit: int, order_by: OrderBy, -) -> FolderGetPage: +) -> tuple[list[Folder], int]: # NOTE: Folder access rights for listing are checked within the listing DB function. total_count, folders = await folders_db.list_( @@ -221,22 +193,15 @@ async def list_folders_full_depth( limit=limit, order_by=order_by, ) - return FolderGetPage( - items=[ - FolderGet( - folder_id=folder.folder_id, - parent_folder_id=folder.parent_folder_id, - name=folder.name, - created_at=folder.created, - modified_at=folder.modified, - trashed_at=folder.trashed, - owner=folder.created_by_gid, - workspace_id=folder.workspace_id, + return ( + [ + Folder( + folder_db=folder, my_access_rights=folder.my_access_rights, ) for folder in folders ], - total=total_count, + total_count, ) @@ -248,7 +213,7 @@ async def update_folder( name: str, parent_folder_id: FolderID | None, product_name: ProductName, -) -> FolderGet: +) -> Folder: folder_db = await folders_db.get( app, folder_id=folder_id, product_name=product_name ) @@ -300,17 +265,7 @@ async def update_folder( parent_folder_id=parent_folder_id, product_name=product_name, ) - return FolderGet( - folder_id=folder_db.folder_id, - parent_folder_id=folder_db.parent_folder_id, - name=folder_db.name, - created_at=folder_db.created, - modified_at=folder_db.modified, - trashed_at=folder_db.trashed, - owner=folder_db.created_by_gid, - workspace_id=folder_db.workspace_id, - my_access_rights=user_folder_access_rights, - ) + return Folder(folder_db, user_folder_access_rights) async def delete_folder( diff --git a/services/web/server/src/simcore_service_webserver/projects/_crud_api_create.py b/services/web/server/src/simcore_service_webserver/projects/_crud_api_create.py index 8b656f67cac..5fa66d35ba1 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_crud_api_create.py +++ b/services/web/server/src/simcore_service_webserver/projects/_crud_api_create.py @@ -18,6 +18,7 @@ from pydantic import TypeAdapter from servicelib.aiohttp.long_running_tasks.server import TaskProgress from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON +from servicelib.rest_constants import RESPONSE_MODEL_POLICY from simcore_postgres_database.utils_projects_nodes import ( ProjectNode, ProjectNodeCreate, @@ -420,8 +421,7 @@ async def create_project( # pylint: disable=too-many-arguments,too-many-branche for gid, access in workspace.access_rights.items() } - # Ensures is like ProjectGet - data = ProjectGet.from_domain_model(new_project).data(exclude_unset=True) + data = ProjectGet.from_domain_model(new_project).data(**RESPONSE_MODEL_POLICY) raise web.HTTPCreated( text=json_dumps({"data": data}), diff --git a/services/web/server/src/simcore_service_webserver/projects/_db_utils.py b/services/web/server/src/simcore_service_webserver/projects/_db_utils.py index da1f06d06df..b5d90f8f13b 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_db_utils.py +++ b/services/web/server/src/simcore_service_webserver/projects/_db_utils.py @@ -19,12 +19,12 @@ from simcore_postgres_database.models.projects_to_products import projects_to_products from simcore_postgres_database.webserver_models import ProjectType, projects from sqlalchemy.dialects.postgresql import insert as pg_insert -from sqlalchemy.sql import select from sqlalchemy.sql.selectable import CompoundSelect, Select from ..db.models import GroupType, groups, projects_tags, user_to_groups, users from ..users.exceptions import UserNotFoundError from ..utils import format_datetime +from ._projects_db import BASE_PROJECT_SELECT_ARGS from .exceptions import ( NodeNotFoundError, ProjectInvalidRightsError, @@ -65,6 +65,8 @@ def convert_to_db_names(project_document_data: dict) -> dict: "tags", "prjOwner", "folderId", + "trashedByPrimaryGid", + "trashed_by_primary_gid", ] # No column for tags, prjOwner is a foreign key in db for key, value in project_document_data.items(): if key not in exclude_keys: @@ -130,7 +132,7 @@ async def _list_user_groups( user_groups.append(everyone_group) else: result = await conn.execute( - select(groups) + sa.select(groups) .select_from(groups.join(user_to_groups)) .where(user_to_groups.c.uid == user_id) ) @@ -255,7 +257,7 @@ async def _get_project( exclude_foreign = exclude_foreign or [] access_rights_subquery = ( - select( + sa.select( project_to_groups.c.project_uuid, sa.func.jsonb_object_agg( project_to_groups.c.gid, @@ -275,29 +277,14 @@ async def _get_project( query = ( sa.select( - projects.c.id, - projects.c.type, - projects.c.uuid, - projects.c.name, - projects.c.description, - projects.c.thumbnail, - projects.c.prj_owner, # == user.id (who created) - projects.c.creation_date, - projects.c.last_change_date, - projects.c.workbench, - projects.c.ui, - projects.c.classifiers, - projects.c.dev, - projects.c.quality, - projects.c.published, - projects.c.hidden, - projects.c.trashed, - projects.c.trashed_by, # == user.id (who trashed) - projects.c.trashed_explicitly, - projects.c.workspace_id, + *BASE_PROJECT_SELECT_ARGS, access_rights_subquery.c.access_rights, ) - .select_from(projects.join(access_rights_subquery, isouter=True)) + .select_from( + projects.join(access_rights_subquery, isouter=True).outerjoin( + users, projects.c.trashed_by == users.c.id + ) + ) .where( (projects.c.uuid == f"{project_uuid}") & ( diff --git a/services/web/server/src/simcore_service_webserver/projects/_projects_db.py b/services/web/server/src/simcore_service_webserver/projects/_projects_db.py index 92c73867f77..7a65a8feda2 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_projects_db.py +++ b/services/web/server/src/simcore_service_webserver/projects/_projects_db.py @@ -3,8 +3,9 @@ import sqlalchemy as sa from aiohttp import web from models_library.projects import ProjectID +from simcore_postgres_database.models.projects import projects +from simcore_postgres_database.models.users import users from simcore_postgres_database.utils_repos import transaction_context -from simcore_postgres_database.webserver_models import projects from sqlalchemy.ext.asyncio import AsyncConnection from ..db.plugin import get_asyncpg_engine @@ -14,17 +15,17 @@ _logger = logging.getLogger(__name__) -# NOTE: MD: I intentionally didn't include the workbench. There is a special interface -# for the workbench, and at some point, this column should be removed from the table. -# The same holds true for access_rights/ui/classifiers/quality, but we have decided to proceed step by step. -_SELECTION_PROJECT_DB_ARGS = [ # noqa: RUF012 +PROJECT_WITHOUT_WORKBENCH_COLS = [ # noqa: RUF012 + # NOTE: MD: I intentionally didn't include the workbench. There is a special interface + # for the workbench, and at some point, this column should be removed from the table. + # The same holds true for access_rights/ui/classifiers/quality, but we have decided to proceed step by step. projects.c.id, projects.c.type, projects.c.uuid, projects.c.name, projects.c.description, projects.c.thumbnail, - projects.c.prj_owner, + projects.c.prj_owner, # == user.id (who created) projects.c.creation_date, projects.c.last_change_date, projects.c.ui, @@ -35,6 +36,15 @@ projects.c.hidden, projects.c.workspace_id, projects.c.trashed, + projects.c.trashed_by, # == user.id (who trashed) + projects.c.trashed_explicitly, +] + +BASE_PROJECT_SELECT_ARGS = [ + *PROJECT_WITHOUT_WORKBENCH_COLS, + projects.c.workbench, + users.c.primary_gid.label("trashed_by_primary_gid"), + # NOTE: needs `.outerjoin(users, projects.c.trashed_by == users.c.id)` ] @@ -51,7 +61,7 @@ async def patch_project( projects.update() .values(last_change_date=sa.func.now(), **new_partial_project_data) .where(projects.c.uuid == f"{project_uuid}") - .returning(*_SELECTION_PROJECT_DB_ARGS) + .returning(*PROJECT_WITHOUT_WORKBENCH_COLS) ) row = await result.first() if row is None: diff --git a/services/web/server/src/simcore_service_webserver/projects/_trash_service.py b/services/web/server/src/simcore_service_webserver/projects/_trash_service.py index 13e07c51475..d44a59dbce7 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_trash_service.py +++ b/services/web/server/src/simcore_service_webserver/projects/_trash_service.py @@ -15,7 +15,7 @@ from . import projects_api from ._access_rights_api import check_user_project_permission from .exceptions import ProjectRunningConflictError -from .models import ProjectPatchExtended +from .models import ProjectPatchInternalExtended _logger = logging.getLogger(__name__) @@ -94,8 +94,10 @@ async def _schedule(): user_id=user_id, product_name=product_name, project_uuid=project_id, - project_patch=ProjectPatchExtended( - trashed_at=arrow.utcnow().datetime, trashed_explicitly=explicit + project_patch=ProjectPatchInternalExtended( + trashed_at=arrow.utcnow().datetime, + trashed_explicitly=explicit, + trashed_by=user_id, ), ) @@ -113,5 +115,7 @@ async def untrash_project( user_id=user_id, product_name=product_name, project_uuid=project_id, - project_patch=ProjectPatchExtended(trashed_at=None, trashed_explicitly=False), + project_patch=ProjectPatchInternalExtended( + trashed_at=None, trashed_explicitly=False, trashed_by=None + ), ) diff --git a/services/web/server/src/simcore_service_webserver/projects/db.py b/services/web/server/src/simcore_service_webserver/projects/db.py index 47704f74e2f..91ba47d23c0 100644 --- a/services/web/server/src/simcore_service_webserver/projects/db.py +++ b/services/web/server/src/simcore_service_webserver/projects/db.py @@ -85,7 +85,7 @@ patch_workbench, update_workbench, ) -from ._projects_db import _SELECTION_PROJECT_DB_ARGS +from ._projects_db import BASE_PROJECT_SELECT_ARGS, PROJECT_WITHOUT_WORKBENCH_COLS from .exceptions import ( ProjectDeleteError, ProjectInvalidRightsError, @@ -427,11 +427,7 @@ async def list_projects( # pylint: disable=too-many-arguments,too-many-statemen private_workspace_query = ( sa.select( - *[ - col - for col in projects.columns - if col.name not in ["access_rights"] - ], + *BASE_PROJECT_SELECT_ARGS, self.access_rights_subquery.c.access_rights, projects_to_products.c.product_name, projects_to_folders.c.folder_id, @@ -452,6 +448,7 @@ async def list_projects( # pylint: disable=too-many-arguments,too-many-statemen isouter=True, ) .join(project_tags_subquery, isouter=True) + .outerjoin(users, projects.c.trashed_by == users.c.id) ) .where( ( @@ -484,11 +481,7 @@ async def list_projects( # pylint: disable=too-many-arguments,too-many-statemen shared_workspace_query = ( sa.select( - *[ - col - for col in projects.columns - if col.name not in ["access_rights"] - ], + *BASE_PROJECT_SELECT_ARGS, workspace_access_rights_subquery.c.access_rights, projects_to_products.c.product_name, projects_to_folders.c.folder_id, @@ -513,6 +506,7 @@ async def list_projects( # pylint: disable=too-many-arguments,too-many-statemen isouter=True, ) .join(project_tags_subquery, isouter=True) + .outerjoin(users, projects.c.trashed_by == users.c.id) ) .where( ( @@ -685,9 +679,13 @@ async def get_project( async def get_project_db(self, project_uuid: ProjectID) -> ProjectDB: async with self.engine.acquire() as conn: result = await conn.execute( - sa.select(*_SELECTION_PROJECT_DB_ARGS).where( - projects.c.uuid == f"{project_uuid}" + sa.select( + *BASE_PROJECT_SELECT_ARGS, + ) + .select_from( + projects.outerjoin(users, projects.c.trashed_by == users.c.id) ) + .where(projects.c.uuid == f"{project_uuid}") ) row = await result.fetchone() if row is None: @@ -699,7 +697,10 @@ async def get_user_specific_project_data_db( ) -> UserSpecificProjectDataDB: async with self.engine.acquire() as conn: result = await conn.execute( - sa.select(*_SELECTION_PROJECT_DB_ARGS, projects_to_folders.c.folder_id) + sa.select( + *PROJECT_WITHOUT_WORKBENCH_COLS, + projects_to_folders.c.folder_id, + ) .select_from( projects.join( projects_to_folders, diff --git a/services/web/server/src/simcore_service_webserver/projects/models.py b/services/web/server/src/simcore_service_webserver/projects/models.py index 8354bdff549..8b76827a1d6 100644 --- a/services/web/server/src/simcore_service_webserver/projects/models.py +++ b/services/web/server/src/simcore_service_webserver/projects/models.py @@ -6,6 +6,7 @@ from common_library.dict_tools import remap_keys from models_library.api_schemas_webserver.projects import ProjectPatch from models_library.folders import FolderID +from models_library.groups import GroupID from models_library.projects import ClassifierID, ProjectID from models_library.projects_ui import StudyUI from models_library.users import UserID @@ -15,7 +16,7 @@ ) from models_library.workspaces import WorkspaceID from pydantic import BaseModel, ConfigDict, HttpUrl, field_validator -from simcore_postgres_database.models.projects import ProjectType, projects +from simcore_postgres_database.models.projects import ProjectType ProjectDict: TypeAlias = dict[str, Any] ProjectProxy: TypeAlias = RowProxy @@ -53,6 +54,8 @@ class ProjectDB(BaseModel): hidden: bool workspace_id: WorkspaceID | None trashed: datetime | None + trashed_by: UserID | None + trashed_by_primary_gid: GroupID | None = None trashed_explicitly: bool = False model_config = ConfigDict(from_attributes=True, arbitrary_types_allowed=True) @@ -72,11 +75,6 @@ class UserSpecificProjectDataDB(ProjectDB): model_config = ConfigDict(from_attributes=True) -assert set(ProjectDB.model_fields.keys()).issubset( # nosec - {c.name for c in projects.columns if c.name not in ["access_rights"]} -) - - class UserProjectAccessRightsDB(BaseModel): uid: UserID read: bool @@ -94,9 +92,10 @@ class UserProjectAccessRightsWithWorkspace(BaseModel): model_config = ConfigDict(from_attributes=True) -class ProjectPatchExtended(ProjectPatch): +class ProjectPatchInternalExtended(ProjectPatch): # ONLY used internally trashed_at: datetime | None + trashed_by: UserID | None trashed_explicitly: bool model_config = ConfigDict(populate_by_name=True, extra="forbid") diff --git a/services/web/server/src/simcore_service_webserver/projects/projects_api.py b/services/web/server/src/simcore_service_webserver/projects/projects_api.py index fa46afffed2..d0eb2c7feb8 100644 --- a/services/web/server/src/simcore_service_webserver/projects/projects_api.py +++ b/services/web/server/src/simcore_service_webserver/projects/projects_api.py @@ -121,14 +121,13 @@ from ..wallets import api as wallets_api from ..wallets.errors import WalletNotEnoughCreditsError from ..workspaces import _workspaces_repository as workspaces_db -from . import _crud_api_delete, _nodes_api, _projects_db +from . import _crud_api_delete, _nodes_api, _projects_db, _wallets_api from ._access_rights_api import ( check_user_project_permission, has_user_project_access_rights, ) from ._db_utils import PermissionStr from ._nodes_utils import set_reservation_same_as_limit, validate_new_service_resources -from ._wallets_api import connect_wallet_to_project, get_project_wallet from .db import APP_PROJECT_DBAPI, ProjectDBAPI from .exceptions import ( ClustersKeeperNotAvailableError, @@ -146,7 +145,7 @@ ProjectTooManyProjectOpenedError, ) from .lock import get_project_locked_state, is_project_locked, lock_project -from .models import ProjectDict, ProjectPatchExtended +from .models import ProjectDict, ProjectPatchInternalExtended from .settings import ProjectsSettings, get_plugin_settings from .utils import extract_dns_without_default_port @@ -252,7 +251,7 @@ async def patch_project( *, user_id: UserID, project_uuid: ProjectID, - project_patch: ProjectPatch | ProjectPatchExtended, + project_patch: ProjectPatch | ProjectPatchInternalExtended, product_name: ProductName, ): patch_project_data = project_patch.to_domain_model() @@ -623,7 +622,7 @@ async def _() -> None: and app_settings.WEBSERVER_CREDIT_COMPUTATION_ENABLED ): # Deal with Wallet - project_wallet = await get_project_wallet( + project_wallet = await _wallets_api.get_project_wallet( request.app, project_id=project_uuid ) if project_wallet is None: @@ -638,7 +637,7 @@ async def _() -> None: project_wallet_id = TypeAdapter(WalletID).validate_python( user_default_wallet_preference.value ) - await connect_wallet_to_project( + await _wallets_api.connect_wallet_to_project( request.app, product_name=product_name, project_id=project_uuid, diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_trash_services.py b/services/web/server/src/simcore_service_webserver/workspaces/_trash_services.py index 59f88f7ad90..611f3fb56e9 100644 --- a/services/web/server/src/simcore_service_webserver/workspaces/_trash_services.py +++ b/services/web/server/src/simcore_service_webserver/workspaces/_trash_services.py @@ -12,8 +12,7 @@ from ..db.plugin import get_asyncpg_engine from ..folders._trash_service import trash_folder, untrash_folder from ..projects._trash_service import trash_project, untrash_project -from ._workspaces_repository import update_workspace -from ._workspaces_service import check_user_workspace_access +from . import _workspaces_repository, _workspaces_service _logger = logging.getLogger(__name__) @@ -25,7 +24,7 @@ async def _check_exists_and_access( user_id: UserID, workspace_id: WorkspaceID, ): - await check_user_workspace_access( + await _workspaces_service.check_user_workspace_access( app=app, user_id=user_id, workspace_id=workspace_id, @@ -50,7 +49,7 @@ async def trash_workspace( async with transaction_context(get_asyncpg_engine(app)) as connection: # EXPLICIT trash - await update_workspace( + await _workspaces_repository.update_workspace( app, connection, product_name=product_name, @@ -59,10 +58,9 @@ async def trash_workspace( ) # IMPLICIT trash - child_folders: list[FolderID] = ( - [] + child_folders: list[FolderID] = [ # NOTE: follows up with https://github.com/ITISFoundation/osparc-simcore/issues/7034 - ) + ] for folder_id in child_folders: await trash_folder( @@ -73,10 +71,9 @@ async def trash_workspace( force_stop_first=force_stop_first, ) - child_projects: list[ProjectID] = ( - [] + child_projects: list[ProjectID] = [ # NOTE: follows up with https://github.com/ITISFoundation/osparc-simcore/issues/7034 - ) + ] for project_id in child_projects: await trash_project( @@ -102,7 +99,7 @@ async def untrash_workspace( async with transaction_context(get_asyncpg_engine(app)) as connection: # EXPLICIT UNtrash - await update_workspace( + await _workspaces_repository.update_workspace( app, connection, product_name=product_name, @@ -110,10 +107,9 @@ async def untrash_workspace( updates=WorkspaceUpdates(trashed=None, trashed_by=None), ) - child_folders: list[FolderID] = ( - [] + child_folders: list[FolderID] = [ # NOTE: follows up with https://github.com/ITISFoundation/osparc-simcore/issues/7034 - ) + ] for folder_id in child_folders: await untrash_folder( @@ -123,10 +119,9 @@ async def untrash_workspace( folder_id=folder_id, ) - child_projects: list[ProjectID] = ( - [] + child_projects: list[ProjectID] = [ # NOTE: follows up with https://github.com/ITISFoundation/osparc-simcore/issues/7034 - ) + ] for project_id in child_projects: await untrash_project( diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_repository.py b/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_repository.py index 63907713bcc..3a9690a3195 100644 --- a/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_repository.py +++ b/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_repository.py @@ -19,6 +19,7 @@ WorkspaceUpdates, ) from pydantic import NonNegativeInt +from simcore_postgres_database.models.users import users from simcore_postgres_database.models.workspaces import workspaces from simcore_postgres_database.models.workspaces_access_rights import ( workspaces_access_rights, @@ -40,7 +41,7 @@ _logger = logging.getLogger(__name__) -_SELECTION_ARGS = ( +_WORKSPACE_SELECTION_COLS = ( workspaces.c.workspace_id, workspaces.c.name, workspaces.c.description, @@ -52,11 +53,6 @@ workspaces.c.trashed_by, ) -assert set(Workspace.model_fields) == {c.name for c in _SELECTION_ARGS} # nosec -assert set(WorkspaceUpdates.model_fields).issubset( # nosec - c.name for c in workspaces.columns -) - async def create_workspace( app: web.Application, @@ -80,32 +76,54 @@ async def create_workspace( modified=func.now(), product_name=product_name, ) - .returning(*_SELECTION_ARGS) + .returning(*_WORKSPACE_SELECTION_COLS) ) row = await result.first() return Workspace.model_validate(row) -_access_rights_subquery = ( - select( - workspaces_access_rights.c.workspace_id, - func.jsonb_object_agg( - workspaces_access_rights.c.gid, - func.jsonb_build_object( - "read", - workspaces_access_rights.c.read, - "write", - workspaces_access_rights.c.write, - "delete", - workspaces_access_rights.c.delete, - ), +def _create_base_select_query(caller_user_id: UserID, product_name: ProductName): + # any other access + access_rights_subquery = ( + select( + workspaces_access_rights.c.workspace_id, + func.jsonb_object_agg( + workspaces_access_rights.c.gid, + func.jsonb_build_object( + "read", + workspaces_access_rights.c.read, + "write", + workspaces_access_rights.c.write, + "delete", + workspaces_access_rights.c.delete, + ), + ) + .filter( + workspaces_access_rights.c.read # Filters out entries where "read" is False + ) + .label("access_rights"), + ).group_by(workspaces_access_rights.c.workspace_id) + ).subquery("access_rights_subquery") + + # caller's access rights + my_access_rights_subquery = create_my_workspace_access_rights_subquery( + user_id=caller_user_id + ) + + return ( + select( + *_WORKSPACE_SELECTION_COLS, + access_rights_subquery.c.access_rights, + my_access_rights_subquery.c.my_access_rights, + users.c.primary_gid.label("trashed_by_primary_gid"), ) - .filter( - workspaces_access_rights.c.read # Filters out entries where "read" is False + .select_from( + workspaces.join(access_rights_subquery) + .join(my_access_rights_subquery) + .outerjoin(users, workspaces.c.trashed_by == users.c.id) ) - .label("access_rights"), - ).group_by(workspaces_access_rights.c.workspace_id) -).subquery("access_rights_subquery") + .where(workspaces.c.product_name == product_name) + ) async def list_workspaces_for_user( @@ -120,43 +138,34 @@ async def list_workspaces_for_user( limit: NonNegativeInt, order_by: OrderBy, ) -> tuple[int, list[UserWorkspaceWithAccessRights]]: - my_access_rights_subquery = create_my_workspace_access_rights_subquery( - user_id=user_id - ) - - base_query = ( - select( - *_SELECTION_ARGS, - _access_rights_subquery.c.access_rights, - my_access_rights_subquery.c.my_access_rights, - ) - .select_from( - workspaces.join(_access_rights_subquery).join(my_access_rights_subquery) - ) - .where(workspaces.c.product_name == product_name) + base_select_query = _create_base_select_query( + caller_user_id=user_id, product_name=product_name ) if filter_trashed is not None: - base_query = base_query.where( + base_select_query = base_select_query.where( workspaces.c.trashed.is_not(None) if filter_trashed else workspaces.c.trashed.is_(None) ) if filter_by_text is not None: - base_query = base_query.where( + base_select_query = base_select_query.where( (workspaces.c.name.ilike(f"%{filter_by_text}%")) | (workspaces.c.description.ilike(f"%{filter_by_text}%")) ) # Select total count from base_query - subquery = base_query.subquery() - count_query = select(func.count()).select_from(subquery) + count_query = select(func.count()).select_from(base_select_query.subquery()) # Ordering and pagination if order_by.direction == OrderDirection.ASC: - list_query = base_query.order_by(asc(getattr(workspaces.c, order_by.field))) + list_query = base_select_query.order_by( + asc(getattr(workspaces.c, order_by.field)) + ) else: - list_query = base_query.order_by(desc(getattr(workspaces.c, order_by.field))) + list_query = base_select_query.order_by( + desc(getattr(workspaces.c, order_by.field)) + ) list_query = list_query.offset(offset).limit(limit) async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: @@ -178,27 +187,12 @@ async def get_workspace_for_user( workspace_id: WorkspaceID, product_name: ProductName, ) -> UserWorkspaceWithAccessRights: - my_access_rights_subquery = create_my_workspace_access_rights_subquery( - user_id=user_id - ) - - base_query = ( - select( - *_SELECTION_ARGS, - _access_rights_subquery.c.access_rights, - my_access_rights_subquery.c.my_access_rights, - ) - .select_from( - workspaces.join(_access_rights_subquery).join(my_access_rights_subquery) - ) - .where( - (workspaces.c.workspace_id == workspace_id) - & (workspaces.c.product_name == product_name) - ) + select_query = _create_base_select_query( + caller_user_id=user_id, product_name=product_name ) async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: - result = await conn.stream(base_query) + result = await conn.stream(select_query) row = await result.first() if row is None: raise WorkspaceAccessForbiddenError( @@ -229,7 +223,7 @@ async def update_workspace( (workspaces.c.workspace_id == workspace_id) & (workspaces.c.product_name == product_name) ) - .returning(*_SELECTION_ARGS) + .returning(*_WORKSPACE_SELECTION_COLS) ) row = await result.first() if row is None: diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers__clone_in_workspace_and_folder.py b/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers__clone_in_workspace_and_folder.py index 8d302b4a336..7f69fad580b 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers__clone_in_workspace_and_folder.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers__clone_in_workspace_and_folder.py @@ -41,7 +41,7 @@ async def create_workspace_and_folder( product_name="osparc", ) - folder = await create_folder( + folder, _ = await create_folder( client.app, user_id=logged_user["id"], name="a", 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 6c38f65770d..39e649e4bd3 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 @@ -23,9 +23,10 @@ from pytest_simcore.helpers.assert_checks import assert_status from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict from pytest_simcore.helpers.typing_env import EnvVarsDict -from pytest_simcore.helpers.webserver_login import UserInfoDict +from pytest_simcore.helpers.webserver_login import NewUser, UserInfoDict from servicelib.aiohttp import status from simcore_service_webserver.db.models import UserRole +from simcore_service_webserver.projects._groups_api import ProjectGroupGet from simcore_service_webserver.projects.models import ProjectDict from yarl import URL @@ -74,7 +75,7 @@ async def test_trash_projects( # noqa: PLR0915 ): assert client.app - # this test should have no errors stopping services + # this test should emulate NO errors stopping services mock_remove_dynamic_services = mocker.patch( "simcore_service_webserver.projects._trash_service.projects_api.remove_project_dynamic_services", autospec=True, @@ -150,6 +151,7 @@ async def test_trash_projects( # noqa: PLR0915 assert got.trashed_at assert trashing_at < got.trashed_at assert got.trashed_at < arrow.utcnow().datetime + assert got.trashed_by == logged_user["primary_gid"] # LIST trashed resp = await client.get("/v0/projects", params={"filters": '{"trashed": true}'}) @@ -181,6 +183,103 @@ async def test_trash_projects( # noqa: PLR0915 mock_remove_dynamic_services.assert_awaited() +@pytest.fixture +async def other_user( + client: TestClient, logged_user: UserInfoDict +) -> AsyncIterable[UserInfoDict]: + # new user different from logged_user + async with NewUser( + { + "name": f"other_user_than_{logged_user['name']}", + "role": "USER", + }, + client.app, + ) as user: + yield user + + +async def test_trash_projects_shared_among_users( + client: TestClient, + logged_user: UserInfoDict, + user_project: ProjectDict, + other_user: UserInfoDict, + mocked_catalog: None, + mocked_dynamic_services_interface: dict[str, MagicMock], +): + assert client.app + + project_uuid = UUID(user_project["uuid"]) + + # GET project + url = client.app.router["get_project"].url_for(project_id=f"{project_uuid}") + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + + project = ProjectGet.model_validate(data) + assert project.uuid == project_uuid + assert project.prj_owner == logged_user["email"] + + # SHARE PROJECT with other-user + url = client.app.router["create_project_group"].url_for( + project_id=f"{project_uuid}", group_id=f"{other_user['primary_gid']}" + ) + resp = await client.post( + f"{url}", + json={"read": True, "write": True, "delete": False}, + ) + data, _ = await assert_status(resp, status.HTTP_201_CREATED) + + project_group = ProjectGroupGet.model_validate(data) + assert project_group.gid == other_user["primary_gid"] + assert project_group.read is True + assert project_group.write is True + assert project_group.delete is False + + # TRASH project + trashing_at = arrow.utcnow().datetime + resp = await client.post( + f"/v0/projects/{project_uuid}:trash", params={"force": "true"} + ) + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # LIST trashed of logged_user + resp = await client.get("/v0/projects", params={"filters": '{"trashed": true}'}) + await assert_status(resp, status.HTTP_200_OK) + + page = Page[ProjectListItem].model_validate(await resp.json()) + assert page.meta.total == 1 + assert page.data[0].uuid == project_uuid + assert page.data[0].trashed_at + assert trashing_at < page.data[0].trashed_at + assert page.data[0].trashed_by == logged_user["primary_gid"] + + # Swith USER: LOGOUT + url = client.app.router["auth_logout"].url_for() + resp = await client.post(f"{url}") + await assert_status(resp, status.HTTP_200_OK) + + url = client.app.router["auth_login"].url_for() + resp = await client.post( + f"{url}", + json={ + "email": other_user["email"], + "password": other_user["raw_password"], + }, + ) + data, _ = await assert_status(resp, status.HTTP_200_OK) + + # LIST trashed of another_user + resp = await client.get("/v0/projects", params={"filters": '{"trashed": true}'}) + await assert_status(resp, status.HTTP_200_OK) + + page = Page[ProjectListItem].model_validate(await resp.json()) + assert page.meta.total == 1 + assert page.data[0].uuid == project_uuid + assert page.data[0].trashed_at + assert trashing_at < page.data[0].trashed_at + assert page.data[0].trashed_by == logged_user["primary_gid"] + + @pytest.mark.acceptance_test( "For https://github.com/ITISFoundation/osparc-simcore/pull/6642" ) @@ -232,6 +331,8 @@ async def test_trash_single_folder(client: TestClient, logged_user: UserInfoDict assert got.trashed_at assert trashing_at < got.trashed_at assert got.trashed_at < arrow.utcnow().datetime + assert got.trashed_by == logged_user["primary_gid"] + assert got.owner == logged_user["primary_gid"] # LIST trashed resp = await client.get("/v0/folders", params={"filters": '{"trashed": true}'}) @@ -347,16 +448,19 @@ async def test_trash_folder_with_content( data, _ = await assert_status(resp, status.HTTP_200_OK) got = FolderGet.model_validate(data) assert got.trashed_at is not None + assert got.trashed_by == logged_user["primary_gid"] resp = await client.get(f"/v0/folders/{subfolder.folder_id}") data, _ = await assert_status(resp, status.HTTP_200_OK) got = FolderGet.model_validate(data) assert got.trashed_at is not None + assert got.trashed_by == logged_user["primary_gid"] resp = await client.get(f"/v0/projects/{project_uuid}") data, _ = await assert_status(resp, status.HTTP_200_OK) got = ProjectGet.model_validate(data) assert got.trashed_at is not None + assert got.trashed_by == logged_user["primary_gid"] # UNTRASH folder resp = await client.post(f"/v0/folders/{folder.folder_id}:untrash") @@ -409,12 +513,12 @@ async def workspace( # 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.model_validate(data) + wks = WorkspaceGet.model_validate(data) - yield workspace + yield wks # DELETE a workspace - resp = await client.delete(f"/v0/workspaces/{workspace.workspace_id}") + resp = await client.delete(f"/v0/workspaces/{wks.workspace_id}") data, _ = await assert_status(resp, status.HTTP_204_NO_CONTENT) @@ -449,7 +553,7 @@ async def test_trash_empty_workspace( _exclude_attrs = {"trashed_by", "trashed_at", "modified_at"} # TRASH - before_trash = arrow.utcnow().datetime + trashing_at = arrow.utcnow().datetime resp = await client.post(f"/v0/workspaces/{workspace.workspace_id}:trash") await assert_status(resp, status.HTTP_204_NO_CONTENT) @@ -470,8 +574,8 @@ async def test_trash_empty_workspace( 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"] + assert trashing_at < page.data[0].trashed_at + assert page.data[0].trashed_by == logged_user["primary_gid"] # --------