Skip to content

Commit 44972f0

Browse files
matusdrobuliak66giancarloromeopcrespov
authored
Is7420/permanently delete trashed workspaces (#7482)
Co-authored-by: Giancarlo Romeo <[email protected]> Co-authored-by: Pedro Crespo-Valero <[email protected]>
1 parent e36b197 commit 44972f0

37 files changed

+1605
-426
lines changed

services/web/server/src/simcore_service_webserver/director_v2/_service.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from ..db.plugin import get_database_engine
2727
from ..products import products_service
2828
from ..products.models import Product
29-
from ..projects import api as projects_service
29+
from ..projects import projects_wallets_service
3030
from ..users import preferences_api as user_preferences_service
3131
from ..users.exceptions import UserDefaultWalletNotFoundError
3232
from ..wallets import api as wallets_service
@@ -204,7 +204,7 @@ async def get_wallet_info(
204204
product.is_payment_enabled and app_settings.WEBSERVER_CREDIT_COMPUTATION_ENABLED
205205
):
206206
return None
207-
project_wallet = await projects_service.get_project_wallet(
207+
project_wallet = await projects_wallets_service.get_project_wallet(
208208
app, project_id=project_id
209209
)
210210
if project_wallet is None:
@@ -219,7 +219,7 @@ async def get_wallet_info(
219219
project_wallet_id = TypeAdapter(WalletID).validate_python(
220220
user_default_wallet_preference.value
221221
)
222-
await projects_service.connect_wallet_to_project(
222+
await projects_wallets_service.connect_wallet_to_project(
223223
app,
224224
product_name=product_name,
225225
project_id=project_id,

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

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,9 @@ async def list_( # pylint: disable=too-many-arguments,too-many-branches
245245

246246
# Ordering and pagination
247247
list_query = (
248-
combined_query.order_by(_to_expression(order_by)).offset(offset).limit(limit)
248+
combined_query.order_by(_to_expression(order_by), folders_v2.c.folder_id)
249+
.offset(offset)
250+
.limit(limit)
249251
)
250252

251253
async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn:
@@ -258,13 +260,14 @@ async def list_( # pylint: disable=too-many-arguments,too-many-branches
258260
return cast(int, total_count), folders
259261

260262

261-
async def list_trashed_folders(
263+
async def list_folders_db_as_admin(
262264
app: web.Application,
263265
connection: AsyncConnection | None = None,
264266
*,
265267
# filter
266268
trashed_explicitly: bool | UnSet = UnSet.VALUE,
267269
trashed_before: datetime | UnSet = UnSet.VALUE,
270+
shared_workspace_id: WorkspaceID | UnSet = UnSet.VALUE, # <-- Workspace filter
268271
# pagination
269272
offset: NonNegativeInt,
270273
limit: int,
@@ -273,28 +276,35 @@ async def list_trashed_folders(
273276
) -> tuple[int, list[FolderDB]]:
274277
"""
275278
NOTE: this is app-wide i.e. no product, user or workspace filtered
276-
TODO: check with MD about workspaces
277279
"""
278-
base_query = sql.select(*_FOLDER_DB_MODEL_COLS).where(
279-
folders_v2.c.trashed.is_not(None)
280-
)
280+
base_query = sql.select(*_FOLDER_DB_MODEL_COLS)
281281

282282
if is_set(trashed_explicitly):
283283
assert isinstance(trashed_explicitly, bool) # nosec
284284
base_query = base_query.where(
285-
folders_v2.c.trashed_explicitly.is_(trashed_explicitly)
285+
(folders_v2.c.trashed_explicitly.is_(trashed_explicitly))
286+
& (folders_v2.c.trashed.is_not(None))
286287
)
287288

288289
if is_set(trashed_before):
289290
assert isinstance(trashed_before, datetime) # nosec
290-
base_query = base_query.where(folders_v2.c.trashed < trashed_before)
291+
base_query = base_query.where(
292+
(folders_v2.c.trashed < trashed_before)
293+
& (folders_v2.c.trashed.is_not(None))
294+
)
295+
296+
if is_set(shared_workspace_id):
297+
assert isinstance(shared_workspace_id, int) # nosec
298+
base_query = base_query.where(folders_v2.c.workspace_id == shared_workspace_id)
291299

292300
# Select total count from base_query
293301
count_query = sql.select(sql.func.count()).select_from(base_query.subquery())
294302

295303
# Ordering and pagination
296304
list_query = (
297-
base_query.order_by(_to_expression(order_by)).offset(offset).limit(limit)
305+
base_query.order_by(_to_expression(order_by), folders_v2.c.folder_id)
306+
.offset(offset)
307+
.limit(limit)
298308
)
299309

300310
async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn:
@@ -376,7 +386,7 @@ async def update(
376386
parent_folder_id: FolderID | None | UnSet = UnSet.VALUE,
377387
trashed: datetime | None | UnSet = UnSet.VALUE,
378388
trashed_explicitly: bool | UnSet = UnSet.VALUE,
379-
trashed_by: UserID | UnSet = UnSet.VALUE, # who trashed
389+
trashed_by: UserID | None | UnSet = UnSet.VALUE, # who trashed
380390
workspace_id: WorkspaceID | None | UnSet = UnSet.VALUE,
381391
user_id: UserID | None | UnSet = UnSet.VALUE, # ownership
382392
) -> FolderDB:
@@ -534,7 +544,7 @@ async def get_all_folders_and_projects_ids_recursively(
534544
product_name: ProductName,
535545
) -> tuple[list[FolderID], list[ProjectID]]:
536546
"""
537-
The purpose of this function is to retrieve all projects within the provided folder ID.
547+
The purpose of this function is to retrieve all subfolders and projects within the provided folder ID.
538548
"""
539549

540550
async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn:

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,11 +205,11 @@ async def replace_folder(request: web.Request):
205205
@login_required
206206
@permission_required("folder.delete")
207207
@handle_plugin_requests_exceptions
208-
async def delete_folder_group(request: web.Request):
208+
async def delete_folder(request: web.Request):
209209
req_ctx = FoldersRequestContext.model_validate(request)
210210
path_params = parse_request_path_parameters_as(FoldersPathParams, request)
211211

212-
await _folders_service.delete_folder(
212+
await _folders_service.delete_folder_with_all_content(
213213
app=request.app,
214214
user_id=req_ctx.user_id,
215215
folder_id=path_params.folder_id,

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

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,8 @@
1111
from models_library.users import UserID
1212
from models_library.workspaces import WorkspaceID, WorkspaceQuery, WorkspaceScope
1313
from pydantic import NonNegativeInt
14-
from servicelib.aiohttp.application_keys import APP_FIRE_AND_FORGET_TASKS_KEY
15-
from servicelib.common_headers import UNDEFINED_DEFAULT_SIMCORE_USER_AGENT_VALUE
16-
from servicelib.utils import fire_and_forget_task
1714

18-
from ..projects._projects_service import submit_delete_project_task
15+
from ..projects._projects_service import delete_project_by_user
1916
from ..users.api import get_user
2017
from ..workspaces.api import check_user_workspace_access
2118
from ..workspaces.errors import (
@@ -320,7 +317,7 @@ async def update_folder(
320317
)
321318

322319

323-
async def delete_folder(
320+
async def delete_folder_with_all_content(
324321
app: web.Application,
325322
user_id: UserID,
326323
folder_id: FolderID,
@@ -352,6 +349,9 @@ async def delete_folder(
352349

353350
# 1. Delete folder content
354351
# 1.1 Delete all child projects that I am an owner
352+
# NOTE: The reason for this is to be cautious and not delete projects by accident that
353+
# are not owned by the user (even if the user was granted delete permissions). As a consequence, after deleting the folder,
354+
# projects that the user does not own will appear in the root. (Maybe this can be changed as we now have a trash system).
355355
project_id_list: list[ProjectID] = (
356356
await _folders_repository.get_projects_recursively_only_if_user_is_owner(
357357
app,
@@ -362,17 +362,11 @@ async def delete_folder(
362362
)
363363
)
364364

365-
# fire and forget task for project deletion
366365
for project_id in project_id_list:
367-
fire_and_forget_task(
368-
submit_delete_project_task(
369-
app,
370-
project_uuid=project_id,
371-
user_id=user_id,
372-
simcore_user_agent=UNDEFINED_DEFAULT_SIMCORE_USER_AGENT_VALUE,
373-
),
374-
task_suffix_name=f"delete_project_task_{project_id}",
375-
fire_and_forget_tasks_collection=app[APP_FIRE_AND_FORGET_TASKS_KEY],
366+
await delete_project_by_user(
367+
app,
368+
project_uuid=project_id,
369+
user_id=user_id,
376370
)
377371

378372
# 1.2 Delete all child folders

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ async def trash_folder(request: web.Request):
3939
user_id=user_id,
4040
folder_id=path_params.folder_id,
4141
force_stop_first=query_params.force,
42+
explicit=True,
4243
)
4344

4445
return web.json_response(status=status.HTTP_204_NO_CONTENT)

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

Lines changed: 79 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from models_library.rest_ordering import OrderBy, OrderDirection
1313
from models_library.rest_pagination import MAXIMUM_NUMBER_OF_ITEMS_PER_PAGE
1414
from models_library.users import UserID
15+
from models_library.workspaces import WorkspaceID
1516
from simcore_postgres_database.utils_repos import transaction_context
1617
from sqlalchemy.ext.asyncio import AsyncConnection
1718

@@ -62,23 +63,24 @@ async def _check_exists_and_access(
6263
return workspace_is_private
6364

6465

65-
async def _folders_db_update(
66+
async def _folders_db_trashed_state_update(
6667
app: web.Application,
6768
connection: AsyncConnection | None = None,
6869
*,
6970
product_name: ProductName,
7071
folder_id: FolderID,
7172
trashed_at: datetime | None,
72-
trashed_by: UserID,
73+
trashed_explicitly: bool,
74+
trashed_by: UserID | None,
7375
):
74-
# EXPLICIT un/trash
76+
# EXPLICIT or IMPLICIT un/trash
7577
await _folders_repository.update(
7678
app,
7779
connection,
7880
folders_id_or_ids=folder_id,
7981
product_name=product_name,
8082
trashed=trashed_at,
81-
trashed_explicitly=trashed_at is not None,
83+
trashed_explicitly=trashed_explicitly,
8284
trashed_by=trashed_by,
8385
)
8486

@@ -110,6 +112,7 @@ async def trash_folder(
110112
user_id: UserID,
111113
folder_id: FolderID,
112114
force_stop_first: bool,
115+
explicit: bool,
113116
):
114117

115118
workspace_is_private = await _check_exists_and_access(
@@ -122,25 +125,28 @@ async def trash_folder(
122125
async with transaction_context(get_asyncpg_engine(app)) as connection:
123126

124127
# 1. Trash folder and children
125-
await _folders_db_update(
128+
await _folders_db_trashed_state_update(
126129
app,
127130
connection,
128131
folder_id=folder_id,
129132
product_name=product_name,
130133
trashed_at=trashed_at,
134+
trashed_explicitly=explicit,
131135
trashed_by=user_id,
132136
)
133137

134138
# 2. Trash all child projects that I am an owner
135-
child_projects: list[
136-
ProjectID
137-
] = await _folders_repository.get_projects_recursively_only_if_user_is_owner(
138-
app,
139-
connection,
140-
folder_id=folder_id,
141-
private_workspace_user_id_or_none=user_id if workspace_is_private else None,
142-
user_id=user_id,
143-
product_name=product_name,
139+
child_projects: list[ProjectID] = (
140+
await _folders_repository.get_projects_recursively_only_if_user_is_owner(
141+
app,
142+
connection,
143+
folder_id=folder_id,
144+
private_workspace_user_id_or_none=(
145+
user_id if workspace_is_private else None
146+
),
147+
user_id=user_id,
148+
product_name=product_name,
149+
)
144150
)
145151

146152
for project_id in child_projects:
@@ -169,23 +175,24 @@ async def untrash_folder(
169175
# 3. UNtrash
170176

171177
# 3.1 UNtrash folder and children
172-
await _folders_db_update(
178+
await _folders_db_trashed_state_update(
173179
app,
174180
folder_id=folder_id,
175181
product_name=product_name,
176182
trashed_at=None,
177-
trashed_by=user_id,
183+
trashed_by=None,
184+
trashed_explicitly=False,
178185
)
179186

180187
# 3.2 UNtrash all child projects that I am an owner
181-
child_projects: list[
182-
ProjectID
183-
] = await _folders_repository.get_projects_recursively_only_if_user_is_owner(
184-
app,
185-
folder_id=folder_id,
186-
private_workspace_user_id_or_none=user_id if workspace_is_private else None,
187-
user_id=user_id,
188-
product_name=product_name,
188+
child_projects: list[ProjectID] = (
189+
await _folders_repository.get_projects_recursively_only_if_user_is_owner(
190+
app,
191+
folder_id=folder_id,
192+
private_workspace_user_id_or_none=user_id if workspace_is_private else None,
193+
user_id=user_id,
194+
product_name=product_name,
195+
)
189196
)
190197

191198
for project_id in child_projects:
@@ -227,7 +234,7 @@ async def list_explicitly_trashed_folders(
227234
user_id=user_id,
228235
product_name=product_name,
229236
text=None,
230-
trashed=True, # NOTE: lists only expliclty trashed!
237+
trashed=True, # NOTE: lists only explicitly trashed!
231238
offset=page_params.offset,
232239
limit=page_params.limit,
233240
order_by=OrderBy(field=IDStr("trashed"), direction=OrderDirection.ASC),
@@ -275,7 +282,7 @@ async def delete_trashed_folder(
275282
)
276283

277284
# NOTE: this function deletes folder AND its content recursively!
278-
await _folders_service.delete_folder(
285+
await _folders_service.delete_folder_with_all_content(
279286
app, user_id=user_id, folder_id=folder_id, product_name=product_name
280287
)
281288

@@ -298,7 +305,7 @@ async def batch_delete_trashed_folders_as_admin(
298305
(
299306
page_params.total_number_of_items,
300307
expired_trashed_folders,
301-
) = await _folders_repository.list_trashed_folders(
308+
) = await _folders_repository.list_folders_db_as_admin(
302309
app,
303310
trashed_explicitly=True,
304311
trashed_before=trashed_before,
@@ -324,3 +331,48 @@ async def batch_delete_trashed_folders_as_admin(
324331
raise FolderBatchDeleteError(
325332
errors=errors, trashed_before=trashed_before, product_name=product_name
326333
)
334+
335+
336+
async def batch_delete_folders_with_content_in_root_workspace_as_admin(
337+
app: web.Application,
338+
*,
339+
workspace_id: WorkspaceID,
340+
product_name: ProductName,
341+
fail_fast: bool,
342+
) -> None:
343+
"""
344+
Deletes all folders recursively in the workspace root.
345+
346+
Raises:
347+
FolderBatchDeleteError: If there are errors during the deletion process.
348+
"""
349+
deleted_folder_ids: list[FolderID] = []
350+
errors: list[tuple[FolderID, Exception]] = []
351+
352+
for page_params in iter_pagination_params(limit=MAXIMUM_NUMBER_OF_ITEMS_PER_PAGE):
353+
(
354+
page_params.total_number_of_items,
355+
folders_for_deletion,
356+
) = await _folders_repository.list_folders_db_as_admin(
357+
app,
358+
shared_workspace_id=workspace_id, # <-- Workspace filter
359+
offset=page_params.offset,
360+
limit=page_params.limit,
361+
order_by=OrderBy(field=IDStr("folder_id")),
362+
)
363+
# BATCH delete
364+
for folder in folders_for_deletion:
365+
try:
366+
await _folders_repository.delete_recursively(
367+
app, folder_id=folder.folder_id, product_name=product_name
368+
)
369+
deleted_folder_ids.append(folder.folder_id)
370+
except Exception as err: # pylint: disable=broad-exception-caught
371+
if fail_fast:
372+
raise
373+
errors.append((folder.folder_id, err))
374+
375+
if errors:
376+
raise FolderBatchDeleteError(
377+
errors=errors,
378+
)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from ._folders_service import delete_folder_with_all_content, list_folders
2+
3+
__all__: tuple[str, ...] = (
4+
"list_folders",
5+
"delete_folder_with_all_content",
6+
)
7+
8+
# nopycln: file

0 commit comments

Comments
 (0)