Skip to content

Commit 3ec9333

Browse files
authored
✨ Trash projects (#6579)
1 parent fe95d75 commit 3ec9333

File tree

45 files changed

+1017
-80
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+1017
-80
lines changed

.env-devel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,7 @@ LOGIN_ACCOUNT_DELETION_RETENTION_DAYS=31
309309
LOGIN_REGISTRATION_CONFIRMATION_REQUIRED=0
310310
LOGIN_REGISTRATION_INVITATION_REQUIRED=0
311311
PROJECTS_INACTIVITY_INTERVAL=20
312+
PROJECTS_TRASH_RETENTION_DAYS=7
312313
PROJECTS_MAX_COPY_SIZE_BYTES=30Gib
313314
PROJECTS_MAX_NUM_RUNNING_DYNAMIC_NODES=5
314315
REST_SWAGGER_API_DOC_ENABLED=1

api/specs/web-server/_projects_crud.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ async def list_projects(
8383
example='{"field": "last_change_date", "direction": "desc"}',
8484
),
8585
] = '{"field": "last_change_date", "direction": "desc"}',
86+
filters: Annotated[Json | None, Query()] = None,
8687
):
8788
...
8889

api/specs/web-server/_trash.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# pylint: disable=redefined-outer-name
2+
# pylint: disable=unused-argument
3+
# pylint: disable=unused-variable
4+
# pylint: disable=too-many-arguments
5+
6+
7+
from enum import Enum
8+
from typing import Annotated
9+
10+
from fastapi import APIRouter, Depends, status
11+
from simcore_service_webserver._meta import API_VTAG
12+
from simcore_service_webserver.projects._trash_handlers import (
13+
ProjectPathParams,
14+
RemoveQueryParams,
15+
)
16+
17+
router = APIRouter(
18+
prefix=f"/{API_VTAG}",
19+
tags=["trash"],
20+
)
21+
22+
23+
@router.delete(
24+
"/trash",
25+
status_code=status.HTTP_204_NO_CONTENT,
26+
)
27+
def empty_trash():
28+
...
29+
30+
31+
_extra_tags: list[str | Enum] = ["projects"]
32+
33+
34+
@router.post(
35+
"/projects/{project_id}:trash",
36+
tags=_extra_tags,
37+
status_code=status.HTTP_204_NO_CONTENT,
38+
responses={
39+
status.HTTP_404_NOT_FOUND: {"description": "Not such a project"},
40+
status.HTTP_409_CONFLICT: {
41+
"description": "Project is in use and cannot be trashed"
42+
},
43+
status.HTTP_503_SERVICE_UNAVAILABLE: {"description": "Trash service error"},
44+
},
45+
)
46+
def trash_project(
47+
_p: Annotated[ProjectPathParams, Depends()],
48+
_q: Annotated[RemoveQueryParams, Depends()],
49+
):
50+
...
51+
52+
53+
@router.post(
54+
"/projects/{project_id}:untrash",
55+
tags=_extra_tags,
56+
status_code=status.HTTP_204_NO_CONTENT,
57+
)
58+
def untrash_project(
59+
_p: Annotated[ProjectPathParams, Depends()],
60+
):
61+
...

api/specs/web-server/openapi.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
"_resource_usage",
5656
"_statics",
5757
"_storage",
58+
"_trash",
5859
"_version_control",
5960
"_workspaces",
6061
# maintenance ----

packages/models-library/src/models_library/api_schemas_webserver/folders_v2.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
from datetime import datetime
22
from typing import NamedTuple
33

4-
from models_library.access_rights import AccessRights
5-
from models_library.basic_types import IDStr
6-
from models_library.folders import FolderID
7-
from models_library.users import GroupID
8-
from models_library.utils.common_validators import null_or_none_str_to_none_validator
9-
from models_library.workspaces import WorkspaceID
104
from pydantic import Extra, PositiveInt, validator
115

6+
from ..access_rights import AccessRights
7+
from ..basic_types import IDStr
8+
from ..folders import FolderID
9+
from ..users import GroupID
10+
from ..utils.common_validators import null_or_none_str_to_none_validator
11+
from ..workspaces import WorkspaceID
1212
from ._base import InputSchema, OutputSchema
1313

1414

