diff --git a/api/specs/web-server/_folders.py b/api/specs/web-server/_folders.py index 88a2b19ce9e..2aa77e485d4 100644 --- a/api/specs/web-server/_folders.py +++ b/api/specs/web-server/_folders.py @@ -25,6 +25,9 @@ FoldersListQueryParams, FoldersPathParams, ) +from simcore_service_webserver.folders._workspaces_handlers import ( + _FolderWorkspacesPathParams, +) router = APIRouter( prefix=f"/{API_VTAG}", @@ -97,3 +100,15 @@ async def delete_folder( _path: Annotated[FoldersPathParams, Depends()], ): ... + + +@router.post( + "/folders/{folder_id}/workspaces/{workspace_id}:move", + status_code=status.HTTP_204_NO_CONTENT, + summary="Move folder to the workspace", + tags=["workspaces"], +) +async def move_folder_to_workspace( + _path: Annotated[_FolderWorkspacesPathParams, Depends()], +): + ... diff --git a/api/specs/web-server/_projects_workspaces.py b/api/specs/web-server/_projects_workspaces.py index 533d3c72a9b..caaccfca05c 100644 --- a/api/specs/web-server/_projects_workspaces.py +++ b/api/specs/web-server/_projects_workspaces.py @@ -23,12 +23,12 @@ ) -@router.put( - "/projects/{project_id}/workspaces/{workspace_id}", +@router.post( + "/projects/{project_id}/workspaces/{workspace_id}:move", status_code=status.HTTP_204_NO_CONTENT, summary="Move project to the workspace", ) -async def replace_project_workspace( +async def move_project_to_workspace( _path: Annotated[_ProjectWorkspacesPathParams, Depends()], ): ... 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 7c4116a136c..a918ece3b92 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,8 +9,16 @@ from typing import Annotated, Any, Literal, TypeAlias 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, Field, HttpUrl, field_validator +from pydantic import ( + BeforeValidator, + ConfigDict, + Field, + HttpUrl, + PlainSerializer, + field_validator, +) from ..api_schemas_long_running_tasks.tasks import TaskGet from ..basic_types import LongTruncatedStr, ShortTruncatedStr @@ -130,12 +138,22 @@ class ProjectPatch(InputSchema): name: ShortTruncatedStr | None = Field(default=None) description: LongTruncatedStr | None = Field(default=None) thumbnail: Annotated[ - HttpUrl | None, BeforeValidator(empty_str_to_none_pre_validator) + HttpUrl | None, + BeforeValidator(empty_str_to_none_pre_validator), + PlainSerializer(lambda x: str(x) if x is not None else None), ] = Field(default=None) access_rights: dict[GroupIDStr, AccessRights] | None = Field(default=None) classifiers: list[ClassifierID] | None = Field(default=None) dev: dict | None = Field(default=None) - ui: StudyUI | None = Field(default=None) + ui: Annotated[ + StudyUI | None, + BeforeValidator(empty_str_to_none_pre_validator), + PlainSerializer( + lambda obj: jsonable_encoder( + obj, exclude_unset=True, by_alias=False + ) # For the sake of backward compatibility + ), + ] = Field(default=None) quality: dict[str, Any] | None = Field(default=None) diff --git a/packages/pytest-simcore/src/pytest_simcore/helpers/webserver_projects.py b/packages/pytest-simcore/src/pytest_simcore/helpers/webserver_projects.py index 55065daaf76..092ab82d655 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/webserver_projects.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/webserver_projects.py @@ -95,7 +95,7 @@ async def create_project( for group_id, permissions in _access_rights.items(): await update_or_insert_project_group( app, - new_project["uuid"], + project_id=new_project["uuid"], group_id=int(group_id), read=permissions["read"], write=permissions["write"], diff --git a/services/director-v2/src/simcore_service_director_v2/modules/db/repositories/comp_tasks/_utils.py b/services/director-v2/src/simcore_service_director_v2/modules/db/repositories/comp_tasks/_utils.py index c5fd0819fcd..dd52f50ac82 100644 --- a/services/director-v2/src/simcore_service_director_v2/modules/db/repositories/comp_tasks/_utils.py +++ b/services/director-v2/src/simcore_service_director_v2/modules/db/repositories/comp_tasks/_utils.py @@ -6,6 +6,7 @@ import aiopg.sa import arrow from dask_task_models_library.container_tasks.protocol import ContainerEnvsDict +from models_library.api_schemas_catalog.services import ServiceGet from models_library.api_schemas_clusters_keeper.ec2_instances import EC2InstanceTypeGet from models_library.api_schemas_directorv2.services import ( NodeRequirements, @@ -89,7 +90,7 @@ async def _get_service_details( node.version, product_name, ) - obj: ServiceMetaDataPublished = ServiceMetaDataPublished(**service_details) + obj: ServiceMetaDataPublished = ServiceGet(**service_details) return obj diff --git a/services/director-v2/tests/unit/with_dbs/comp_scheduler/test_api_route_computations.py b/services/director-v2/tests/unit/with_dbs/comp_scheduler/test_api_route_computations.py index 4381c9311d4..6b6084c5895 100644 --- a/services/director-v2/tests/unit/with_dbs/comp_scheduler/test_api_route_computations.py +++ b/services/director-v2/tests/unit/with_dbs/comp_scheduler/test_api_route_computations.py @@ -186,15 +186,29 @@ def _mocked_service_resources(request) -> httpx.Response: def _mocked_services_details( request, service_key: str, service_version: str ) -> httpx.Response: + assert "json_schema_extra" in ServiceGet.model_config + assert isinstance(ServiceGet.model_config["json_schema_extra"], dict) + assert isinstance( + ServiceGet.model_config["json_schema_extra"]["examples"], list + ) + assert isinstance( + ServiceGet.model_config["json_schema_extra"]["examples"][0], dict + ) + data_published = fake_service_details.model_copy( + update={ + "key": urllib.parse.unquote(service_key), + "version": service_version, + } + ).model_dump(by_alias=True) + data = { + **ServiceGet.model_config["json_schema_extra"]["examples"][0], + **data_published, + } + payload = ServiceGet.model_validate(data) return httpx.Response( 200, json=jsonable_encoder( - fake_service_details.model_copy( - update={ - "key": urllib.parse.unquote(service_key), - "version": service_version, - } - ), + payload, by_alias=True, ), ) diff --git a/services/static-webserver/client/source/class/osparc/dashboard/StudyBrowser.js b/services/static-webserver/client/source/class/osparc/dashboard/StudyBrowser.js index c7ef8f916f2..2eecb230400 100644 --- a/services/static-webserver/client/source/class/osparc/dashboard/StudyBrowser.js +++ b/services/static-webserver/client/source/class/osparc/dashboard/StudyBrowser.js @@ -578,11 +578,6 @@ qx.Class.define("osparc.dashboard.StudyBrowser", { const data = e.getData(); const destWorkspaceId = data["workspaceId"]; const destFolderId = data["folderId"]; - if (destWorkspaceId !== currentWorkspaceId) { - const msg = this.tr("Moving folders to Shared Workspaces are coming soon"); - osparc.FlashMessenger.getInstance().logAs(msg, "WARNING"); - return; - } const moveFolder = () => { Promise.all([ this.__moveFolderToWorkspace(folderId, destWorkspaceId), diff --git a/services/static-webserver/client/source/class/osparc/data/Resources.js b/services/static-webserver/client/source/class/osparc/data/Resources.js index d87f6c690bf..f8b38797c58 100644 --- a/services/static-webserver/client/source/class/osparc/data/Resources.js +++ b/services/static-webserver/client/source/class/osparc/data/Resources.js @@ -288,8 +288,8 @@ qx.Class.define("osparc.data.Resources", { url: statics.API + "/projects/{studyId}/folders/{folderId}" }, moveToWorkspace: { - method: "PUT", - url: statics.API + "/projects/{studyId}/workspaces/{workspaceId}" + method: "POST", + url: statics.API + "/projects/{studyId}/workspaces/{workspaceId}:move" }, } }, @@ -342,8 +342,8 @@ qx.Class.define("osparc.data.Resources", { url: statics.API + "/folders/{folderId}" }, moveToWorkspace: { - method: "PUT", - url: statics.API + "/folders/{folderId}/folders/{workspaceId}" + method: "POST", + url: statics.API + "/folders/{folderId}/folders/{workspaceId}:move" }, trash: { method: "POST", 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 9cca4bafd06..84951101670 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 @@ -2944,6 +2944,59 @@ paths: schema: $ref: '#/components/schemas/EnvelopedError' description: Service Unavailable + /v0/folders/{folder_id}/workspaces/{workspace_id}:move: + post: + tags: + - folders + - workspaces + summary: Move folder to the workspace + operationId: move_folder_to_workspace + parameters: + - name: folder_id + in: path + required: true + schema: + type: integer + exclusiveMinimum: true + title: Folder Id + minimum: 0 + - name: workspace_id + in: path + required: true + schema: + anyOf: + - type: integer + exclusiveMinimum: true + minimum: 0 + - type: 'null' + title: Workspace Id + responses: + '204': + description: Successful Response + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Not Found + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Forbidden + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Conflict + '503': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Service Unavailable /v0/tasks: get: tags: @@ -4706,13 +4759,13 @@ paths: application/json: schema: $ref: '#/components/schemas/Envelope_WalletGet_' - /v0/projects/{project_id}/workspaces/{workspace_id}: - put: + /v0/projects/{project_id}/workspaces/{workspace_id}:move: + post: tags: - projects - workspaces summary: Move project to the workspace - operationId: replace_project_workspace + operationId: move_project_to_workspace parameters: - name: project_id in: path diff --git a/services/web/server/src/simcore_service_webserver/folders/_folders_db.py b/services/web/server/src/simcore_service_webserver/folders/_folders_db.py index 7e3a54d0bb5..88bb3987de4 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_folders_db.py +++ b/services/web/server/src/simcore_service_webserver/folders/_folders_db.py @@ -6,7 +6,7 @@ import logging from datetime import datetime -from typing import Any, Final, cast +from typing import Final, cast import sqlalchemy as sa from aiohttp import web @@ -33,6 +33,7 @@ from simcore_postgres_database.utils_workspaces_sql import ( create_my_workspace_access_rights_subquery, ) +from simcore_service_webserver.utils import UnSet, as_dict_exclude_unset from sqlalchemy import func from sqlalchemy.ext.asyncio import AsyncConnection from sqlalchemy.orm import aliased @@ -43,18 +44,9 @@ _logger = logging.getLogger(__name__) - -class UnSet: - ... - - _unset: Final = UnSet() -def as_dict_exclude_unset(**params) -> dict[str, Any]: - return {k: v for k, v in params.items() if not isinstance(v, UnSet)} - - _SELECTION_ARGS = ( folders_v2.c.folder_id, folders_v2.c.name, @@ -324,6 +316,8 @@ async def update( parent_folder_id: FolderID | None | UnSet = _unset, trashed_at: datetime | None | UnSet = _unset, trashed_explicitly: bool | UnSet = _unset, + workspace_id: WorkspaceID | None | UnSet = _unset, + user_id: UserID | None | UnSet = _unset, ) -> FolderDB: """ Batch/single patch of folder/s @@ -334,6 +328,8 @@ async def update( parent_folder_id=parent_folder_id, trashed_at=trashed_at, trashed_explicitly=trashed_explicitly, + workspace_id=workspace_id, + user_id=user_id, ) query = ( @@ -467,6 +463,60 @@ async def get_projects_recursively_only_if_user_is_owner( return [ProjectID(row[0]) async for row in result] +async def get_all_folders_and_projects_ids_recursively( + app: web.Application, + connection: AsyncConnection | None = None, + *, + folder_id: FolderID, + private_workspace_user_id_or_none: UserID | None, + product_name: ProductName, +) -> tuple[list[FolderID], list[ProjectID]]: + """ + The purpose of this function is to retrieve all projects within the provided folder ID. + """ + + async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: + + # Step 1: Define the base case for the recursive CTE + base_query = select( + folders_v2.c.folder_id, folders_v2.c.parent_folder_id + ).where( + (folders_v2.c.folder_id == folder_id) # <-- specified folder id + & (folders_v2.c.product_name == product_name) + ) + folder_hierarchy_cte = base_query.cte(name="folder_hierarchy", recursive=True) + + # Step 2: Define the recursive case + folder_alias = aliased(folders_v2) + recursive_query = select( + folder_alias.c.folder_id, folder_alias.c.parent_folder_id + ).select_from( + folder_alias.join( + folder_hierarchy_cte, + folder_alias.c.parent_folder_id == folder_hierarchy_cte.c.folder_id, + ) + ) + + # Step 3: Combine base and recursive cases into a CTE + folder_hierarchy_cte = folder_hierarchy_cte.union_all(recursive_query) + + # Step 4: Execute the query to get all descendants + final_query = select(folder_hierarchy_cte) + result = await conn.stream(final_query) + # list of tuples [(folder_id, parent_folder_id), ...] ex. [(1, None), (2, 1)] + folder_ids = [item.folder_id async for item in result] + + query = select(projects_to_folders.c.project_uuid).where( + (projects_to_folders.c.folder_id.in_(folder_ids)) + & (projects_to_folders.c.user_id == private_workspace_user_id_or_none) + ) + + result = await conn.stream(query) + project_ids = [ProjectID(row.project_uuid) async for row in result] + + return folder_ids, project_ids + + async def get_folders_recursively( app: web.Application, connection: AsyncConnection | None = None, 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 9cac8a2f1a1..553d43bd64c 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_models.py +++ b/services/web/server/src/simcore_service_webserver/folders/_models.py @@ -18,10 +18,9 @@ null_or_none_str_to_none_validator, ) from models_library.workspaces import WorkspaceID -from pydantic import BeforeValidator, ConfigDict, Field -from servicelib.request_keys import RQT_USERID_KEY +from pydantic import BaseModel, BeforeValidator, ConfigDict, Field -from .._constants import RQ_PRODUCT_KEY +from .._constants import RQ_PRODUCT_KEY, RQT_USERID_KEY _logger = logging.getLogger(__name__) @@ -88,3 +87,12 @@ class FolderSearchQueryParams( class FolderTrashQueryParams(RemoveQueryParams): ... + + +class _FolderWorkspacesPathParams(BaseModel): + folder_id: FolderID + workspace_id: Annotated[ + WorkspaceID | None, BeforeValidator(null_or_none_str_to_none_validator) + ] = Field(default=None) + + model_config = ConfigDict(extra="forbid") diff --git a/services/web/server/src/simcore_service_webserver/folders/_workspaces_api.py b/services/web/server/src/simcore_service_webserver/folders/_workspaces_api.py new file mode 100644 index 00000000000..115ff2c8d8e --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/folders/_workspaces_api.py @@ -0,0 +1,138 @@ +import logging + +from aiohttp import web +from models_library.folders import FolderID +from models_library.products import ProductName +from models_library.users import UserID +from models_library.workspaces import WorkspaceID +from simcore_postgres_database.utils_repos import transaction_context + +from ..db.plugin import get_asyncpg_engine +from ..projects import _folders_db as project_to_folders_db +from ..projects import _groups_db as project_groups_db +from ..projects import _projects_db as projects_db +from ..projects._access_rights_api import check_user_project_permission +from ..users.api import get_user +from ..workspaces.api import check_user_workspace_access +from . import _folders_db + +_logger = logging.getLogger(__name__) + + +async def move_folder_into_workspace( + app: web.Application, + *, + user_id: UserID, + folder_id: FolderID, + workspace_id: WorkspaceID | None, + product_name: ProductName, +) -> None: + # 1. User needs to have delete permission on source folder + folder_db = await _folders_db.get( + app, folder_id=folder_id, product_name=product_name + ) + workspace_is_private = True + if folder_db.workspace_id: + await check_user_workspace_access( + app, + user_id=user_id, + workspace_id=folder_db.workspace_id, + product_name=product_name, + permission="delete", + ) + workspace_is_private = False + + # 2. User needs to have write permission on destination workspace + if workspace_id is not None: + await check_user_workspace_access( + app, + user_id=user_id, + workspace_id=workspace_id, + product_name=product_name, + permission="write", + ) + + # 3. User needs to have delete permission on all the projects inside source folder + ( + folder_ids, + project_ids, + ) = await _folders_db.get_all_folders_and_projects_ids_recursively( + app, + connection=None, + folder_id=folder_id, + private_workspace_user_id_or_none=user_id if workspace_is_private else None, + product_name=product_name, + ) + # NOTE: Not the most effective, can be improved + for project_id in project_ids: + await check_user_project_permission( + app, + project_id=project_id, + user_id=user_id, + product_name=product_name, + permission="delete", + ) + + # ⬆️ Here we have already guaranties that user has all the right permissions to do this operation ⬆️ + + async with transaction_context(get_asyncpg_engine(app)) as conn: + # 4. Update workspace ID on the project resource + for project_id in project_ids: + await projects_db.patch_project( + app=app, + connection=conn, + project_uuid=project_id, + new_partial_project_data={"workspace_id": workspace_id}, + ) + + # 5. BATCH update of folders with workspace_id + await _folders_db.update( + app, + connection=conn, + folders_id_or_ids=set(folder_ids), + product_name=product_name, + workspace_id=workspace_id, # <-- Updating workspace_id + user_id=user_id if workspace_id is None else None, # <-- Updating user_id + ) + + # 6. Update source folder parent folder ID with NULL (it will appear in the root directory) + await _folders_db.update( + app, + connection=conn, + folders_id_or_ids=folder_id, + product_name=product_name, + parent_folder_id=None, # <-- Updating parent folder ID + ) + + # 7. Remove all records of project to folders that are not in the folders that we are moving + # (ex. If we are moving from private workspace, the same project can be in different folders for different users) + await project_to_folders_db.delete_all_project_to_folder_by_project_ids_not_in_folder_ids( + app, + connection=conn, + project_id_or_ids=set(project_ids), + not_in_folder_ids=set(folder_ids), + ) + + # 8. Update the user id field for the remaining folders + await project_to_folders_db.update_project_to_folder( + app, + connection=conn, + folders_id_or_ids=set(folder_ids), + user_id=user_id if workspace_id is None else None, # <-- Updating user_id + ) + + # 9. Remove all project permissions, leave only the user who moved the project + user = await get_user(app, user_id=user_id) + for project_id in project_ids: + await project_groups_db.delete_all_project_groups( + app, connection=conn, project_id=project_id + ) + await project_groups_db.update_or_insert_project_group( + app, + connection=conn, + project_id=project_id, + group_id=user["primary_gid"], + read=True, + write=True, + delete=True, + ) diff --git a/services/web/server/src/simcore_service_webserver/folders/_workspaces_handlers.py b/services/web/server/src/simcore_service_webserver/folders/_workspaces_handlers.py new file mode 100644 index 00000000000..faa505ecd31 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/folders/_workspaces_handlers.py @@ -0,0 +1,38 @@ +import logging + +from aiohttp import web +from servicelib.aiohttp import status +from servicelib.aiohttp.requests_validation import parse_request_path_parameters_as + +from .._meta import api_version_prefix as VTAG +from ..login.decorators import login_required +from ..security.decorators import permission_required +from . import _workspaces_api +from ._exceptions_handlers import handle_plugin_requests_exceptions +from ._models import FoldersRequestContext, _FolderWorkspacesPathParams + +_logger = logging.getLogger(__name__) + + +routes = web.RouteTableDef() + + +@routes.post( + f"/{VTAG}/folders/{{folder_id}}/workspaces/{{workspace_id}}:move", + name="move_folder_to_workspace", +) +@login_required +@permission_required("folder.update") +@handle_plugin_requests_exceptions +async def move_folder_to_workspace(request: web.Request): + req_ctx = FoldersRequestContext.model_validate(request) + path_params = parse_request_path_parameters_as(_FolderWorkspacesPathParams, request) + + await _workspaces_api.move_folder_into_workspace( + app=request.app, + user_id=req_ctx.user_id, + folder_id=path_params.folder_id, + workspace_id=path_params.workspace_id, + product_name=req_ctx.product_name, + ) + return web.json_response(status=status.HTTP_204_NO_CONTENT) diff --git a/services/web/server/src/simcore_service_webserver/folders/plugin.py b/services/web/server/src/simcore_service_webserver/folders/plugin.py index 8ddef03ec1f..2601962e52f 100644 --- a/services/web/server/src/simcore_service_webserver/folders/plugin.py +++ b/services/web/server/src/simcore_service_webserver/folders/plugin.py @@ -7,7 +7,7 @@ from servicelib.aiohttp.application_keys import APP_SETTINGS_KEY from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup -from . import _folders_handlers, _trash_handlers +from . import _folders_handlers, _trash_handlers, _workspaces_handlers _logger = logging.getLogger(__name__) @@ -25,3 +25,4 @@ def setup_folders(app: web.Application): # routes app.router.add_routes(_folders_handlers.routes) app.router.add_routes(_trash_handlers.routes) + app.router.add_routes(_workspaces_handlers.routes) diff --git a/services/web/server/src/simcore_service_webserver/projects/_folders_db.py b/services/web/server/src/simcore_service_webserver/projects/_folders_db.py index 59ea8ebe282..e655cc17bf5 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_folders_db.py +++ b/services/web/server/src/simcore_service_webserver/projects/_folders_db.py @@ -6,6 +6,7 @@ import logging from datetime import datetime +from typing import Final from aiohttp import web from models_library.folders import FolderID @@ -13,15 +14,17 @@ from models_library.users import UserID from pydantic import BaseModel from simcore_postgres_database.models.projects_to_folders import projects_to_folders +from simcore_postgres_database.utils_repos import transaction_context +from simcore_service_webserver.utils import UnSet, as_dict_exclude_unset from sqlalchemy import func, literal_column +from sqlalchemy.ext.asyncio import AsyncConnection from sqlalchemy.sql import select -from ..db.plugin import get_database_engine +from ..db.plugin import get_asyncpg_engine, get_database_engine _logger = logging.getLogger(__name__) - -_logger = logging.getLogger(__name__) +_unset: Final = UnSet() ### Models @@ -100,13 +103,79 @@ async def delete_project_to_folder( ) +### AsyncPg + + async def delete_all_project_to_folder_by_project_id( app: web.Application, + connection: AsyncConnection | None = None, + *, project_id: ProjectID, ) -> None: - async with get_database_engine(app).acquire() as conn: - await conn.execute( + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + await conn.stream( projects_to_folders.delete().where( projects_to_folders.c.project_uuid == f"{project_id}" ) ) + + +async def update_project_to_folder( + app: web.Application, + connection: AsyncConnection | None = None, + *, + folders_id_or_ids: FolderID | set[FolderID], + # updatable columns + user_id: UserID | None | UnSet = _unset, +) -> None: + """ + Batch/single patch of project to folders + """ + # NOTE: exclude unset can also be done using a pydantic model and dict(exclude_unset=True) + updated = as_dict_exclude_unset( + user_id=user_id, + ) + + query = projects_to_folders.update().values(modified=func.now(), **updated) + + if isinstance(folders_id_or_ids, set): + # batch-update + query = query.where( + projects_to_folders.c.folder_id.in_(list(folders_id_or_ids)) + ) + else: + # single-update + query = query.where(projects_to_folders.c.folder_id == folders_id_or_ids) + + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + await conn.stream(query) + + +async def delete_all_project_to_folder_by_project_ids_not_in_folder_ids( + app: web.Application, + connection: AsyncConnection | None = None, + *, + project_id_or_ids: ProjectID | set[ProjectID], + not_in_folder_ids: set[FolderID], +) -> None: + query = projects_to_folders.delete() + + if isinstance(project_id_or_ids, set): + # batch-delete + query = query.where( + projects_to_folders.c.project_uuid.in_( + [f"{project_id}" for project_id in project_id_or_ids] + ) + ) + else: + # single-delete + query = query.where( + projects_to_folders.c.project_uuid == f"{project_id_or_ids}" + ) + + query = query.where( + projects_to_folders.c.folder_id.not_in(not_in_folder_ids) # <-- NOT IN! + ) + + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + await conn.stream(query) diff --git a/services/web/server/src/simcore_service_webserver/projects/_groups_api.py b/services/web/server/src/simcore_service_webserver/projects/_groups_api.py index 7ae45f0f90c..b32a6d15fa1 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_groups_api.py +++ b/services/web/server/src/simcore_service_webserver/projects/_groups_api.py @@ -80,7 +80,8 @@ async def list_project_groups_by_user_and_project( ] = await projects_groups_db.list_project_groups(app=app, project_id=project_id) project_groups_api: list[ProjectGroupGet] = [ - ProjectGroupGet.model_validate(group.model_dump()) for group in project_groups_db + ProjectGroupGet.model_validate(group.model_dump()) + for group in project_groups_db ] return project_groups_api diff --git a/services/web/server/src/simcore_service_webserver/projects/_groups_db.py b/services/web/server/src/simcore_service_webserver/projects/_groups_db.py index 5b963b90cdb..4355f0c9d92 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_groups_db.py +++ b/services/web/server/src/simcore_service_webserver/projects/_groups_db.py @@ -3,19 +3,22 @@ - Adds a layer to the postgres API with a focus on the projects comments """ + import logging from datetime import datetime from aiohttp import web from models_library.projects import ProjectID from models_library.users import GroupID -from pydantic import BaseModel, TypeAdapter +from pydantic import BaseModel, ConfigDict, TypeAdapter from simcore_postgres_database.models.project_to_groups import project_to_groups +from simcore_postgres_database.utils_repos import transaction_context from sqlalchemy import func, literal_column from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.ext.asyncio import AsyncConnection from sqlalchemy.sql import select -from ..db.plugin import get_database_engine +from ..db.plugin import get_asyncpg_engine from .exceptions import ProjectGroupNotFoundError _logger = logging.getLogger(__name__) @@ -31,39 +34,46 @@ class ProjectGroupGetDB(BaseModel): created: datetime modified: datetime + model_config = ConfigDict(from_attributes=True) + ## DB API async def create_project_group( app: web.Application, + connection: AsyncConnection | None = None, + *, project_id: ProjectID, group_id: GroupID, - *, read: bool, write: bool, delete: bool, ) -> ProjectGroupGetDB: - async with get_database_engine(app).acquire() as conn: - result = await conn.execute( - project_to_groups.insert() - .values( - project_uuid=f"{project_id}", - gid=group_id, - read=read, - write=write, - delete=delete, - created=func.now(), - modified=func.now(), - ) - .returning(literal_column("*")) + query = ( + project_to_groups.insert() + .values( + project_uuid=f"{project_id}", + gid=group_id, + read=read, + write=write, + delete=delete, + created=func.now(), + modified=func.now(), ) + .returning(literal_column("*")) + ) + + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + result = await conn.stream(query) row = await result.first() return ProjectGroupGetDB.model_validate(row) async def list_project_groups( app: web.Application, + connection: AsyncConnection | None = None, + *, project_id: ProjectID, ) -> list[ProjectGroupGetDB]: stmt = ( @@ -79,14 +89,16 @@ async def list_project_groups( .where(project_to_groups.c.project_uuid == f"{project_id}") ) - async with get_database_engine(app).acquire() as conn: - result = await conn.execute(stmt) - rows = await result.fetchall() or [] + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + result = await conn.stream(stmt) + rows = await result.all() or [] return TypeAdapter(list[ProjectGroupGetDB]).validate_python(rows) async def get_project_group( app: web.Application, + connection: AsyncConnection | None = None, + *, project_id: ProjectID, group_id: GroupID, ) -> ProjectGroupGetDB: @@ -106,8 +118,8 @@ async def get_project_group( ) ) - async with get_database_engine(app).acquire() as conn: - result = await conn.execute(stmt) + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + result = await conn.stream(stmt) row = await result.first() if row is None: raise ProjectGroupNotFoundError( @@ -118,27 +130,31 @@ async def get_project_group( async def replace_project_group( app: web.Application, + connection: AsyncConnection | None = None, + *, project_id: ProjectID, group_id: GroupID, - *, read: bool, write: bool, delete: bool, ) -> ProjectGroupGetDB: - async with get_database_engine(app).acquire() as conn: - result = await conn.execute( - project_to_groups.update() - .values( - read=read, - write=write, - delete=delete, - ) - .where( - (project_to_groups.c.project_uuid == f"{project_id}") - & (project_to_groups.c.gid == group_id) - ) - .returning(literal_column("*")) + + query = ( + project_to_groups.update() + .values( + read=read, + write=write, + delete=delete, + ) + .where( + (project_to_groups.c.project_uuid == f"{project_id}") + & (project_to_groups.c.gid == group_id) ) + .returning(literal_column("*")) + ) + + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + result = await conn.stream(query) row = await result.first() if row is None: raise ProjectGroupNotFoundError( @@ -149,14 +165,15 @@ async def replace_project_group( async def update_or_insert_project_group( app: web.Application, + connection: AsyncConnection | None = None, + *, project_id: ProjectID, group_id: GroupID, - *, read: bool, write: bool, delete: bool, ) -> None: - async with get_database_engine(app).acquire() as conn: + async with transaction_context(get_asyncpg_engine(app), connection) as conn: insert_stmt = pg_insert(project_to_groups).values( project_uuid=f"{project_id}", gid=group_id, @@ -175,16 +192,18 @@ async def update_or_insert_project_group( "modified": func.now(), }, ) - await conn.execute(on_update_stmt) + await conn.stream(on_update_stmt) async def delete_project_group( app: web.Application, + connection: AsyncConnection | None = None, + *, project_id: ProjectID, group_id: GroupID, ) -> None: - async with get_database_engine(app).acquire() as conn: - await conn.execute( + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + await conn.stream( project_to_groups.delete().where( (project_to_groups.c.project_uuid == f"{project_id}") & (project_to_groups.c.gid == group_id) @@ -194,10 +213,12 @@ async def delete_project_group( async def delete_all_project_groups( app: web.Application, + connection: AsyncConnection | None = None, + *, project_id: ProjectID, ) -> None: - async with get_database_engine(app).acquire() as conn: - await conn.execute( + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + await conn.stream( project_to_groups.delete().where( project_to_groups.c.project_uuid == f"{project_id}" ) 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 new file mode 100644 index 00000000000..3c94e9e7cdc --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/projects/_projects_db.py @@ -0,0 +1,59 @@ +import logging + +import sqlalchemy as sa +from aiohttp import web +from models_library.projects import ProjectID +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 +from .exceptions import ProjectNotFoundError +from .models import ProjectDB + +_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 + projects.c.id, + projects.c.type, + projects.c.uuid, + projects.c.name, + projects.c.description, + projects.c.thumbnail, + projects.c.prj_owner, + projects.c.creation_date, + projects.c.last_change_date, + projects.c.ui, + projects.c.classifiers, + projects.c.dev, + projects.c.quality, + projects.c.published, + projects.c.hidden, + projects.c.workspace_id, + projects.c.trashed_at, +] + + +async def patch_project( + app: web.Application, + connection: AsyncConnection | None = None, + *, + project_uuid: ProjectID, + new_partial_project_data: dict, +) -> ProjectDB: + + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + result = await conn.stream( + 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) + ) + row = await result.first() + if row is None: + raise ProjectNotFoundError(project_uuid=project_uuid) + return ProjectDB.model_validate(row) diff --git a/services/web/server/src/simcore_service_webserver/projects/_workspaces_api.py b/services/web/server/src/simcore_service_webserver/projects/_workspaces_api.py index 105decdd3ac..1462168fa52 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_workspaces_api.py +++ b/services/web/server/src/simcore_service_webserver/projects/_workspaces_api.py @@ -5,13 +5,15 @@ from models_library.projects import ProjectID from models_library.users import UserID from models_library.workspaces import WorkspaceID +from simcore_postgres_database.utils_repos import transaction_context +from ..db.plugin import get_asyncpg_engine from ..projects._access_rights_api import get_user_project_access_rights from ..users.api import get_user from ..workspaces.api import check_user_workspace_access from . import _folders_db as project_to_folders_db from . import _groups_db as project_groups_db -from .db import APP_PROJECT_DBAPI, ProjectDBAPI +from . import _projects_db from .exceptions import ProjectInvalidRightsError _logger = logging.getLogger(__name__) @@ -25,8 +27,6 @@ async def move_project_into_workspace( workspace_id: WorkspaceID | None, product_name: ProductName, ) -> None: - project_api: ProjectDBAPI = app[APP_PROJECT_DBAPI] - # 1. User needs to have delete permission on project project_access_rights = await get_user_project_access_rights( app, project_id=project_id, user_id=user_id, product_name=product_name @@ -44,26 +44,33 @@ async def move_project_into_workspace( permission="write", ) - # 3. Delete project to folders (for everybody) - await project_to_folders_db.delete_all_project_to_folder_by_project_id( - app, - project_id=project_id, - ) + async with transaction_context(get_asyncpg_engine(app)) as conn: + # 3. Delete project to folders (for everybody) + await project_to_folders_db.delete_all_project_to_folder_by_project_id( + app, + connection=conn, + project_id=project_id, + ) - # 4. Update workspace ID on the project resource - await project_api.patch_project( - project_uuid=project_id, - new_partial_project_data={"workspace_id": workspace_id}, - ) + # 4. Update workspace ID on the project resource + await _projects_db.patch_project( + app=app, + connection=conn, + project_uuid=project_id, + new_partial_project_data={"workspace_id": workspace_id}, + ) - # 5. Remove all project permissions, leave only the user who moved the project - user = await get_user(app, user_id=user_id) - await project_groups_db.delete_all_project_groups(app, project_id=project_id) - await project_groups_db.update_or_insert_project_group( - app, - project_id=project_id, - group_id=user["primary_gid"], - read=True, - write=True, - delete=True, - ) + # 5. Remove all project permissions, leave only the user who moved the project + user = await get_user(app, user_id=user_id) + await project_groups_db.delete_all_project_groups( + app, connection=conn, project_id=project_id + ) + await project_groups_db.update_or_insert_project_group( + app, + connection=conn, + project_id=project_id, + group_id=user["primary_gid"], + read=True, + write=True, + delete=True, + ) diff --git a/services/web/server/src/simcore_service_webserver/projects/_workspaces_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_workspaces_handlers.py index ff881b418af..ef3d20b3c5a 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_workspaces_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_workspaces_handlers.py @@ -51,19 +51,21 @@ async def wrapper(request: web.Request) -> web.StreamResponse: class _ProjectWorkspacesPathParams(BaseModel): project_id: ProjectID - workspace_id: Annotated[WorkspaceID | None, BeforeValidator(null_or_none_str_to_none_validator)] = Field(default=None) + workspace_id: Annotated[ + WorkspaceID | None, BeforeValidator(null_or_none_str_to_none_validator) + ] = Field(default=None) model_config = ConfigDict(extra="forbid") -@routes.put( - f"/{VTAG}/projects/{{project_id}}/workspaces/{{workspace_id}}", - name="replace_project_workspace", +@routes.post( + f"/{VTAG}/projects/{{project_id}}/workspaces/{{workspace_id}}:move", + name="move_project_to_workspace", ) @login_required @permission_required("project.workspaces.*") @_handle_projects_workspaces_exceptions -async def replace_project_workspace(request: web.Request): +async def move_project_to_workspace(request: web.Request): req_ctx = RequestContext.model_validate(request) path_params = parse_request_path_parameters_as( _ProjectWorkspacesPathParams, request diff --git a/services/web/server/src/simcore_service_webserver/projects/db.py b/services/web/server/src/simcore_service_webserver/projects/db.py index cdaed691e71..b0fc7c5551a 100644 --- a/services/web/server/src/simcore_service_webserver/projects/db.py +++ b/services/web/server/src/simcore_service_webserver/projects/db.py @@ -85,6 +85,7 @@ patch_workbench, update_workbench, ) +from ._projects_db import _SELECTION_PROJECT_DB_ARGS from .exceptions import ( ProjectDeleteError, ProjectInvalidRightsError, @@ -676,33 +677,10 @@ async def get_project( project_type, ) - # 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 - projects.c.id, - projects.c.type, - projects.c.uuid, - projects.c.name, - projects.c.description, - projects.c.thumbnail, - projects.c.prj_owner, - projects.c.creation_date, - projects.c.last_change_date, - projects.c.ui, - projects.c.classifiers, - projects.c.dev, - projects.c.quality, - projects.c.published, - projects.c.hidden, - projects.c.workspace_id, - projects.c.trashed_at, - ] - async def get_project_db(self, project_uuid: ProjectID) -> ProjectDB: async with self.engine.acquire() as conn: result = await conn.execute( - sa.select(*self._SELECTION_PROJECT_DB_ARGS).where( + sa.select(*_SELECTION_PROJECT_DB_ARGS).where( projects.c.uuid == f"{project_uuid}" ) ) @@ -716,9 +694,7 @@ async def get_user_specific_project_data_db( ) -> 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 - ) + sa.select(*_SELECTION_PROJECT_DB_ARGS, projects_to_folders.c.folder_id) .select_from( projects.join( projects_to_folders, @@ -865,21 +841,6 @@ async def replace_project( msg = "linter unhappy without this" raise RuntimeError(msg) - async def patch_project( - self, project_uuid: ProjectID, new_partial_project_data: dict - ) -> ProjectDB: - async with self.engine.acquire() as conn: - result = await conn.execute( - projects.update() - .values(last_change_date=sa.func.now(), **new_partial_project_data) - .where(projects.c.uuid == f"{project_uuid}") - .returning(*self._SELECTION_PROJECT_DB_ARGS) - ) - row = await result.fetchone() - if row is None: - raise ProjectNotFoundError(project_uuid=project_uuid) - return ProjectDB.model_validate(row) - async def get_project_product(self, project_uuid: ProjectID) -> ProductName: async with self.engine.acquire() as conn: result = await conn.execute( 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 6876c63718d..cf9445985c6 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 @@ -120,7 +120,7 @@ from ..wallets import api as wallets_api from ..wallets.errors import WalletNotEnoughCreditsError from ..workspaces import _workspaces_db as workspaces_db -from . import _crud_api_delete, _nodes_api +from . import _crud_api_delete, _nodes_api, _projects_db from ._access_rights_api import ( check_user_project_permission, has_user_project_access_rights, @@ -253,8 +253,8 @@ async def patch_project( project_patch: ProjectPatch | ProjectPatchExtended, product_name: ProductName, ): - _project_patch_exclude_unset: dict[str, Any] = jsonable_encoder( - project_patch, exclude_unset=True, by_alias=False + _project_patch_exclude_unset = project_patch.model_dump( + exclude_unset=True, by_alias=False ) db: ProjectDBAPI = app[APP_PROJECT_DBAPI] @@ -289,7 +289,8 @@ async def patch_project( raise ProjectOwnerNotFoundInTheProjectAccessRightsError # 4. Patch the project - await db.patch_project( + await _projects_db.patch_project( + app=app, project_uuid=project_uuid, new_partial_project_data=_project_patch_exclude_unset, ) diff --git a/services/web/server/src/simcore_service_webserver/utils.py b/services/web/server/src/simcore_service_webserver/utils.py index c6eade6345d..1f73ac06e0a 100644 --- a/services/web/server/src/simcore_service_webserver/utils.py +++ b/services/web/server/src/simcore_service_webserver/utils.py @@ -194,3 +194,17 @@ def compute_sha1_on_small_dataset(d: Any) -> SHA1Str: # SEE options in https://github.com/ijl/orjson#option data_bytes = orjson.dumps(d, option=orjson.OPT_NON_STR_KEYS | orjson.OPT_SORT_KEYS) return SHA1Str(hashlib.sha1(data_bytes).hexdigest()) # nosec # NOSONAR + + +# ----------------------------------------------- +# +# UNSET +# + + +class UnSet: + ... + + +def as_dict_exclude_unset(**params) -> dict[str, Any]: + return {k: v for k, v in params.items() if not isinstance(v, UnSet)} diff --git a/services/web/server/tests/conftest.py b/services/web/server/tests/conftest.py index 7085050f331..f215368ad1d 100644 --- a/services/web/server/tests/conftest.py +++ b/services/web/server/tests/conftest.py @@ -358,7 +358,7 @@ async def _creator( for group_id, permissions in _access_rights.items(): await update_or_insert_project_group( client.app, - data["uuid"], + project_id=data["uuid"], group_id=int(group_id), read=permissions["read"], write=permissions["write"], diff --git a/services/web/server/tests/integration/01/test_garbage_collection.py b/services/web/server/tests/integration/01/test_garbage_collection.py index c52977d7115..d3aee60764d 100644 --- a/services/web/server/tests/integration/01/test_garbage_collection.py +++ b/services/web/server/tests/integration/01/test_garbage_collection.py @@ -237,7 +237,7 @@ async def new_project( for group_id, permissions in access_rights.items(): await update_or_insert_project_group( client.app, - project["uuid"], + project_id=project["uuid"], group_id=int(group_id), read=permissions["read"], write=permissions["write"], diff --git a/services/web/server/tests/unit/with_dbs/03/test_project_db.py b/services/web/server/tests/unit/with_dbs/03/test_project_db.py index fadfe561267..1d73a0e88c4 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_project_db.py +++ b/services/web/server/tests/unit/with_dbs/03/test_project_db.py @@ -201,7 +201,7 @@ async def _inserter(prj: dict[str, Any], **overrides) -> dict[str, Any]: for group_id, permissions in _access_rights.items(): await update_or_insert_project_group( client.app, - new_project["uuid"], + project_id=new_project["uuid"], group_id=int(group_id), read=permissions["read"], write=permissions["write"], diff --git a/services/web/server/tests/unit/with_dbs/04/workspaces/conftest.py b/services/web/server/tests/unit/with_dbs/04/workspaces/conftest.py index 744b30da23b..fa008269aaf 100644 --- a/services/web/server/tests/unit/with_dbs/04/workspaces/conftest.py +++ b/services/web/server/tests/unit/with_dbs/04/workspaces/conftest.py @@ -5,6 +5,7 @@ import pytest import sqlalchemy as sa +from simcore_postgres_database.models.projects import projects from simcore_postgres_database.models.workspaces import workspaces @@ -13,3 +14,4 @@ def workspaces_clean_db(postgres_db: sa.engine.Engine) -> Iterator[None]: with postgres_db.connect() as con: yield con.execute(workspaces.delete()) + con.execute(projects.delete()) diff --git a/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__moving_folders_between_workspaces.py b/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__moving_folders_between_workspaces.py new file mode 100644 index 00000000000..ea7105a3338 --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__moving_folders_between_workspaces.py @@ -0,0 +1,274 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments +# pylint: disable=too-many-statements + + +from copy import deepcopy +from http.client import NO_CONTENT + +import pytest +from aiohttp.test_utils import TestClient +from pytest_mock import MockerFixture +from pytest_simcore.helpers.assert_checks import assert_status +from pytest_simcore.helpers.webserver_login import UserInfoDict +from pytest_simcore.helpers.webserver_projects import create_project +from servicelib.aiohttp import status +from simcore_service_webserver.db.models import UserRole +from simcore_service_webserver.db.plugin import setup_db +from simcore_service_webserver.projects.models import ProjectDict + + +@pytest.fixture +def user_role() -> UserRole: + return UserRole.USER + + +@pytest.fixture +def mock_catalog_api_get_services_for_user_in_product(mocker: MockerFixture): + mocker.patch( + "simcore_service_webserver.projects._crud_api_read.get_services_for_user_in_product", + spec=True, + return_value=[], + ) + mocker.patch( + "simcore_service_webserver.projects._crud_handlers.get_services_for_user_in_product", + spec=True, + return_value=[], + ) + mocker.patch( + "simcore_service_webserver.projects._crud_handlers.project_uses_available_services", + spec=True, + return_value=True, + ) + + +@pytest.fixture +async def moving_folder_id( + client: TestClient, + logged_user: UserInfoDict, + fake_project: ProjectDict, +) -> str: + assert client.app + setup_db(client.app) + + ### Project creation + + # Create 2 projects + project_data = deepcopy(fake_project) + first_project = await create_project( + client.app, + params_override=project_data, + user_id=logged_user["id"], + product_name="osparc", + ) + second_project = await create_project( + client.app, + params_override=project_data, + user_id=logged_user["id"], + product_name="osparc", + ) + + ### Folder creation + + # Create folder + url = client.app.router["create_folder"].url_for() + resp = await client.post( + f"{url}", + json={ + "name": "Original user folder", + }, + ) + first_folder, _ = await assert_status(resp, status.HTTP_201_CREATED) + + # Create sub folder of previous folder + url = client.app.router["create_folder"].url_for() + resp = await client.post( + f"{url}", + json={ + "name": "Second user folder", + "parentFolderId": f"{first_folder['folderId']}", + }, + ) + second_folder, _ = await assert_status(resp, status.HTTP_201_CREATED) + + # Create sub sub folder of previous sub folder + url = client.app.router["create_folder"].url_for() + resp = await client.post( + f"{url}", + json={ + "name": "Third user folder", + "parentFolderId": f"{second_folder['folderId']}", + }, + ) + third_folder, _ = await assert_status(resp, status.HTTP_201_CREATED) + + ### Move projects to subfolder + # add first project to the folder + url = client.app.router["replace_project_folder"].url_for( + folder_id=f"{second_folder['folderId']}", project_id=f"{first_project['uuid']}" + ) + resp = await client.put(f"{url}") + await assert_status(resp, status.HTTP_204_NO_CONTENT) + # add second project to the folder + url = client.app.router["replace_project_folder"].url_for( + folder_id=f"{second_folder['folderId']}", project_id=f"{second_project['uuid']}" + ) + resp = await client.put(f"{url}") + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + ## Double check whether everything is setup OK + url = ( + client.app.router["list_projects"] + .url_for() + .with_query({"folder_id": f"{second_folder['folderId']}"}) + ) + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(data) == 2 + + url = ( + client.app.router["list_projects"] + .url_for() + .with_query({"folder_id": f"{first_folder['folderId']}"}) + ) + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(data) == 0 + + url = client.app.router["list_projects"].url_for().with_query({"folder_id": "null"}) + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(data) == 0 + + url = client.app.router["list_folders"].url_for().with_query({"folder_id": "null"}) + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(data) == 1 + + url = ( + client.app.router["list_folders"] + .url_for() + .with_query({"folder_id": f"{first_folder['folderId']}"}) + ) + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(data) == 1 + + return f"{second_folder['folderId']}" + + +async def _move_folder_to_workspace_and_assert( + client: TestClient, folder_id: str, workspace_id: str +): + assert client.app + + # MOVE + url = client.app.router["move_folder_to_workspace"].url_for( + folder_id=folder_id, + workspace_id=workspace_id, + ) + resp = await client.post(f"{url}") + await assert_status(resp, NO_CONTENT) + + # ASSERT + url = ( + client.app.router["list_projects"] + .url_for() + .with_query( + { + "folder_id": folder_id, + "workspace_id": workspace_id, + } + ) + ) + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(data) == 2 + + url = ( + client.app.router["list_folders"] + .url_for() + .with_query( + { + "folder_id": folder_id, + "workspace_id": workspace_id, + } + ) + ) + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(data) == 1 + + +async def test_moving_between_private_and_shared_workspaces( + client: TestClient, + logged_user: UserInfoDict, + mock_catalog_api_get_services_for_user_in_product: MockerFixture, + fake_project: ProjectDict, + moving_folder_id: str, + workspaces_clean_db: None, +): + assert client.app + + # We will test these scenarios of moving folders: + # 1. Private workspace -> Shared workspace + # 2. Shared workspace A -> Shared workspace B + # 3. Shared workspace A -> Shared workspace A (Corner case - This endpoint is not used like this) + # 4. Shared workspace -> Private workspace + # 5. Private workspace -> Private workspace (Corner case - This endpoint is not used like this) + + # create a new workspace + url = client.app.router["create_workspace"].url_for() + resp = await client.post( + f"{url}", + json={ + "name": "A", + "description": "A", + "thumbnail": None, + }, + ) + shared_workspace_A, _ = await assert_status(resp, status.HTTP_201_CREATED) + + # 1. Private workspace -> Shared workspace A + await _move_folder_to_workspace_and_assert( + client, + folder_id=moving_folder_id, + workspace_id=f"{shared_workspace_A['workspaceId']}", + ) + + # create a new workspace + url = client.app.router["create_workspace"].url_for() + resp = await client.post( + f"{url}", + json={ + "name": "B", + "description": "B", + "thumbnail": None, + }, + ) + shared_workspace_B, _ = await assert_status(resp, status.HTTP_201_CREATED) + # 2. Shared workspace A -> Shared workspace B + await _move_folder_to_workspace_and_assert( + client, + folder_id=moving_folder_id, + workspace_id=f"{shared_workspace_B['workspaceId']}", + ) + + # 3. (Corner case) Shared workspace B -> Shared workspace B + await _move_folder_to_workspace_and_assert( + client, + folder_id=moving_folder_id, + workspace_id=f"{shared_workspace_B['workspaceId']}", + ) + + # 4. Shared workspace -> Private workspace + await _move_folder_to_workspace_and_assert( + client, folder_id=moving_folder_id, workspace_id="null" + ) + + # 5. (Corner case) Private workspace -> Private workspace + await _move_folder_to_workspace_and_assert( + client, folder_id=moving_folder_id, workspace_id="null" + ) diff --git a/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__moving_projects_between_workspaces.py b/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__moving_projects_between_workspaces.py index 21b16ea9738..a81c76012a0 100644 --- a/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__moving_projects_between_workspaces.py +++ b/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__moving_projects_between_workspaces.py @@ -55,10 +55,10 @@ async def test_moving_between_workspaces_user_role_permissions( workspaces_clean_db: None, ): # Move project from workspace to your private workspace - base_url = client.app.router["replace_project_workspace"].url_for( + base_url = client.app.router["move_project_to_workspace"].url_for( project_id=fake_project["uuid"], workspace_id="null" ) - resp = await client.put(f"{base_url}") + resp = await client.post(f"{base_url}") await assert_status(resp, expected.no_content) @@ -103,10 +103,10 @@ async def test_moving_between_private_and_shared_workspaces( assert data["workspaceId"] == added_workspace["workspaceId"] # <-- Workspace ID # Move project from workspace to your private workspace - base_url = client.app.router["replace_project_workspace"].url_for( + base_url = client.app.router["move_project_to_workspace"].url_for( project_id=project["uuid"], workspace_id="null" ) - resp = await client.put(f"{base_url}") + resp = await client.post(f"{base_url}") await assert_status(resp, status.HTTP_204_NO_CONTENT) # Get project in workspace @@ -116,10 +116,10 @@ async def test_moving_between_private_and_shared_workspaces( assert data["workspaceId"] is None # <-- Workspace ID is None # Move project from your private workspace to shared workspace - base_url = client.app.router["replace_project_workspace"].url_for( + base_url = client.app.router["move_project_to_workspace"].url_for( project_id=project["uuid"], workspace_id=f"{added_workspace['workspaceId']}" ) - resp = await client.put(f"{base_url}") + resp = await client.post(f"{base_url}") await assert_status(resp, status.HTTP_204_NO_CONTENT) # Get project in workspace @@ -182,10 +182,10 @@ async def test_moving_between_shared_and_shared_workspaces( assert data["workspaceId"] == added_workspace["workspaceId"] # <-- Workspace ID # Move project from workspace to your private workspace - base_url = client.app.router["replace_project_workspace"].url_for( + base_url = client.app.router["move_project_to_workspace"].url_for( project_id=project["uuid"], workspace_id=f"{second_workspace['workspaceId']}" ) - resp = await client.put(f"{base_url}") + resp = await client.post(f"{base_url}") await assert_status(resp, status.HTTP_204_NO_CONTENT) # Get project in workspace @@ -262,10 +262,10 @@ async def test_moving_between_workspaces_check_removed_from_folder( assert data["workspaceId"] == added_workspace["workspaceId"] # <-- Workspace ID # Move project from workspace to your private workspace - base_url = client.app.router["replace_project_workspace"].url_for( + base_url = client.app.router["move_project_to_workspace"].url_for( project_id=project["uuid"], workspace_id="none" ) - resp = await client.put(f"{base_url}") + resp = await client.post(f"{base_url}") await assert_status(resp, status.HTTP_204_NO_CONTENT) # Get project in workspace