From a50d80c55eaf0f0a654dc79bce689f8846a97942 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 <60785969+matusdrobuliak66@users.noreply.github.com> Date: Fri, 27 Sep 2024 17:02:46 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=A8=20adding=20`folder=5Fid`=20to=20pr?= =?UTF-8?q?oject=20resource=20(#6460)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api_schemas_webserver/projects.py | 1 + .../src/models_library/projects.py | 6 +++ .../api/v0/openapi.yaml | 10 ++++ .../projects/_access_rights_api.py | 30 +++++++---- .../projects/_crud_api_create.py | 7 +++ .../projects/_crud_handlers.py | 12 ++++- .../projects/_db_utils.py | 1 + .../simcore_service_webserver/projects/db.py | 50 +++++++++++++++---- .../projects/models.py | 21 +++++++- .../projects/projects_api.py | 10 +++- services/web/server/tests/conftest.py | 1 + .../02/test_projects_crud_handlers.py | 8 +++ .../02/test_projects_states_handlers.py | 2 + .../unit/with_dbs/03/folders/test_folders.py | 6 +++ .../tests/unit/with_dbs/03/tags/test_tags.py | 1 + ...t_workspaces__folders_and_projects_crud.py | 5 ++ 16 files changed, 149 insertions(+), 22 deletions(-) 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 b644ac52d0b..2d8cd69ab93 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 @@ -84,6 +84,7 @@ class ProjectGet(OutputSchema): dev: dict | None permalink: ProjectPermalink = FieldNotRequired() workspace_id: WorkspaceID | None + folder_id: FolderID | None _empty_description = validator("description", allow_reuse=True, pre=True)( none_to_empty_str_pre_validator diff --git a/packages/models-library/src/models_library/projects.py b/packages/models-library/src/models_library/projects.py index 6c2036caa5a..6f62457272c 100644 --- a/packages/models-library/src/models_library/projects.py +++ b/packages/models-library/src/models_library/projects.py @@ -8,6 +8,7 @@ from typing import Any, Final, TypeAlias from uuid import UUID +from models_library.folders import FolderID from models_library.workspaces import WorkspaceID from pydantic import BaseModel, ConstrainedStr, Extra, Field, validator @@ -179,6 +180,11 @@ class Project(BaseProjectModel): description="To which workspace project belongs. If None, belongs to private user workspace.", alias="workspaceId", ) + folder_id: FolderID | None = Field( + default=None, + description="To which folder project belongs. If None, belongs to root folder.", + alias="folderId", + ) class Config: description = "Document that stores metadata, pipeline and UI setup of a study" 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 a75cbcaba19..8af414d41fa 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 @@ -10236,6 +10236,11 @@ components: exclusiveMinimum: true type: integer minimum: 0 + folderId: + title: Folderid + exclusiveMinimum: true + type: integer + minimum: 0 ProjectGroupGet: title: ProjectGroupGet required: @@ -10471,6 +10476,11 @@ components: exclusiveMinimum: true type: integer minimum: 0 + folderId: + title: Folderid + exclusiveMinimum: true + type: integer + minimum: 0 ProjectLocked: title: ProjectLocked required: diff --git a/services/web/server/src/simcore_service_webserver/projects/_access_rights_api.py b/services/web/server/src/simcore_service_webserver/projects/_access_rights_api.py index 1c7919a4985..805b46fa65e 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_access_rights_api.py +++ b/services/web/server/src/simcore_service_webserver/projects/_access_rights_api.py @@ -9,7 +9,7 @@ from ._access_rights_db import get_project_owner from .db import APP_PROJECT_DBAPI, ProjectDBAPI from .exceptions import ProjectInvalidRightsError, ProjectNotFoundError -from .models import UserProjectAccessRights +from .models import UserProjectAccessRightsWithWorkspace async def validate_project_ownership( @@ -31,7 +31,7 @@ async def get_user_project_access_rights( project_id: ProjectID, user_id: UserID, product_name: ProductName, -) -> UserProjectAccessRights: +) -> UserProjectAccessRightsWithWorkspace: """ This function resolves user access rights on the project resource. @@ -51,11 +51,14 @@ async def get_user_project_access_rights( workspace_id=project_db.workspace_id, product_name=product_name, ) - _user_project_access_rights = UserProjectAccessRights( - uid=user_id, - read=workspace.my_access_rights.read, - write=workspace.my_access_rights.write, - delete=workspace.my_access_rights.delete, + _user_project_access_rights_with_workspace = ( + UserProjectAccessRightsWithWorkspace( + uid=user_id, + workspace_id=project_db.workspace_id, + read=workspace.my_access_rights.read, + write=workspace.my_access_rights.write, + delete=workspace.my_access_rights.delete, + ) ) else: _user_project_access_rights = ( @@ -63,7 +66,16 @@ async def get_user_project_access_rights( user_id, project_id ) ) - return _user_project_access_rights + _user_project_access_rights_with_workspace = ( + UserProjectAccessRightsWithWorkspace( + uid=user_id, + workspace_id=None, + read=_user_project_access_rights.read, + write=_user_project_access_rights.write, + delete=_user_project_access_rights.delete, + ) + ) + return _user_project_access_rights_with_workspace async def has_user_project_access_rights( @@ -92,7 +104,7 @@ async def check_user_project_permission( user_id: UserID, product_name: ProductName, permission: PermissionStr = "read", -) -> UserProjectAccessRights: +) -> UserProjectAccessRightsWithWorkspace: _user_project_access_rights = await get_user_project_access_rights( app, project_id=project_id, user_id=user_id, product_name=product_name ) 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 c8c9a2100d4..37416912e15 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 @@ -398,6 +398,13 @@ async def create_project( # pylint: disable=too-many-arguments,too-many-branche # Adds permalink await update_or_pop_permalink_in_project(request, new_project) + # Adds folderId + user_specific_project_data_db = await db.get_user_specific_project_data_db( + project_uuid=new_project["uuid"], + private_workspace_user_id_or_none=user_id if workspace_id is None else None, + ) + new_project["folderId"] = user_specific_project_data_db.folder_id + # Overwrite project access rights if workspace_id: workspace_db: UserWorkspaceAccessRightsDB = ( diff --git a/services/web/server/src/simcore_service_webserver/projects/_crud_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_crud_handlers.py index 9f138ebbb97..969658a0581 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_crud_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_crud_handlers.py @@ -444,7 +444,7 @@ async def replace_project(request: web.Request): reason=f"Project {path_params.project_id} cannot be modified while pipeline is still running." ) - await check_user_project_permission( + user_project_permission = await check_user_project_permission( request.app, project_id=path_params.project_id, user_id=req_ctx.user_id, @@ -483,6 +483,16 @@ async def replace_project(request: web.Request): is_template=False, app=request.app, ) + # Appends folder ID + user_specific_project_data_db = await db.get_user_specific_project_data_db( + project_uuid=path_params.project_id, + private_workspace_user_id_or_none=( + req_ctx.user_id + if user_project_permission.workspace_id is None + else None + ), + ) + data["folderId"] = user_specific_project_data_db.folder_id return web.json_response({"data": data}, dumps=json_dumps) 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 42c62e8d6a2..aa0f6ce8048 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 @@ -64,6 +64,7 @@ def convert_to_db_names(project_document_data: dict) -> dict: exclude_keys = [ "tags", "prjOwner", + "folderId", ] # 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: 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 68b305b61cd..7fa0bc000df 100644 --- a/services/web/server/src/simcore_service_webserver/projects/db.py +++ b/services/web/server/src/simcore_service_webserver/projects/db.py @@ -87,7 +87,12 @@ ProjectNodeResourcesInsufficientRightsError, ProjectNotFoundError, ) -from .models import ProjectDB, ProjectDict, UserProjectAccessRights +from .models import ( + ProjectDB, + ProjectDict, + UserProjectAccessRightsDB, + UserSpecificProjectDataDB, +) _logger = logging.getLogger(__name__) @@ -398,6 +403,7 @@ async def list_projects( # pylint: disable=too-many-arguments ], access_rights_subquery.c.access_rights, projects_to_products.c.product_name, + projects_to_folders.c.folder_id, ) .select_from(_join_query) .where( @@ -496,13 +502,9 @@ async def get_project( only_published: bool = False, only_templates: bool = False, ) -> tuple[ProjectDict, ProjectType]: - """Returns all projects *owned* by the user - - - prj_owner - - Notice that a user can have access to a template but he might not own it - - Notice that a user can have access to a project where he/she has read access - - :raises ProjectNotFoundError: project is not assigned to user + """ + This is a legacy function that retrieves the project resource along with additional adjustments. + The `get_project_db` function is now recommended for use when interacting with the projects DB layer. """ async with self.engine.acquire() as conn: project = await self._get_project( @@ -553,9 +555,37 @@ async def get_project_db(self, project_uuid: ProjectID) -> ProjectDB: raise ProjectNotFoundError(project_uuid=project_uuid) return ProjectDB.from_orm(row) + async def get_user_specific_project_data_db( + self, project_uuid: ProjectID, private_workspace_user_id_or_none: UserID | None + ) -> UserSpecificProjectDataDB: + async with self.engine.acquire() as conn: + result = await conn.execute( + sa.select( + *self._SELECTION_PROJECT_DB_ARGS, projects_to_folders.c.folder_id + ) + .select_from( + projects.join( + projects_to_folders, + ( + (projects_to_folders.c.project_uuid == projects.c.uuid) + & ( + projects_to_folders.c.user_id + == private_workspace_user_id_or_none + ) + ), + isouter=True, + ) + ) + .where(projects.c.uuid == f"{project_uuid}") + ) + row = await result.fetchone() + if row is None: + raise ProjectNotFoundError(project_uuid=project_uuid) + return UserSpecificProjectDataDB.from_orm(row) + async def get_pure_project_access_rights_without_workspace( self, user_id: UserID, project_uuid: ProjectID - ) -> UserProjectAccessRights: + ) -> UserProjectAccessRightsDB: """ Be careful what you want. You should use `get_user_project_access_rights` to get access rights on the project. It depends on which context you are in, whether private or shared workspace. @@ -597,7 +627,7 @@ async def get_pure_project_access_rights_without_workspace( raise ProjectInvalidRightsError( user_id=user_id, project_uuid=project_uuid ) - return UserProjectAccessRights.from_orm(row) + return UserProjectAccessRightsDB.from_orm(row) async def replace_project( self, 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 c124c9547fc..8f4a13c172b 100644 --- a/services/web/server/src/simcore_service_webserver/projects/models.py +++ b/services/web/server/src/simcore_service_webserver/projects/models.py @@ -4,6 +4,7 @@ from aiopg.sa.result import RowProxy from models_library.basic_types import HttpUrlWithCustomMinLength +from models_library.folders import FolderID from models_library.projects import ClassifierID, ProjectID from models_library.projects_ui import StudyUI from models_library.users import UserID @@ -63,13 +64,31 @@ class Config: ) +class UserSpecificProjectDataDB(ProjectDB): + folder_id: FolderID | None + + class Config: + orm_mode = True + + assert set(ProjectDB.__fields__.keys()).issubset( # nosec {c.name for c in projects.columns if c.name not in ["access_rights"]} ) -class UserProjectAccessRights(BaseModel): +class UserProjectAccessRightsDB(BaseModel): + uid: UserID + read: bool + write: bool + delete: bool + + class Config: + orm_mode = True + + +class UserProjectAccessRightsWithWorkspace(BaseModel): uid: UserID + workspace_id: WorkspaceID | None # None if it's a private workspace read: bool write: bool delete: bool 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 fc130436caf..8a5e69c39ce 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 @@ -180,18 +180,26 @@ async def get_project_for_user( db = ProjectDBAPI.get_from_app_context(app) product_name = await db.get_project_product(ProjectID(project_uuid)) - await check_user_project_permission( + user_project_access = await check_user_project_permission( app, project_id=ProjectID(project_uuid), user_id=user_id, product_name=product_name, permission=cast(PermissionStr, check_permissions), ) + workspace_is_private = user_project_access.workspace_id is None project, project_type = await db.get_project( project_uuid, ) + # add folder id to the project base on the user + user_specific_project_data_db = await db.get_user_specific_project_data_db( + project_uuid=ProjectID(project_uuid), + private_workspace_user_id_or_none=user_id if workspace_is_private else None, + ) + project["folderId"] = user_specific_project_data_db.folder_id + # adds state if it is not a template if include_state: project = await add_project_states_for_user( diff --git a/services/web/server/tests/conftest.py b/services/web/server/tests/conftest.py index 3f47a75afc2..a57dfd3c852 100644 --- a/services/web/server/tests/conftest.py +++ b/services/web/server/tests/conftest.py @@ -423,6 +423,7 @@ async def _creator( # dynamic "state", "permalink", + "folderId", ] for key in new_project: diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py b/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py index 52ecb20038a..ec856a80f05 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py @@ -168,6 +168,7 @@ async def _assert_get_same_project( # Optional fields are not part of reference 'project' project_state = data.pop("state") project_permalink = data.pop("permalink", None) + folder_id = data.pop("folderId", None) assert data == project @@ -177,6 +178,8 @@ async def _assert_get_same_project( if project_permalink: assert parse_obj_as(ProjectPermalink, project_permalink) + assert folder_id is None + async def _replace_project( client: TestClient, project_update: dict, expected: HTTPStatus @@ -222,6 +225,7 @@ async def test_list_projects( # template project project_state = data[0].pop("state") project_permalink = data[0].pop("permalink") + folder_id = data[0].pop("folderId") assert data[0] == template_project assert not ProjectState( @@ -232,10 +236,12 @@ async def test_list_projects( # standard project project_state = data[1].pop("state") project_permalink = data[1].pop("permalink", None) + folder_id = data[1].pop("folderId") assert data[1] == user_project assert ProjectState(**project_state) assert project_permalink is None + assert folder_id is None # GET /v0/projects?type=user data, *_ = await _list_and_assert_projects(client, expected, {"type": "user"}) @@ -245,6 +251,7 @@ async def test_list_projects( # standad project project_state = data[0].pop("state") project_permalink = data[0].pop("permalink", None) + folder_id = data[0].pop("folderId") assert data[0] == user_project assert not ProjectState( @@ -261,6 +268,7 @@ async def test_list_projects( # template project project_state = data[0].pop("state") project_permalink = data[0].pop("permalink") + folder_id = data[0].pop("folderId") assert data[0] == template_project assert not ProjectState( diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_states_handlers.py b/services/web/server/tests/unit/with_dbs/02/test_projects_states_handlers.py index 910d94f06c0..d81dbbdb352 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_states_handlers.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_states_handlers.py @@ -920,6 +920,7 @@ async def test_get_active_project( ) assert not error assert ProjectState(**data.pop("state")).locked.value + data.pop("folderId") user_project_last_change_date = user_project.pop("lastChangeDate") data_last_change_date = data.pop("lastChangeDate") @@ -1416,6 +1417,7 @@ async def test_open_shared_project_at_same_time( num_assertions += 1 elif data: project_status = ProjectState(**data.pop("state")) + data.pop("folderId") assert data == shared_project assert project_status.locked.value assert project_status.locked.owner diff --git a/services/web/server/tests/unit/with_dbs/03/folders/test_folders.py b/services/web/server/tests/unit/with_dbs/03/folders/test_folders.py index f919d125119..7643afa7367 100644 --- a/services/web/server/tests/unit/with_dbs/03/folders/test_folders.py +++ b/services/web/server/tests/unit/with_dbs/03/folders/test_folders.py @@ -302,6 +302,8 @@ async def test_project_listing_inside_of_private_folder( data, _ = await assert_status(resp, status.HTTP_200_OK) assert len(data) == 1 assert data[0]["uuid"] == user_project["uuid"] + assert data[0]["workspaceId"] is None + assert data[0]["folderId"] == original_user_folder["folderId"] # Create new user async with LoggedUser(client) as new_logged_user: @@ -332,6 +334,8 @@ async def test_project_listing_inside_of_private_folder( data, _ = await assert_status(resp, status.HTTP_200_OK) assert len(data) == 1 assert data[0]["uuid"] == user_project["uuid"] + assert data[0]["workspaceId"] is None + assert data[0]["folderId"] is None # create a new folder url = client.app.router["create_folder"].url_for() @@ -353,6 +357,8 @@ async def test_project_listing_inside_of_private_folder( data, _ = await assert_status(resp, status.HTTP_200_OK) assert len(data) == 1 assert data[0]["uuid"] == user_project["uuid"] + assert data[0]["workspaceId"] is None + assert data[0]["folderId"] == new_user_folder["folderId"] @pytest.fixture diff --git a/services/web/server/tests/unit/with_dbs/03/tags/test_tags.py b/services/web/server/tests/unit/with_dbs/03/tags/test_tags.py index 71012571736..5e480b2777f 100644 --- a/services/web/server/tests/unit/with_dbs/03/tags/test_tags.py +++ b/services/web/server/tests/unit/with_dbs/03/tags/test_tags.py @@ -86,6 +86,7 @@ async def test_tags_to_studies( ), exclude_unset=True, ) + user_project["folderId"] = None data = await assert_get_same_project(client, user_project, expected) # Delete tag0 diff --git a/services/web/server/tests/unit/with_dbs/03/workspaces/test_workspaces__folders_and_projects_crud.py b/services/web/server/tests/unit/with_dbs/03/workspaces/test_workspaces__folders_and_projects_crud.py index 9467d7e2274..44b015bec0d 100644 --- a/services/web/server/tests/unit/with_dbs/03/workspaces/test_workspaces__folders_and_projects_crud.py +++ b/services/web/server/tests/unit/with_dbs/03/workspaces/test_workspaces__folders_and_projects_crud.py @@ -83,12 +83,16 @@ async def test_workspaces_full_workflow_with_folders_and_projects( 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]["folderId"] is None # Get project in workspace base_url = client.app.router["get_project"].url_for(project_id=project["uuid"]) resp = await client.get(base_url) data, _ = await assert_status(resp, status.HTTP_200_OK) assert data["uuid"] == project["uuid"] + assert data["workspaceId"] == added_workspace["workspaceId"] + assert data["folderId"] is None # Create folder in workspace url = client.app.router["create_folder"].url_for() @@ -131,6 +135,7 @@ async def test_workspaces_full_workflow_with_folders_and_projects( data, _ = await assert_status(resp, status.HTTP_200_OK) assert len(data) == 1 assert data[0]["uuid"] == project["uuid"] + assert data[0]["folderId"] is first_folder["folderId"] # Create new user async with LoggedUser(client) as new_logged_user: