Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
496bba5
api and handlers
matusdrobuliak66 Nov 27, 2024
fcd0701
db
matusdrobuliak66 Nov 27, 2024
29f67de
Merge branch 'master' into move-folder-between-workspaced
matusdrobuliak66 Nov 27, 2024
33b48e6
open api specs
matusdrobuliak66 Nov 27, 2024
f9c7c0b
Merge branch 'master' into move-folder-between-workspaced
matusdrobuliak66 Nov 28, 2024
dc092c2
adding unit tests
matusdrobuliak66 Nov 28, 2024
3f8e8b1
adding unit tests
matusdrobuliak66 Nov 28, 2024
e6908f0
refactor project DB
matusdrobuliak66 Nov 28, 2024
57b63b1
adding transaction
matusdrobuliak66 Nov 28, 2024
5e8e5e3
open api specs
matusdrobuliak66 Nov 28, 2024
f45879c
final cleanup
matusdrobuliak66 Nov 28, 2024
1ce4f4e
final cleanup
matusdrobuliak66 Nov 28, 2024
b7ee5cf
final cleanup
matusdrobuliak66 Nov 28, 2024
470716f
open api specs
matusdrobuliak66 Nov 28, 2024
e2d205e
open api specs
matusdrobuliak66 Nov 28, 2024
4b87ce3
review @pcrespov
matusdrobuliak66 Nov 29, 2024
0ac8e2d
Merge branch 'master' into move-folder-between-workspaced
matusdrobuliak66 Nov 29, 2024
0d71046
review @pcrespov
matusdrobuliak66 Nov 29, 2024
96d9b93
Merge branch 'master' into move-folder-between-workspaced
matusdrobuliak66 Nov 29, 2024
7061af7
review @pcrespov
matusdrobuliak66 Nov 29, 2024
b77602c
open api specs
matusdrobuliak66 Nov 29, 2024
6f43ade
Merge branch 'move-folder-between-workspaced' of github.com:matusdrob…
matusdrobuliak66 Nov 29, 2024
4e4675f
frontend changes
matusdrobuliak66 Nov 29, 2024
c37f3dc
clieaning
matusdrobuliak66 Nov 29, 2024
0fb74d4
frontend changes
matusdrobuliak66 Nov 29, 2024
c2f63e9
fix
matusdrobuliak66 Nov 29, 2024
f2cad55
Merge branch 'master' into move-folder-between-workspaced
matusdrobuliak66 Nov 29, 2024
7a9284c
Merge branch 'master' into move-folder-between-workspaced
matusdrobuliak66 Dec 2, 2024
6e3ed2a
fix
matusdrobuliak66 Dec 2, 2024
d03a496
fix
matusdrobuliak66 Dec 2, 2024
64758ff
fix
matusdrobuliak66 Dec 2, 2024
4dfa541
fix
matusdrobuliak66 Dec 2, 2024
763586a
fix
matusdrobuliak66 Dec 2, 2024
6e287d9
fix
matusdrobuliak66 Dec 2, 2024
21619d9
Merge branch 'master' into move-folder-between-workspaced
matusdrobuliak66 Dec 2, 2024
cd50981
Merge branch 'master' into move-folder-between-workspaced
matusdrobuliak66 Dec 2, 2024
5c785d2
fix
matusdrobuliak66 Dec 3, 2024
a9d5436
fix
matusdrobuliak66 Dec 3, 2024
658b167
Merge branch 'master' into move-folder-between-workspaced
matusdrobuliak66 Dec 3, 2024
eabeafb
fix
matusdrobuliak66 Dec 3, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions api/specs/web-server/_folders.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
FoldersListQueryParams,
FoldersPathParams,
)
from simcore_service_webserver.folders._workspaces_handlers import (
_FolderWorkspacesPathParams,
)

router = APIRouter(
prefix=f"/{API_VTAG}",
Expand Down Expand Up @@ -97,3 +100,15 @@ async def delete_folder(
_path: Annotated[FoldersPathParams, Depends()],
):
...


@router.put(
"/folders/{folder_id}/workspaces/{workspace_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Move folder to the workspace",
tags=["workspaces"],
)
async def replace_folder_workspace(
_path: Annotated[_FolderWorkspacesPathParams, Depends()],
):
...
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2944,6 +2944,59 @@ paths:
schema:
$ref: '#/components/schemas/EnvelopedError'
description: Service Unavailable
/v0/folders/{folder_id}/workspaces/{workspace_id}:
put:
tags:
- folders
- workspaces
summary: Move folder to the workspace
operationId: replace_folder_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:
Expand Down Expand Up @@ -4540,7 +4593,7 @@ paths:
'403':
description: ProjectInvalidRightsError
'404':
description: UserDefaultWalletNotFoundError, ProjectNotFoundError
description: ProjectNotFoundError, UserDefaultWalletNotFoundError
'409':
description: ProjectTooManyProjectOpenedError
'422':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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 = (
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
)
Loading
Loading