Skip to content

Commit 7405274

Browse files
🎨 folder deletion (#6324)
1 parent b74bae4 commit 7405274

File tree

5 files changed

+408
-8
lines changed

5 files changed

+408
-8
lines changed

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

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,19 @@
77
from models_library.api_schemas_webserver.folders_v2 import FolderGet, FolderGetPage
88
from models_library.folders import FolderID
99
from models_library.products import ProductName
10+
from models_library.projects import ProjectID
1011
from models_library.rest_ordering import OrderBy
1112
from models_library.users import UserID
1213
from models_library.workspaces import WorkspaceID
1314
from pydantic import NonNegativeInt
15+
from servicelib.aiohttp.application_keys import APP_FIRE_AND_FORGET_TASKS_KEY
16+
from servicelib.common_headers import UNDEFINED_DEFAULT_SIMCORE_USER_AGENT_VALUE
17+
from servicelib.utils import fire_and_forget_task
1418
from simcore_service_webserver.workspaces._workspaces_api import (
1519
check_user_workspace_access,
1620
)
1721

22+
from ..projects.projects_api import submit_delete_project_task
1823
from ..users.api import get_user
1924
from ..workspaces.errors import (
2025
WorkspaceAccessForbiddenError,
@@ -224,7 +229,7 @@ async def update_folder(
224229
workspace_is_private = False
225230
user_folder_access_rights = user_workspace_access_rights.my_access_rights
226231

227-
# Check user has acces to the folder
232+
# Check user has access to the folder
228233
await folders_db.get_for_user_or_workspace(
229234
app,
230235
folder_id=folder_id,
@@ -273,7 +278,7 @@ async def delete_folder(
273278
)
274279
workspace_is_private = False
275280

276-
# Check user has acces to the folder
281+
# Check user has access to the folder
277282
await folders_db.get_for_user_or_workspace(
278283
app,
279284
folder_id=folder_id,
@@ -282,4 +287,32 @@ async def delete_folder(
282287
workspace_id=folder_db.workspace_id,
283288
)
284289

285-
await folders_db.delete(app, folder_id=folder_id, product_name=product_name)
290+
# 1. Delete folder content
291+
# 1.1 Delete all child projects that I am an owner
292+
project_id_list: list[
293+
ProjectID
294+
] = await folders_db.get_projects_recursively_only_if_user_is_owner(
295+
app,
296+
folder_id=folder_id,
297+
private_workspace_user_id_or_none=user_id if workspace_is_private else None,
298+
user_id=user_id,
299+
product_name=product_name,
300+
)
301+
302+
# fire and forget task for project deletion
303+
for project_id in project_id_list:
304+
fire_and_forget_task(
305+
submit_delete_project_task(
306+
app,
307+
project_uuid=project_id,
308+
user_id=user_id,
309+
simcore_user_agent=UNDEFINED_DEFAULT_SIMCORE_USER_AGENT_VALUE,
310+
),
311+
task_suffix_name=f"delete_project_task_{project_id}",
312+
fire_and_forget_tasks_collection=app[APP_FIRE_AND_FORGET_TASKS_KEY],
313+
)
314+
315+
# 1.2 Delete all child folders
316+
await folders_db.delete_recursively(
317+
app, folder_id=folder_id, product_name=product_name
318+
)

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

Lines changed: 102 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,16 @@
1010
from aiohttp import web
1111
from models_library.folders import FolderDB, FolderID
1212
from models_library.products import ProductName
13+
from models_library.projects import ProjectID
1314
from models_library.rest_ordering import OrderBy, OrderDirection
1415
from models_library.users import GroupID, UserID
1516
from models_library.workspaces import WorkspaceID
1617
from pydantic import NonNegativeInt
1718
from simcore_postgres_database.models.folders_v2 import folders_v2
19+
from simcore_postgres_database.models.projects import projects
20+
from simcore_postgres_database.models.projects_to_folders import projects_to_folders
1821
from sqlalchemy import func
22+
from sqlalchemy.orm import aliased
1923
from sqlalchemy.sql import asc, desc, select
2024

2125
from ..db.plugin import get_database_engine
@@ -212,16 +216,109 @@ async def update(
212216
return FolderDB.from_orm(row)
213217

214218

215-
async def delete(
219+
async def delete_recursively(
216220
app: web.Application,
217221
*,
218222
folder_id: FolderID,
219223
product_name: ProductName,
220224
) -> None:
221-
async with get_database_engine(app).acquire() as conn:
225+
async with get_database_engine(app).acquire() as conn, conn.begin():
226+
# Step 1: Define the base case for the recursive CTE
227+
base_query = select(
228+
folders_v2.c.folder_id, folders_v2.c.parent_folder_id
229+
).where(
230+
(folders_v2.c.folder_id == folder_id) # <-- specified folder id
231+
& (folders_v2.c.product_name == product_name)
232+
)
233+
folder_hierarchy_cte = base_query.cte(name="folder_hierarchy", recursive=True)
234+
# Step 2: Define the recursive case
235+
folder_alias = aliased(folders_v2)
236+
recursive_query = select(
237+
folder_alias.c.folder_id, folder_alias.c.parent_folder_id
238+
).select_from(
239+
folder_alias.join(
240+
folder_hierarchy_cte,
241+
folder_alias.c.parent_folder_id == folder_hierarchy_cte.c.folder_id,
242+
)
243+
)
244+
# Step 3: Combine base and recursive cases into a CTE
245+
folder_hierarchy_cte = folder_hierarchy_cte.union_all(recursive_query)
246+
# Step 4: Execute the query to get all descendants
247+
final_query = select(folder_hierarchy_cte)
248+
result = await conn.execute(final_query)
249+
rows = ( # list of tuples [(folder_id, parent_folder_id), ...] ex. [(1, None), (2, 1)]
250+
await result.fetchall() or []
251+
)
252+
253+
# Sort folders so that child folders come first
254+
sorted_folders = sorted(
255+
rows, key=lambda x: (x[1] is not None, x[1]), reverse=True
256+
)
257+
folder_ids = [item[0] for item in sorted_folders]
222258
await conn.execute(
223-
folders_v2.delete().where(
224-
(folders_v2.c.folder_id == folder_id)
225-
& (folders_v2.c.product_name == product_name)
259+
folders_v2.delete().where(folders_v2.c.folder_id.in_(folder_ids))
260+
)
261+
262+
263+
async def get_projects_recursively_only_if_user_is_owner(
264+
app: web.Application,
265+
*,
266+
folder_id: FolderID,
267+
private_workspace_user_id_or_none: UserID | None,
268+
user_id: UserID,
269+
product_name: ProductName,
270+
) -> list[ProjectID]:
271+
"""
272+
The purpose of this function is to retrieve all projects within the provided folder ID.
273+
These projects are subsequently deleted, so we only return projects where the user is the owner.
274+
For future improvement, we can return all projects for which the user has delete permissions.
275+
This permission check would require using the `workspace_access_rights` table for workspace projects,
276+
or the `users_to_groups` table for private workspace projects.
277+
"""
278+
279+
async with get_database_engine(app).acquire() as conn, conn.begin():
280+
# Step 1: Define the base case for the recursive CTE
281+
base_query = select(
282+
folders_v2.c.folder_id, folders_v2.c.parent_folder_id
283+
).where(
284+
(folders_v2.c.folder_id == folder_id) # <-- specified folder id
285+
& (folders_v2.c.product_name == product_name)
286+
)
287+
folder_hierarchy_cte = base_query.cte(name="folder_hierarchy", recursive=True)
288+
# Step 2: Define the recursive case
289+
folder_alias = aliased(folders_v2)
290+
recursive_query = select(
291+
folder_alias.c.folder_id, folder_alias.c.parent_folder_id
292+
).select_from(
293+
folder_alias.join(
294+
folder_hierarchy_cte,
295+
folder_alias.c.parent_folder_id == folder_hierarchy_cte.c.folder_id,
226296
)
227297
)
298+
# Step 3: Combine base and recursive cases into a CTE
299+
folder_hierarchy_cte = folder_hierarchy_cte.union_all(recursive_query)
300+
# Step 4: Execute the query to get all descendants
301+
final_query = select(folder_hierarchy_cte)
302+
result = await conn.execute(final_query)
303+
rows = ( # list of tuples [(folder_id, parent_folder_id), ...] ex. [(1, None), (2, 1)]
304+
await result.fetchall() or []
305+
)
306+
307+
folder_ids = [item[0] for item in rows]
308+
309+
query = (
310+
select(projects_to_folders.c.project_uuid)
311+
.join(projects)
312+
.where(
313+
(projects_to_folders.c.folder_id.in_(folder_ids))
314+
& (projects_to_folders.c.user_id == private_workspace_user_id_or_none)
315+
)
316+
)
317+
if private_workspace_user_id_or_none is not None:
318+
query = query.where(projects.c.prj_owner == user_id)
319+
320+
result = await conn.execute(query)
321+
322+
rows = await result.fetchall() or []
323+
results = [ProjectID(row[0]) for row in rows]
324+
return results

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,12 @@ async def create_project( # pylint: disable=too-many-arguments,too-many-branche
320320
# As user has access to the project, it has implicitly access to the folder
321321
folder_id = prj_to_folder_db.folder_id
322322

323+
if as_template:
324+
# For template we do not care about workspace/folder
325+
workspace_id = None
326+
new_project["workspaceId"] = workspace_id
327+
folder_id = None
328+
323329
if predefined_project:
324330
# 2. overrides with optional body and re-validate
325331
new_project, project_nodes = await _compose_project_data(

services/web/server/tests/unit/with_dbs/03/folders/test_folders.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
import asyncio
2+
13
# pylint: disable=redefined-outer-name
24
# pylint: disable=unused-argument
35
# pylint: disable=unused-variable
46
# pylint: disable=too-many-arguments
57
# pylint: disable=too-many-statements
68
from http import HTTPStatus
9+
from unittest import mock
710

811
import pytest
912
from aiohttp.test_utils import TestClient
@@ -16,6 +19,7 @@
1619
standard_role_response,
1720
)
1821
from servicelib.aiohttp import status
22+
from servicelib.aiohttp.application_keys import APP_FIRE_AND_FORGET_TASKS_KEY
1923
from simcore_service_webserver.db.models import UserRole
2024
from simcore_service_webserver.projects._groups_db import update_or_insert_project_group
2125
from simcore_service_webserver.projects.models import ProjectDict
@@ -336,3 +340,128 @@ async def test_project_listing_inside_of_private_folder(
336340
data, _ = await assert_status(resp, status.HTTP_200_OK)
337341
assert len(data) == 1
338342
assert data[0]["uuid"] == user_project["uuid"]
343+
344+
345+
@pytest.fixture
346+
def mock_storage_delete_data_folders(mocker: MockerFixture) -> mock.Mock:
347+
mocker.patch(
348+
"simcore_service_webserver.dynamic_scheduler.api.list_dynamic_services",
349+
autospec=True,
350+
)
351+
mocker.patch(
352+
"simcore_service_webserver.projects.projects_api.remove_project_dynamic_services",
353+
autospec=True,
354+
)
355+
mocker.patch(
356+
"simcore_service_webserver.projects._crud_api_delete.api.delete_pipeline",
357+
autospec=True,
358+
)
359+
return mocker.patch(
360+
"simcore_service_webserver.projects._crud_api_delete.delete_data_folders_of_project",
361+
return_value=None,
362+
)
363+
364+
365+
@pytest.mark.parametrize("user_role,expected", [(UserRole.USER, status.HTTP_200_OK)])
366+
async def test_folders_deletion(
367+
client: TestClient,
368+
logged_user: UserInfoDict,
369+
user_project: ProjectDict,
370+
expected: HTTPStatus,
371+
mock_catalog_api_get_services_for_user_in_product: MockerFixture,
372+
mock_storage_delete_data_folders: mock.Mock,
373+
):
374+
assert client.app
375+
376+
# create a new folder
377+
url = client.app.router["create_folder"].url_for()
378+
resp = await client.post(url.path, json={"name": "My first folder"})
379+
root_folder, _ = await assert_status(resp, status.HTTP_201_CREATED)
380+
assert FolderGet.parse_obj(root_folder)
381+
382+
# create a subfolder folder
383+
url = client.app.router["create_folder"].url_for()
384+
resp = await client.post(
385+
url.path,
386+
json={
387+
"name": "My subfolder 1",
388+
"parentFolderId": root_folder["folderId"],
389+
},
390+
)
391+
subfolder_1, _ = await assert_status(resp, status.HTTP_201_CREATED)
392+
393+
# create a subfolder folder
394+
url = client.app.router["create_folder"].url_for()
395+
resp = await client.post(
396+
url.path,
397+
json={
398+
"name": "My subfolder 2",
399+
"parentFolderId": root_folder["folderId"],
400+
},
401+
)
402+
subfolder_2, _ = await assert_status(resp, status.HTTP_201_CREATED)
403+
404+
# add project to the sub folder
405+
url = client.app.router["replace_project_folder"].url_for(
406+
folder_id=f"{subfolder_2['folderId']}",
407+
project_id=f"{user_project['uuid']}",
408+
)
409+
resp = await client.put(url.path)
410+
await assert_status(resp, status.HTTP_204_NO_CONTENT)
411+
412+
# create a sub sub folder folder
413+
url = client.app.router["create_folder"].url_for()
414+
resp = await client.post(
415+
url.path,
416+
json={
417+
"name": "My sub sub folder",
418+
"parentFolderId": subfolder_1["folderId"],
419+
},
420+
)
421+
await assert_status(resp, status.HTTP_201_CREATED)
422+
423+
# list user folders
424+
url = client.app.router["list_folders"].url_for()
425+
resp = await client.get(url.path)
426+
data, _ = await assert_status(resp, status.HTTP_200_OK)
427+
assert len(data) == 1
428+
429+
# list subfolder projects
430+
base_url = client.app.router["list_projects"].url_for()
431+
url = base_url.with_query({"folder_id": f"{subfolder_2['folderId']}"})
432+
resp = await client.get(url)
433+
data, _ = await assert_status(resp, status.HTTP_200_OK)
434+
assert len(data) == 1
435+
assert data[0]["uuid"] == user_project["uuid"]
436+
437+
# list root projects
438+
base_url = client.app.router["list_projects"].url_for()
439+
resp = await client.get(base_url)
440+
data, _ = await assert_status(resp, status.HTTP_200_OK)
441+
assert len(data) == 0
442+
443+
# delete a subfolder
444+
url = client.app.router["delete_folder"].url_for(
445+
folder_id=f"{subfolder_1['folderId']}"
446+
)
447+
resp = await client.delete(url.path)
448+
await assert_status(resp, status.HTTP_204_NO_CONTENT)
449+
450+
# delete a root folder
451+
url = client.app.router["delete_folder"].url_for(
452+
folder_id=f"{root_folder['folderId']}"
453+
)
454+
resp = await client.delete(url.path)
455+
await assert_status(resp, status.HTTP_204_NO_CONTENT)
456+
457+
fire_and_forget_tasks = client.app[APP_FIRE_AND_FORGET_TASKS_KEY]
458+
t: asyncio.Task = list(fire_and_forget_tasks)[0]
459+
assert t.get_name().startswith("fire_and_forget_task_delete_project_task_")
460+
await t
461+
assert len(client.app[APP_FIRE_AND_FORGET_TASKS_KEY]) == 0
462+
463+
# list root projects (The project should have been deleted)
464+
base_url = client.app.router["list_projects"].url_for()
465+
resp = await client.get(base_url)
466+
data, _ = await assert_status(resp, status.HTTP_200_OK)
467+
assert len(data) == 0

0 commit comments

Comments
 (0)