Skip to content

Commit 8344bf6

Browse files
authored
✨ web-api: Empty bins for folders and its content (#7237)
1 parent 135be29 commit 8344bf6

File tree

8 files changed

+337
-27
lines changed

8 files changed

+337
-27
lines changed

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

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,23 @@
33

44
import arrow
55
from aiohttp import web
6-
from models_library.folders import FolderID
6+
from common_library.pagination_tools import iter_pagination_params
7+
from models_library.access_rights import AccessRights
8+
from models_library.basic_types import IDStr
9+
from models_library.folders import FolderDB, FolderID
710
from models_library.products import ProductName
811
from models_library.projects import ProjectID
12+
from models_library.rest_ordering import OrderBy, OrderDirection
13+
from models_library.rest_pagination import MAXIMUM_NUMBER_OF_ITEMS_PER_PAGE
914
from models_library.users import UserID
1015
from simcore_postgres_database.utils_repos import transaction_context
1116
from sqlalchemy.ext.asyncio import AsyncConnection
1217

1318
from ..db.plugin import get_asyncpg_engine
1419
from ..projects._trash_service import trash_project, untrash_project
1520
from ..workspaces.api import check_user_workspace_access
16-
from . import _folders_repository
21+
from . import _folders_repository, _folders_service
22+
from .errors import FolderNotTrashedError
1723

1824
_logger = logging.getLogger(__name__)
1925

@@ -186,3 +192,89 @@ async def untrash_folder(
186192
await untrash_project(
187193
app, product_name=product_name, user_id=user_id, project_id=project_id
188194
)
195+
196+
197+
def _can_delete(
198+
folder_db: FolderDB,
199+
my_access_rights: AccessRights,
200+
user_id: UserID,
201+
until_equal_datetime: datetime | None,
202+
) -> bool:
203+
return bool(
204+
folder_db.trashed
205+
and (until_equal_datetime is None or folder_db.trashed < until_equal_datetime)
206+
and my_access_rights.delete
207+
and folder_db.trashed_by == user_id
208+
and folder_db.trashed_explicitly
209+
)
210+
211+
212+
async def list_explicitly_trashed_folders(
213+
app: web.Application,
214+
*,
215+
product_name: ProductName,
216+
user_id: UserID,
217+
until_equal_datetime: datetime | None = None,
218+
) -> list[FolderID]:
219+
trashed_folder_ids: list[FolderID] = []
220+
221+
for page_params in iter_pagination_params(limit=MAXIMUM_NUMBER_OF_ITEMS_PER_PAGE):
222+
(
223+
folders,
224+
page_params.total_number_of_items,
225+
) = await _folders_service.list_folders_full_depth(
226+
app,
227+
user_id=user_id,
228+
product_name=product_name,
229+
text=None,
230+
trashed=True,
231+
offset=page_params.offset,
232+
limit=page_params.limit,
233+
order_by=OrderBy(field=IDStr("trashed"), direction=OrderDirection.ASC),
234+
)
235+
236+
# NOTE: Applying POST-FILTERING
237+
trashed_folder_ids.extend(
238+
[
239+
f.folder_db.folder_id
240+
for f in folders
241+
if _can_delete(
242+
f.folder_db,
243+
my_access_rights=f.my_access_rights,
244+
user_id=user_id,
245+
until_equal_datetime=until_equal_datetime,
246+
)
247+
]
248+
)
249+
return trashed_folder_ids
250+
251+
252+
async def delete_trashed_folder(
253+
app: web.Application,
254+
*,
255+
product_name: ProductName,
256+
user_id: UserID,
257+
folder_id: FolderID,
258+
until_equal_datetime: datetime | None = None,
259+
) -> None:
260+
261+
folder = await _folders_service.get_folder(
262+
app, user_id=user_id, folder_id=folder_id, product_name=product_name
263+
)
264+
265+
if not _can_delete(
266+
folder.folder_db,
267+
folder.my_access_rights,
268+
user_id=user_id,
269+
until_equal_datetime=until_equal_datetime,
270+
):
271+
raise FolderNotTrashedError(
272+
folder_id=folder_id,
273+
user_id=user_id,
274+
reason="Cannot delete trashed folder since it does not fit current criteria",
275+
)
276+
277+
# NOTE: this function deletes folder AND its content recursively!
278+
await _folders_service.delete_folder(
279+
app, user_id=user_id, folder_id=folder_id, product_name=product_name
280+
)

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,13 @@ class FolderAccessForbiddenError(FoldersValueError):
1919

2020
class FolderGroupNotFoundError(FoldersValueError):
2121
msg_template = "Folder group not found. {reason}"
22+
23+
24+
class FoldersRuntimeError(WebServerBaseError, RuntimeError):
25+
...
26+
27+
28+
class FolderNotTrashedError(FoldersRuntimeError):
29+
msg_template = (
30+
"Cannot delete folder {folder_id} since it was not trashed first: {reason}"
31+
)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from ._trash_service import delete_trashed_folder, list_explicitly_trashed_folders
2+
3+
__all__: tuple[str, ...] = (
4+
"delete_trashed_folder",
5+
"list_explicitly_trashed_folders",
6+
)
7+
8+
# nopycln: file

services/web/server/src/simcore_service_webserver/projects/_common/exceptions_handlers.py

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import itertools
12
import logging
3+
from collections import Counter
24

35
from servicelib.aiohttp import status
46
from servicelib.rabbitmq.rpc_interfaces.catalog.errors import (
@@ -174,16 +176,37 @@
174176
),
175177
}
176178

179+
180+
_ERRORS = [
181+
_FOLDER_ERRORS,
182+
_NODE_ERRORS,
183+
_OTHER_ERRORS,
184+
_PRICING_ERRORS,
185+
_PROJECT_ERRORS,
186+
_WALLET_ERRORS,
187+
_WORKSPACE_ERRORS,
188+
]
189+
190+
191+
def _assert_duplicate():
192+
duplicates = {
193+
exc.__name__: count
194+
for exc, count in Counter(itertools.chain(*[d.keys() for d in _ERRORS])).items()
195+
if count > 1
196+
}
197+
if duplicates:
198+
msg = f"Found duplicated exceptions: {duplicates}"
199+
raise AssertionError(msg)
200+
return True
201+
202+
203+
assert _assert_duplicate() # nosec
204+
177205
_TO_HTTP_ERROR_MAP: ExceptionToHttpErrorMap = {
178-
**_FOLDER_ERRORS,
179-
**_NODE_ERRORS,
180-
**_OTHER_ERRORS,
181-
**_PRICING_ERRORS,
182-
**_PROJECT_ERRORS,
183-
**_WALLET_ERRORS,
184-
**_WORKSPACE_ERRORS,
206+
k: v for dikt in _ERRORS for k, v in dikt.items()
185207
}
186208

209+
187210
handle_plugin_requests_exceptions = exception_handling_decorator(
188211
to_exceptions_handlers_map(_TO_HTTP_ERROR_MAP)
189212
)

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

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import asyncio
2-
import datetime
32
import logging
3+
from datetime import datetime
44

55
import arrow
66
from aiohttp import web
@@ -9,6 +9,7 @@
99
from models_library.products import ProductName
1010
from models_library.projects import ProjectID
1111
from models_library.rest_ordering import OrderBy, OrderDirection
12+
from models_library.rest_pagination import MAXIMUM_NUMBER_OF_ITEMS_PER_PAGE
1213
from models_library.users import UserID
1314
from servicelib.aiohttp.application_keys import APP_FIRE_AND_FORGET_TASKS_KEY
1415
from servicelib.common_headers import UNDEFINED_DEFAULT_SIMCORE_USER_AGENT_VALUE
@@ -132,7 +133,7 @@ async def untrash_project(
132133
def _can_delete(
133134
project: ProjectDict,
134135
user_id: UserID,
135-
until_equal_datetime: datetime.datetime | None,
136+
until_equal_datetime: datetime | None,
136137
) -> bool:
137138
"""
138139
This is the current policy to delete trashed project
@@ -158,19 +159,19 @@ def _can_delete(
158159
)
159160

160161

161-
async def list_trashed_projects(
162+
async def list_explicitly_trashed_projects(
162163
app: web.Application,
163164
*,
164165
product_name: ProductName,
165166
user_id: UserID,
166-
until_equal_datetime: datetime.datetime | None = None,
167+
until_equal_datetime: datetime | None = None,
167168
) -> list[ProjectID]:
168169
"""
169170
Lists all projects that were trashed until a specific datetime (if !=None).
170171
"""
171172
trashed_projects: list[ProjectID] = []
172173

173-
for page_params in iter_pagination_params(limit=100):
174+
for page_params in iter_pagination_params(limit=MAXIMUM_NUMBER_OF_ITEMS_PER_PAGE):
174175
(
175176
projects,
176177
page_params.total_number_of_items,
@@ -202,12 +203,12 @@ async def list_trashed_projects(
202203
return trashed_projects
203204

204205

205-
async def delete_trashed_project(
206+
async def delete_explicitly_trashed_project(
206207
app: web.Application,
207208
*,
208209
user_id: UserID,
209210
project_id: ProjectID,
210-
until_equal_datetime: datetime.datetime | None = None,
211+
until_equal_datetime: datetime | None = None,
211212
) -> None:
212213
"""
213214
Deletes a project that was explicitly trashed by the user from a specific datetime (if provided, otherwise all).
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from ._trash_service import (
2+
delete_explicitly_trashed_project,
3+
list_explicitly_trashed_projects,
4+
)
5+
6+
__all__: tuple[str, ...] = (
7+
"delete_explicitly_trashed_project",
8+
"list_explicitly_trashed_projects",
9+
)
10+
11+
# nopycln: file

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

Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import asyncio
22
import logging
33
from datetime import timedelta
4+
from typing import Final
45

56
import arrow
67
from aiohttp import web
@@ -9,31 +10,38 @@
910
from servicelib.logging_errors import create_troubleshotting_log_kwargs
1011
from servicelib.logging_utils import log_context
1112

12-
from ..projects import _trash_service
13+
from ..folders import folders_trash_service
14+
from ..projects import projects_trash_service
1315
from .settings import get_plugin_settings
1416

1517
_logger = logging.getLogger(__name__)
1618

19+
_TIP: Final[str] = (
20+
"`empty_trash_safe` is set `fail_fast=False`."
21+
"\nErrors while deletion are ignored."
22+
"\nNew runs might resolve them"
23+
)
1724

18-
async def empty_trash_safe(
25+
26+
async def _empty_explicitly_trashed_projects(
1927
app: web.Application, product_name: ProductName, user_id: UserID
2028
):
21-
assert app # nosec
22-
23-
trashed_projects_ids = await _trash_service.list_trashed_projects(
24-
app=app, product_name=product_name, user_id=user_id
29+
trashed_projects_ids = (
30+
await projects_trash_service.list_explicitly_trashed_projects(
31+
app=app, product_name=product_name, user_id=user_id
32+
)
2533
)
2634

2735
with log_context(
2836
_logger,
2937
logging.DEBUG,
30-
"Deleting %s trashed projects",
38+
"Deleting %s explicitly trashed projects",
3139
len(trashed_projects_ids),
3240
):
3341
for project_id in trashed_projects_ids:
3442
try:
3543

36-
await _trash_service.delete_trashed_project(
44+
await projects_trash_service.delete_explicitly_trashed_project(
3745
app,
3846
user_id=user_id,
3947
project_id=project_id,
@@ -49,13 +57,56 @@ async def empty_trash_safe(
4957
"product_name": product_name,
5058
"user_id": user_id,
5159
},
52-
tip="`empty_trash_safe` is set `fail_fast=False`."
53-
"\nErrors while deletion are ignored."
54-
"\nNew runs might resolve them",
60+
tip=_TIP,
5561
)
5662
)
5763

5864

65+
async def _empty_trashed_folders(
66+
app: web.Application, product_name: ProductName, user_id: UserID
67+
):
68+
trashed_folders_ids = await folders_trash_service.list_explicitly_trashed_folders(
69+
app=app, product_name=product_name, user_id=user_id
70+
)
71+
72+
with log_context(
73+
_logger,
74+
logging.DEBUG,
75+
"Deleting %s trashed folders (and all its content)",
76+
len(trashed_folders_ids),
77+
):
78+
for folder_id in trashed_folders_ids:
79+
try:
80+
await folders_trash_service.delete_trashed_folder(
81+
app,
82+
product_name=product_name,
83+
user_id=user_id,
84+
folder_id=folder_id,
85+
)
86+
87+
except Exception as exc: # pylint: disable=broad-exception-caught
88+
_logger.warning(
89+
**create_troubleshotting_log_kwargs(
90+
"Error deleting a trashed folders (and content) while emptying trash.",
91+
error=exc,
92+
error_context={
93+
"folder_id": folder_id,
94+
"product_name": product_name,
95+
"user_id": user_id,
96+
},
97+
tip=_TIP,
98+
)
99+
)
100+
101+
102+
async def empty_trash_safe(
103+
app: web.Application, *, product_name: ProductName, user_id: UserID
104+
):
105+
await _empty_explicitly_trashed_projects(app, product_name, user_id)
106+
107+
await _empty_trashed_folders(app, product_name, user_id)
108+
109+
59110
async def prune_trash(app: web.Application) -> list[str]:
60111
"""Deletes expired items in the trash"""
61112
settings = get_plugin_settings(app)

0 commit comments

Comments
 (0)