Skip to content

Commit fcd0701

Browse files
db
1 parent 496bba5 commit fcd0701

File tree

6 files changed

+227
-43
lines changed

6 files changed

+227
-43
lines changed

services/web/server/src/simcore_service_webserver/folders/_folders_db.py

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import logging
88
from datetime import datetime
9-
from typing import Any, Final, cast
9+
from typing import Final, cast
1010

1111
import sqlalchemy as sa
1212
from aiohttp import web
@@ -33,6 +33,7 @@
3333
from simcore_postgres_database.utils_workspaces_sql import (
3434
create_my_workspace_access_rights_subquery,
3535
)
36+
from simcore_service_webserver.utils import UnSet, as_dict_exclude_unset
3637
from sqlalchemy import func
3738
from sqlalchemy.ext.asyncio import AsyncConnection
3839
from sqlalchemy.orm import aliased
@@ -43,18 +44,9 @@
4344

4445
_logger = logging.getLogger(__name__)
4546

46-
47-
class UnSet:
48-
...
49-
50-
5147
_unset: Final = UnSet()
5248

5349

54-
def as_dict_exclude_unset(**params) -> dict[str, Any]:
55-
return {k: v for k, v in params.items() if not isinstance(v, UnSet)}
56-
57-
5850
_SELECTION_ARGS = (
5951
folders_v2.c.folder_id,
6052
folders_v2.c.name,
@@ -324,6 +316,7 @@ async def update(
324316
parent_folder_id: FolderID | None | UnSet = _unset,
325317
trashed_at: datetime | None | UnSet = _unset,
326318
trashed_explicitly: bool | UnSet = _unset,
319+
workspace_id: WorkspaceID | None | UnSet = _unset,
327320
) -> FolderDB:
328321
"""
329322
Batch/single patch of folder/s
@@ -334,6 +327,7 @@ async def update(
334327
parent_folder_id=parent_folder_id,
335328
trashed_at=trashed_at,
336329
trashed_explicitly=trashed_explicitly,
330+
workspace_id=workspace_id,
337331
)
338332

339333
query = (
@@ -467,6 +461,60 @@ async def get_projects_recursively_only_if_user_is_owner(
467461
return [ProjectID(row[0]) async for row in result]
468462

469463

464+
async def get_all_folders_and_projects_recursively(
465+
app: web.Application,
466+
connection: AsyncConnection | None = None,
467+
*,
468+
folder_id: FolderID,
469+
private_workspace_user_id_or_none: UserID | None,
470+
product_name: ProductName,
471+
) -> tuple[list[FolderID], list[ProjectID]]:
472+
"""
473+
The purpose of this function is to retrieve all projects within the provided folder ID.
474+
"""
475+
476+
async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn:
477+
478+
# Step 1: Define the base case for the recursive CTE
479+
base_query = select(
480+
folders_v2.c.folder_id, folders_v2.c.parent_folder_id
481+
).where(
482+
(folders_v2.c.folder_id == folder_id) # <-- specified folder id
483+
& (folders_v2.c.product_name == product_name)
484+
)
485+
folder_hierarchy_cte = base_query.cte(name="folder_hierarchy", recursive=True)
486+
487+
# Step 2: Define the recursive case
488+
folder_alias = aliased(folders_v2)
489+
recursive_query = select(
490+
folder_alias.c.folder_id, folder_alias.c.parent_folder_id
491+
).select_from(
492+
folder_alias.join(
493+
folder_hierarchy_cte,
494+
folder_alias.c.parent_folder_id == folder_hierarchy_cte.c.folder_id,
495+
)
496+
)
497+
498+
# Step 3: Combine base and recursive cases into a CTE
499+
folder_hierarchy_cte = folder_hierarchy_cte.union_all(recursive_query)
500+
501+
# Step 4: Execute the query to get all descendants
502+
final_query = select(folder_hierarchy_cte)
503+
result = await conn.stream(final_query)
504+
# list of tuples [(folder_id, parent_folder_id), ...] ex. [(1, None), (2, 1)]
505+
folder_ids = [item[0] async for item in result]
506+
507+
query = select(projects_to_folders.c.project_uuid).where(
508+
(projects_to_folders.c.folder_id.in_(folder_ids))
509+
& (projects_to_folders.c.user_id == private_workspace_user_id_or_none)
510+
)
511+
512+
result = await conn.stream(query)
513+
project_ids = [ProjectID(row[0]) async for row in result]
514+
515+
return folder_ids, project_ids
516+
517+
470518
async def get_folders_recursively(
471519
app: web.Application,
472520
connection: AsyncConnection | None = None,
Lines changed: 76 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import logging
22

33
from aiohttp import web
4-
from models_library.access_rights import AccessRights
54
from models_library.folders import FolderID
65
from models_library.products import ProductName
76
from models_library.users import UserID
87
from models_library.workspaces import WorkspaceID
8+
from simcore_service_webserver.projects.db import ProjectDBAPI
99

10+
from ..projects import _folders_db as project_to_folders_db
11+
from ..projects import _groups_db as project_groups_db
12+
from ..projects._access_rights_api import check_user_project_permission
1013
from ..users.api import get_user
1114
from ..workspaces.api import check_user_workspace_access
1215
from . import _folders_db
@@ -22,64 +25,107 @@ async def move_folder_into_workspace(
2225
workspace_id: WorkspaceID | None,
2326
product_name: ProductName,
2427
) -> None:
28+
projects_db = ProjectDBAPI.get_from_app_context(app)
29+
2530
# 1. User needs to have delete permission on source folder
2631
folder_db = await _folders_db.get(
2732
app, folder_id=folder_id, product_name=product_name
2833
)
2934
workspace_is_private = True
30-
user_folder_access_rights = AccessRights(read=True, write=True, delete=True)
3135
if folder_db.workspace_id:
32-
user_workspace_access_rights = await check_user_workspace_access(
36+
await check_user_workspace_access(
3337
app,
3438
user_id=user_id,
3539
workspace_id=folder_db.workspace_id,
3640
product_name=product_name,
3741
permission="delete",
3842
)
3943
workspace_is_private = False
40-
user_folder_access_rights = user_workspace_access_rights.my_access_rights
41-
42-
# Here we have checked user has delete access rights on the folder he is moving
4344

44-
# 2. User needs to have write permission on destination workspace
45+
# 2. User needs to have write permission on destination workspace
4546
if workspace_id is not None:
46-
user_workspace_access_rights = await check_user_workspace_access(
47+
await check_user_workspace_access(
4748
app,
4849
user_id=user_id,
4950
workspace_id=workspace_id,
5051
product_name=product_name,
5152
permission="write",
5253
)
5354

54-
# Here we have already guaranties that user has all the right permissions to do this operation
55+
# 3. User needs to have delete permission on all the projects inside source folder
56+
(
57+
folder_ids,
58+
project_ids,
59+
) = await _folders_db.get_all_folders_and_projects_recursively(
60+
app,
61+
connection=None,
62+
folder_id=folder_id,
63+
private_workspace_user_id_or_none=user_id if workspace_is_private else None,
64+
product_name=product_name,
65+
)
66+
# NOTE: Not the most effective, can be improved
67+
for project_id in project_ids:
68+
await check_user_project_permission(
69+
app,
70+
project_id=project_id,
71+
user_id=user_id,
72+
product_name=product_name,
73+
permission="delete",
74+
)
5575

56-
# Get all project children
57-
# await _folders_db.
58-
# Get all folder children
59-
children_folders_list = await _folders_db.get_folders_recursively(
60-
app, connection=None, folder_id=folder_id, product_name=product_name
76+
# ⬆️ Here we have already guaranties that user has all the right permissions to do this operation ⬆️
77+
78+
# 4. Update workspace ID on the project resource
79+
for project_id in project_ids:
80+
await projects_db.patch_project(
81+
project_uuid=project_id,
82+
new_partial_project_data={"workspace_id": workspace_id},
83+
)
84+
85+
# 5. BATCH update of folders with workspace_id
86+
await _folders_db.update(
87+
app,
88+
connection=None,
89+
folders_id_or_ids=set(folder_ids),
90+
product_name=product_name,
91+
workspace_id=workspace_id, # <-- Updating workspace_id
6192
)
6293

63-
# 3. Delete project to folders (for everybody)
64-
await project_to_folders_db.delete_all_project_to_folder_by_project_id(
94+
# 6. Update source folder parent folder ID with NULL (it will appear in the root directory)
95+
await _folders_db.update(
6596
app,
66-
project_id=project_id,
97+
connection=None,
98+
folders_id_or_ids=folder_id,
99+
product_name=product_name,
100+
parent_folder_id=None, # <-- Updating parent folder ID
67101
)
68102

69-
# 4. Update workspace ID on the project resource
70-
await project_api.patch_project(
71-
project_uuid=project_id,
72-
new_partial_project_data={"workspace_id": workspace_id},
103+
# 7. Remove all records of project to folders that are not in the folders that we are moving
104+
# (ex. If we are moving from private workspace, the same project can be in different folders for different users)
105+
await project_to_folders_db.delete_all_project_to_folder_by_project_ids_not_in_folder_ids(
106+
app,
107+
connection=None,
108+
project_id_or_ids=set(project_ids),
109+
not_in_folder_ids=set(folder_ids),
73110
)
74111

75-
# 5. Remove all project permissions, leave only the user who moved the project
76-
user = await get_user(app, user_id=user_id)
77-
await project_groups_db.delete_all_project_groups(app, project_id=project_id)
78-
await project_groups_db.update_or_insert_project_group(
112+
# 8. Update the user id field for the remaining folders
113+
await project_to_folders_db.update_project_to_folder(
79114
app,
80-
project_id=project_id,
81-
group_id=user["primary_gid"],
82-
read=True,
83-
write=True,
84-
delete=True,
115+
connection=None,
116+
folders_id_or_ids=set(folder_ids),
117+
user_id=user_id if workspace_id is None else None,
85118
)
119+
120+
# 9. Remove all project permissions, leave only the user who moved the project
121+
user = await get_user(app, user_id=user_id)
122+
for project_id in project_ids:
123+
await project_groups_db.delete_all_project_groups(app, project_id=project_id)
124+
await project_groups_db.update_or_insert_project_group(
125+
app,
126+
project_id=project_id,
127+
group_id=user["primary_gid"],
128+
read=True,
129+
write=True,
130+
delete=True,
131+
)

services/web/server/src/simcore_service_webserver/folders/_workspaces_handlers.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from .._meta import api_version_prefix as VTAG
1515
from ..folders.errors import FolderAccessForbiddenError, FolderNotFoundError
1616
from ..login.decorators import login_required
17+
from ..projects.exceptions import ProjectInvalidRightsError, ProjectNotFoundError
1718
from ..security.decorators import permission_required
1819
from ..workspaces.errors import WorkspaceAccessForbiddenError, WorkspaceNotFoundError
1920
from . import _workspaces_api
@@ -29,12 +30,14 @@ async def wrapper(request: web.Request) -> web.StreamResponse:
2930
return await handler(request)
3031

3132
except (
33+
ProjectInvalidRightsError,
3234
FolderNotFoundError,
3335
WorkspaceNotFoundError,
3436
) as exc:
3537
raise web.HTTPNotFound(reason=f"{exc}") from exc
3638

3739
except (
40+
ProjectNotFoundError,
3841
FolderAccessForbiddenError,
3942
WorkspaceAccessForbiddenError,
4043
) as exc:

services/web/server/src/simcore_service_webserver/projects/_folders_db.py

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,25 @@
66

77
import logging
88
from datetime import datetime
9+
from typing import Final
910

1011
from aiohttp import web
1112
from models_library.folders import FolderID
1213
from models_library.projects import ProjectID
1314
from models_library.users import UserID
1415
from pydantic import BaseModel
1516
from simcore_postgres_database.models.projects_to_folders import projects_to_folders
17+
from simcore_postgres_database.utils_repos import transaction_context
18+
from simcore_service_webserver.utils import UnSet, as_dict_exclude_unset
1619
from sqlalchemy import func, literal_column
20+
from sqlalchemy.ext.asyncio import AsyncConnection
1721
from sqlalchemy.sql import select
1822

19-
from ..db.plugin import get_database_engine
23+
from ..db.plugin import get_asyncpg_engine, get_database_engine
2024

2125
_logger = logging.getLogger(__name__)
2226

23-
24-
_logger = logging.getLogger(__name__)
27+
_unset: Final = UnSet()
2528

2629
### Models
2730

@@ -110,3 +113,72 @@ async def delete_all_project_to_folder_by_project_id(
110113
projects_to_folders.c.project_uuid == f"{project_id}"
111114
)
112115
)
116+
117+
118+
### AsyncPg
119+
120+
121+
async def update_project_to_folder(
122+
app: web.Application,
123+
connection: AsyncConnection | None = None,
124+
*,
125+
folders_id_or_ids: FolderID | set[FolderID],
126+
filter_by_user_id: UserID | None | UnSet = _unset,
127+
# updatable columns
128+
user_id: UserID | None | UnSet = _unset,
129+
) -> None:
130+
"""
131+
Batch/single patch of project to folders
132+
"""
133+
# NOTE: exclude unset can also be done using a pydantic model and dict(exclude_unset=True)
134+
updated = as_dict_exclude_unset(
135+
user_id=user_id,
136+
)
137+
138+
query = projects_to_folders.update().values(modified=func.now(), **updated)
139+
140+
if isinstance(folders_id_or_ids, set):
141+
# batch-update
142+
query = query.where(
143+
projects_to_folders.c.folder_id.in_(list(folders_id_or_ids))
144+
)
145+
else:
146+
# single-update
147+
query = query.where(projects_to_folders.c.folder_id == folders_id_or_ids)
148+
149+
if not isinstance(filter_by_user_id, UnSet):
150+
query = query.where(projects_to_folders.c.user_id == filter_by_user_id)
151+
152+
async with transaction_context(get_asyncpg_engine(app), connection) as conn:
153+
await conn.stream(query)
154+
155+
156+
async def delete_all_project_to_folder_by_project_ids_not_in_folder_ids(
157+
app: web.Application,
158+
connection: AsyncConnection | None = None,
159+
*,
160+
project_id_or_ids: ProjectID | set[ProjectID],
161+
# Optional filter
162+
not_in_folder_ids: set[FolderID],
163+
) -> None:
164+
query = projects_to_folders.delete()
165+
166+
if isinstance(project_id_or_ids, set):
167+
# batch-delete
168+
query = query.where(
169+
projects_to_folders.c.project_uuid.in_(list(project_id_or_ids))
170+
)
171+
else:
172+
# single-delete
173+
query = query.where(
174+
projects_to_folders.c.project_uuid == f"{project_id_or_ids}"
175+
)
176+
177+
query = query.where(
178+
projects_to_folders.c.folder_id.not_in( # <-- NOT IN!
179+
[f"{folder_id}" for folder_id in not_in_folder_ids]
180+
)
181+
)
182+
183+
async with transaction_context(get_asyncpg_engine(app), connection) as conn:
184+
await conn.stream(query)

services/web/server/src/simcore_service_webserver/projects/_workspaces_api.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ async def move_project_into_workspace(
5555
project_uuid=project_id,
5656
new_partial_project_data={"workspace_id": workspace_id},
5757
)
58+
# NOTE: MD: should I also patch the project owner? -> probably yes, or if it is more like "original owner" then probably no
5859

5960
# 5. Remove all project permissions, leave only the user who moved the project
6061
user = await get_user(app, user_id=user_id)

0 commit comments

Comments
 (0)