packages/models-library/src/models_library/api_schemas_webserver/projects.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
66
"""
77

8+
from datetime import datetime
89
from typing import Any, Literal, TypeAlias
910

1011
from models_library.folders import FolderID
@@ -85,6 +86,7 @@ class ProjectGet(OutputSchema):
8586
permalink: ProjectPermalink = FieldNotRequired()
8687
workspace_id: WorkspaceID | None
8788
folder_id: FolderID | None
89+
trashed_at: datetime | None
8890

8991
_empty_description = validator("description", allow_reuse=True, pre=True)(
9092
none_to_empty_str_pre_validator

packages/models-library/src/models_library/projects.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,11 @@ class Project(BaseProjectModel):
186186
alias="folderId",
187187
)
188188

189+
trashed_at: datetime | None = Field(
190+
default=None,
191+
alias="trashedAt",
192+
)
193+
189194
class Config:
190195
description = "Document that stores metadata, pipeline and UI setup of a study"
191196
title = "osparc-simcore project"
Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,22 @@
1-
from pydantic import BaseModel
1+
from typing import Generic, TypeVar
2+
3+
from pydantic import BaseModel, Field, Json
4+
from pydantic.generics import GenericModel
25

36

47
class Filters(BaseModel):
5-
"""inspired by Docker API https://docs.docker.com/engine/api/v1.43/#tag/Container/operation/ContainerList.
8+
"""
69
Encoded as JSON. Each available filter can have its own logic (should be well documented)
10+
Inspired by Docker API https://docs.docker.com/engine/api/v1.43/#tag/Container/operation/ContainerList.
711
"""
12+
13+
14+
# Custom filter
15+
FilterT = TypeVar("FilterT", bound=Filters)
16+
17+
18+
class FiltersQueryParameters(GenericModel, Generic[FilterT]):
19+
filters: Json[FilterT] | None = Field( # pylint: disable=unsubscriptable-object
20+
default=None,
21+
description="Custom filter query parameter encoded as JSON",
22+
)

packages/models-library/src/models_library/users.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from typing import TypeAlias
22

3+
from models_library.basic_types import IDStr
34
from pydantic import BaseModel, ConstrainedStr, Field, PositiveInt
45

56
UserID: TypeAlias = PositiveInt
7+
UserNameID: TypeAlias = IDStr
68
GroupID: TypeAlias = PositiveInt
79

810

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import logging
2+
3+
import pytest
4+
from models_library.rest_filters import Filters, FiltersQueryParameters
5+
from pydantic import Extra, ValidationError
6+
7+
8+
# 1. create filter model
9+
class CustomFilter(Filters):
10+
is_trashed: bool | None = None
11+
is_hidden: bool | None = None
12+
13+
14+
class CustomFilterStrict(CustomFilter):
15+
class Config(CustomFilter.Config):
16+
extra = Extra.forbid
17+
18+
19+
def test_custom_filter_query_parameters():
20+
21+
# 2. use generic as query parameters
22+
logging.info(
23+
"json schema is for the query \n %s",
24+
FiltersQueryParameters[CustomFilter].schema_json(indent=1),
25+
)
26+
27+
# lets filter only is_trashed and unset is_hidden
28+
custom_filter = CustomFilter(is_trashed=True)
29+
assert custom_filter.json() == '{"is_trashed": true, "is_hidden": null}'
30+
31+
# default to None (optional)
32+
query_param = FiltersQueryParameters[CustomFilter]()
33+
assert query_param.filters is None
34+
35+
36+
@pytest.mark.parametrize(
37+
"url_query_value,expects",
38+
[
39+
('{"is_trashed": true, "is_hidden": null}', CustomFilter(is_trashed=True)),
40+
('{"is_trashed": true}', CustomFilter(is_trashed=True)),
41+
(None, None),
42+
],
43+
)
44+
def test_valid_filter_queries(
45+
url_query_value: str | None, expects: CustomFilter | None
46+
):
47+
query_param = FiltersQueryParameters[CustomFilter](filters=url_query_value)
48+
assert query_param.filters == expects
49+
50+
51+
def test_invalid_filter_query_is_ignored():
52+
# NOTE: invalid filter get ignored!
53+
url_query_value = '{"undefined_filter": true, "is_hidden": true}'
54+
55+
query_param = FiltersQueryParameters[CustomFilter](filters=url_query_value)
56+
assert query_param.filters == CustomFilter(is_hidden=True)
57+
58+
59+
@pytest.mark.xfail
60+
def test_invalid_filter_query_fails():
61+
# NOTE: this should fail according to pydantic manual but it does not
62+
url_query_value = '{"undefined_filter": true, "is_hidden": true}'
63+
64+
with pytest.raises(ValidationError):
65+
FiltersQueryParameters[CustomFilterStrict](filters=url_query_value)

0 commit comments

Comments
 (0)