Skip to content

Commit 8754b76

Browse files
authored
✨ web-api: Empty bin of explicitly trashed projects (#7226)
1 parent bcaa959 commit 8754b76

File tree

14 files changed

+443
-34
lines changed

14 files changed

+443
-34
lines changed

api/specs/web-server/_trash.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@
2626
)
2727

2828

29-
@router.delete(
30-
"/trash",
29+
@router.post(
30+
"/trash:empty",
3131
status_code=status.HTTP_204_NO_CONTENT,
3232
)
3333
def empty_trash():
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
from collections.abc import Iterable
2+
from typing import Annotated
3+
4+
from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt, PositiveInt
5+
6+
7+
class PageParams(BaseModel):
8+
offset_initial: Annotated[NonNegativeInt, Field(frozen=True)] = 0
9+
offset_current: NonNegativeInt = 0 # running offset
10+
limit: Annotated[PositiveInt, Field(frozen=True)]
11+
total_number_of_items: int | None = None
12+
13+
model_config = ConfigDict(validate_assignment=True)
14+
15+
@property
16+
def offset(self) -> NonNegativeInt:
17+
return self.offset_current
18+
19+
def has_items_left(self) -> bool:
20+
return (
21+
self.total_number_of_items is None
22+
or self.offset_current < self.total_number_of_items
23+
)
24+
25+
def total_number_of_pages(self) -> NonNegativeInt:
26+
assert self.total_number_of_items # nosec
27+
num_items = self.total_number_of_items - self.offset_initial
28+
return num_items // self.limit + (1 if num_items % self.limit else 0)
29+
30+
31+
def iter_pagination_params(
32+
offset: NonNegativeInt = 0,
33+
limit: PositiveInt = 100,
34+
total_number_of_items: NonNegativeInt | None = None,
35+
) -> Iterable[PageParams]:
36+
37+
kwargs = {}
38+
if total_number_of_items:
39+
kwargs["total_number_of_items"] = total_number_of_items
40+
41+
page_params = PageParams(
42+
offset_initial=offset, offset_current=offset, limit=limit, **kwargs
43+
)
44+
45+
assert page_params.offset_current == page_params.offset_initial # nosec
46+
47+
total_count_before = page_params.total_number_of_items
48+
page_index = 0
49+
50+
while page_params.has_items_left():
51+
52+
yield page_params
53+
54+
if page_params.total_number_of_items is None:
55+
msg = "Must be updated at least before the first iteration, i.e. page_args.total_number_of_items = total_count"
56+
raise RuntimeError(msg)
57+
58+
if (
59+
total_count_before
60+
and total_count_before != page_params.total_number_of_items
61+
):
62+
msg = (
63+
f"total_number_of_items cannot change on every iteration: before={total_count_before}, now={page_params.total_number_of_items}."
64+
"WARNING: the size of the paginated collection might be changing while it is being iterated?"
65+
)
66+
raise RuntimeError(msg)
67+
68+
if page_index == 0:
69+
total_count_before = page_params.total_number_of_items
70+
71+
page_params.offset_current += limit
72+
assert page_params.offset == page_params.offset_current # nosec
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# pylint: disable=redefined-outer-name
2+
# pylint: disable=unused-argument
3+
# pylint: disable=unused-variable
4+
# pylint: disable=too-many-arguments
5+
6+
import asyncio
7+
from collections.abc import Callable
8+
9+
import pytest
10+
from common_library.pagination_tools import iter_pagination_params
11+
from pydantic import ValidationError
12+
13+
14+
@pytest.fixture
15+
def all_items() -> list[int]:
16+
return list(range(11))
17+
18+
19+
@pytest.fixture
20+
async def get_page(all_items: list[int]) -> Callable:
21+
async def _get_page(offset, limit) -> tuple[list[int], int]:
22+
await asyncio.sleep(0)
23+
return all_items[offset : offset + limit], len(all_items)
24+
25+
return _get_page
26+
27+
28+
@pytest.mark.parametrize("limit", [2, 3, 5])
29+
@pytest.mark.parametrize("offset", [0, 1, 5])
30+
async def test_iter_pages_args(
31+
limit: int, offset: int, get_page: Callable, all_items: list[int]
32+
):
33+
34+
last_page = [None] * limit
35+
36+
num_items = len(all_items) - offset
37+
expected_num_pages = num_items // limit + (1 if num_items % limit else 0)
38+
39+
num_pages = 0
40+
page_args = None
41+
for page_index, page_args in enumerate(iter_pagination_params(offset, limit)):
42+
43+
page_items, page_args.total_number_of_items = await get_page(
44+
page_args.offset_current, page_args.limit
45+
)
46+
47+
assert set(last_page) != set(page_items)
48+
last_page = list(page_items)
49+
50+
# contains sub-sequence
51+
assert str(page_items)[1:-1] in str(all_items)[1:-1]
52+
53+
num_pages = page_index + 1
54+
55+
assert last_page[-1] == all_items[-1]
56+
assert num_pages == expected_num_pages
57+
58+
assert page_args is not None
59+
assert not page_args.has_items_left()
60+
assert page_args.total_number_of_pages() == num_pages
61+
62+
63+
@pytest.mark.parametrize("limit", [-1, 0])
64+
@pytest.mark.parametrize("offset", [-1])
65+
def test_iter_pages_args_invalid(limit: int, offset: int):
66+
67+
with pytest.raises(ValidationError): # noqa: PT012
68+
for _ in iter_pagination_params(offset=offset, limit=limit):
69+
pass
70+
71+
72+
def test_fails_if_total_number_of_items_not_set():
73+
with pytest.raises( # noqa: PT012
74+
RuntimeError,
75+
match="page_args.total_number_of_items = total_count",
76+
):
77+
for _ in iter_pagination_params(limit=2):
78+
pass
79+
80+
81+
def test_fails_if_total_number_of_items_changes():
82+
with pytest.raises( # noqa: PT012
83+
RuntimeError,
84+
match="total_number_of_items cannot change on every iteration",
85+
):
86+
for page_params in iter_pagination_params(limit=2, total_number_of_items=4):
87+
assert page_params.total_number_of_items == 4
88+
page_params.total_number_of_items += 1

services/static-webserver/client/source/class/osparc/dashboard/StudyBrowserHeader.js

-1
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,6 @@ qx.Class.define("osparc.dashboard.StudyBrowserHeader", {
197197
appearance: "danger-button",
198198
allowGrowY: false,
199199
alignY: "middle",
200-
visibility: "excluded", // Not yet implemented
201200
});
202201
control.addListener("execute", () => this.fireEvent("emptyTrashRequested"));
203202
this._addAt(control, this.self().POS.EMPTY_TRASH_BUTTON);

services/static-webserver/client/source/class/osparc/data/Resources.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -458,8 +458,8 @@ qx.Class.define("osparc.data.Resources", {
458458
"trash": {
459459
endpoints: {
460460
delete: {
461-
method: "DELETE",
462-
url: statics.API + "/trash"
461+
method: "POST",
462+
url: statics.API + "/trash:empty"
463463
}
464464
}
465465
},

services/web/server/VERSION

+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.57.0
1+
0.58.0

services/web/server/setup.cfg

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[bumpversion]
2-
current_version = 0.57.0
2+
current_version = 0.58.0
33
commit = True
44
message = services/webserver api version: {current_version} → {new_version}
55
tag = False

services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml

+3-3
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ openapi: 3.1.0
22
info:
33
title: simcore-service-webserver
44
description: Main service with an interface (http-API & websockets) to the web front-end
5-
version: 0.57.0
5+
version: 0.58.0
66
servers:
77
- url: ''
88
description: webserver
@@ -5973,8 +5973,8 @@ paths:
59735973
application/json:
59745974
schema:
59755975
$ref: '#/components/schemas/Envelope_FileUploadCompleteFutureResponse_'
5976-
/v0/trash:
5977-
delete:
5976+
/v0/trash:empty:
5977+
post:
59785978
tags:
59795979
- trash
59805980
summary: Empty Trash

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

+120-5
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,29 @@
11
import asyncio
2+
import datetime
23
import logging
34

45
import arrow
56
from aiohttp import web
7+
from common_library.pagination_tools import iter_pagination_params
8+
from models_library.basic_types import IDStr
69
from models_library.products import ProductName
710
from models_library.projects import ProjectID
11+
from models_library.rest_ordering import OrderBy, OrderDirection
812
from models_library.users import UserID
913
from servicelib.aiohttp.application_keys import APP_FIRE_AND_FORGET_TASKS_KEY
1014
from servicelib.common_headers import UNDEFINED_DEFAULT_SIMCORE_USER_AGENT_VALUE
1115
from servicelib.utils import fire_and_forget_task
1216

1317
from ..director_v2 import api as director_v2_api
1418
from ..dynamic_scheduler import api as dynamic_scheduler_api
15-
from . import projects_service
19+
from . import _crud_api_read, projects_service
1620
from ._access_rights_api import check_user_project_permission
17-
from .exceptions import ProjectRunningConflictError
18-
from .models import ProjectPatchInternalExtended
21+
from .exceptions import (
22+
ProjectNotFoundError,
23+
ProjectNotTrashedError,
24+
ProjectRunningConflictError,
25+
)
26+
from .models import ProjectDict, ProjectPatchInternalExtended
1927

2028
_logger = logging.getLogger(__name__)
2129

@@ -45,7 +53,7 @@ async def trash_project(
4553
project_id: ProjectID,
4654
force_stop_first: bool,
4755
explicit: bool,
48-
):
56+
) -> None:
4957
"""
5058
5159
Raises:
@@ -108,7 +116,7 @@ async def untrash_project(
108116
product_name: ProductName,
109117
user_id: UserID,
110118
project_id: ProjectID,
111-
):
119+
) -> None:
112120
# NOTE: check_user_project_permission is inside projects_api.patch_project
113121
await projects_service.patch_project(
114122
app,
@@ -119,3 +127,110 @@ async def untrash_project(
119127
trashed_at=None, trashed_explicitly=False, trashed_by=None
120128
),
121129
)
130+
131+
132+
def _can_delete(
133+
project: ProjectDict,
134+
user_id: UserID,
135+
until_equal_datetime: datetime.datetime | None,
136+
) -> bool:
137+
"""
138+
This is the current policy to delete trashed project
139+
140+
"""
141+
trashed_at = project.get("trashed")
142+
trashed_by = project.get("trashedBy")
143+
trashed_explicitly = project.get("trashedExplicitly")
144+
145+
assert trashed_at is not None # nosec
146+
assert trashed_by is not None # nosec
147+
148+
is_shared = len(project["accessRights"]) > 1
149+
150+
return bool(
151+
trashed_at
152+
and (until_equal_datetime is None or trashed_at < until_equal_datetime)
153+
# NOTE: current policy is more restricted until
154+
# logic is adapted to deal with the other cases
155+
and trashed_by == user_id
156+
and not is_shared
157+
and trashed_explicitly
158+
)
159+
160+
161+
async def list_trashed_projects(
162+
app: web.Application,
163+
*,
164+
product_name: ProductName,
165+
user_id: UserID,
166+
until_equal_datetime: datetime.datetime | None = None,
167+
) -> list[ProjectID]:
168+
"""
169+
Lists all projects that were trashed until a specific datetime (if !=None).
170+
"""
171+
trashed_projects: list[ProjectID] = []
172+
173+
for page_params in iter_pagination_params(limit=100):
174+
(
175+
projects,
176+
page_params.total_number_of_items,
177+
) = await _crud_api_read.list_projects_full_depth(
178+
app,
179+
user_id=user_id,
180+
product_name=product_name,
181+
trashed=True,
182+
tag_ids_list=[],
183+
offset=page_params.offset,
184+
limit=page_params.limit,
185+
order_by=OrderBy(field=IDStr("trashed"), direction=OrderDirection.ASC),
186+
search_by_multi_columns=None,
187+
search_by_project_name=None,
188+
)
189+
190+
# NOTE: Applying POST-FILTERING because we do not want to modify the interface of
191+
# _crud_api_read.list_projects_full_depth at this time.
192+
# This filtering couldn't be handled at the database level when `projects_repo`
193+
# was refactored, as defining a custom trash_filter was needed to allow more
194+
# flexibility in filtering options.
195+
trashed_projects = [
196+
project["uuid"]
197+
for project in projects
198+
if _can_delete(project, user_id, until_equal_datetime)
199+
]
200+
return trashed_projects
201+
202+
203+
async def delete_trashed_project(
204+
app: web.Application,
205+
*,
206+
user_id: UserID,
207+
project_id: ProjectID,
208+
until_equal_datetime: datetime.datetime | None = None,
209+
) -> None:
210+
"""
211+
Deletes a project that was explicitly trashed by the user from a specific datetime (if provided, otherwise all).
212+
213+
Raises:
214+
ProjectNotFoundError: If the project is not found.
215+
ProjectNotTrashedError: If the project was not trashed explicitly by the user from the specified datetime.
216+
"""
217+
project = await projects_service.get_project_for_user(
218+
app, project_uuid=f"{project_id}", user_id=user_id
219+
)
220+
221+
if not project:
222+
raise ProjectNotFoundError(project_uuid=project_id, user_id=user_id)
223+
224+
if not _can_delete(project, user_id, until_equal_datetime):
225+
# safety check
226+
raise ProjectNotTrashedError(
227+
project_uuid=project_id,
228+
user_id=user_id,
229+
reason="Cannot delete trashed project since it does not fit current criteria",
230+
)
231+
232+
await projects_service.delete_project_by_user(
233+
app,
234+
user_id=user_id,
235+
project_uuid=project_id,
236+
)

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

+6
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,12 @@ class ProjectRunningConflictError(ProjectTrashError):
9393
)
9494

9595

96+
class ProjectNotTrashedError(ProjectTrashError):
97+
msg_template = (
98+
"Cannot delete project {project_uuid} since it was not trashed first: {reason}"
99+
)
100+
101+
96102
class NodeNotFoundError(BaseProjectError):
97103
msg_template = "Node '{node_uuid}' not found in project '{project_uuid}'"
98104

0 commit comments

Comments
 (0)