Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🎨 moving folders to workspaces #6851

Merged
Show file tree
Hide file tree
Changes from 15 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
18 changes: 18 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,18 @@ async def delete_folder(
_path: Annotated[FoldersPathParams, Depends()],
):
...


### Move Folder to Workspace
matusdrobuliak66 marked this conversation as resolved.
Show resolved Hide resolved


@router.put(
"/folders/{folder_id}/workspaces/{workspace_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Move folder to the workspace",
matusdrobuliak66 marked this conversation as resolved.
Show resolved Hide resolved
tags=["workspaces"],
)
async def replace_folder_workspace(
_path: Annotated[_FolderWorkspacesPathParams, Depends()],
):
...
matusdrobuliak66 marked this conversation as resolved.
Show resolved Hide resolved
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
matusdrobuliak66 marked this conversation as resolved.
Show resolved Hide resolved
'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_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]]:
matusdrobuliak66 marked this conversation as resolved.
Show resolved Hide resolved
"""
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)
matusdrobuliak66 marked this conversation as resolved.
Show resolved Hide resolved
)
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[0] async for item in result]
matusdrobuliak66 marked this conversation as resolved.
Show resolved Hide resolved

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[0]) async for row in result]
matusdrobuliak66 marked this conversation as resolved.
Show resolved Hide resolved

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 _db_v2 as projects_db
from ..projects import _folders_db as project_to_folders_db
from ..projects import _groups_db as project_groups_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_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(
matusdrobuliak66 marked this conversation as resolved.
Show resolved Hide resolved
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(
matusdrobuliak66 marked this conversation as resolved.
Show resolved Hide resolved
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),
matusdrobuliak66 marked this conversation as resolved.
Show resolved Hide resolved
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(
matusdrobuliak66 marked this conversation as resolved.
Show resolved Hide resolved
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