Skip to content

Commit 59aeb99

Browse files
🎨 moving folders to workspaces (#6851)
1 parent 6a7b073 commit 59aeb99

File tree

29 files changed

+918
-176
lines changed

29 files changed

+918
-176
lines changed

api/specs/web-server/_folders.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@
2525
FoldersListQueryParams,
2626
FoldersPathParams,
2727
)
28+
from simcore_service_webserver.folders._workspaces_handlers import (
29+
_FolderWorkspacesPathParams,
30+
)
2831

2932
router = APIRouter(
3033
prefix=f"/{API_VTAG}",
@@ -97,3 +100,15 @@ async def delete_folder(
97100
_path: Annotated[FoldersPathParams, Depends()],
98101
):
99102
...
103+
104+
105+
@router.post(
106+
"/folders/{folder_id}/workspaces/{workspace_id}:move",
107+
status_code=status.HTTP_204_NO_CONTENT,
108+
summary="Move folder to the workspace",
109+
tags=["workspaces"],
110+
)
111+
async def move_folder_to_workspace(
112+
_path: Annotated[_FolderWorkspacesPathParams, Depends()],
113+
):
114+
...

api/specs/web-server/_projects_workspaces.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,12 @@
2323
)
2424

2525

26-
@router.put(
27-
"/projects/{project_id}/workspaces/{workspace_id}",
26+
@router.post(
27+
"/projects/{project_id}/workspaces/{workspace_id}:move",
2828
status_code=status.HTTP_204_NO_CONTENT,
2929
summary="Move project to the workspace",
3030
)
31-
async def replace_project_workspace(
31+
async def move_project_to_workspace(
3232
_path: Annotated[_ProjectWorkspacesPathParams, Depends()],
3333
):
3434
...

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

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,16 @@
99
from typing import Annotated, Any, Literal, TypeAlias
1010

1111
from models_library.folders import FolderID
12+
from models_library.utils._original_fastapi_encoders import jsonable_encoder
1213
from models_library.workspaces import WorkspaceID
13-
from pydantic import BeforeValidator, ConfigDict, Field, HttpUrl, field_validator
14+
from pydantic import (
15+
BeforeValidator,
16+
ConfigDict,
17+
Field,
18+
HttpUrl,
19+
PlainSerializer,
20+
field_validator,
21+
)
1422

1523
from ..api_schemas_long_running_tasks.tasks import TaskGet
1624
from ..basic_types import LongTruncatedStr, ShortTruncatedStr
@@ -130,12 +138,22 @@ class ProjectPatch(InputSchema):
130138
name: ShortTruncatedStr | None = Field(default=None)
131139
description: LongTruncatedStr | None = Field(default=None)
132140
thumbnail: Annotated[
133-
HttpUrl | None, BeforeValidator(empty_str_to_none_pre_validator)
141+
HttpUrl | None,
142+
BeforeValidator(empty_str_to_none_pre_validator),
143+
PlainSerializer(lambda x: str(x) if x is not None else None),
134144
] = Field(default=None)
135145
access_rights: dict[GroupIDStr, AccessRights] | None = Field(default=None)
136146
classifiers: list[ClassifierID] | None = Field(default=None)
137147
dev: dict | None = Field(default=None)
138-
ui: StudyUI | None = Field(default=None)
148+
ui: Annotated[
149+
StudyUI | None,
150+
BeforeValidator(empty_str_to_none_pre_validator),
151+
PlainSerializer(
152+
lambda obj: jsonable_encoder(
153+
obj, exclude_unset=True, by_alias=False
154+
) # For the sake of backward compatibility
155+
),
156+
] = Field(default=None)
139157
quality: dict[str, Any] | None = Field(default=None)
140158

141159

packages/pytest-simcore/src/pytest_simcore/helpers/webserver_projects.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ async def create_project(
9595
for group_id, permissions in _access_rights.items():
9696
await update_or_insert_project_group(
9797
app,
98-
new_project["uuid"],
98+
project_id=new_project["uuid"],
9999
group_id=int(group_id),
100100
read=permissions["read"],
101101
write=permissions["write"],

services/director-v2/src/simcore_service_director_v2/modules/db/repositories/comp_tasks/_utils.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import aiopg.sa
77
import arrow
88
from dask_task_models_library.container_tasks.protocol import ContainerEnvsDict
9+
from models_library.api_schemas_catalog.services import ServiceGet
910
from models_library.api_schemas_clusters_keeper.ec2_instances import EC2InstanceTypeGet
1011
from models_library.api_schemas_directorv2.services import (
1112
NodeRequirements,
@@ -89,7 +90,7 @@ async def _get_service_details(
8990
node.version,
9091
product_name,
9192
)
92-
obj: ServiceMetaDataPublished = ServiceMetaDataPublished(**service_details)
93+
obj: ServiceMetaDataPublished = ServiceGet(**service_details)
9394
return obj
9495

9596

services/director-v2/tests/unit/with_dbs/comp_scheduler/test_api_route_computations.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -186,15 +186,29 @@ def _mocked_service_resources(request) -> httpx.Response:
186186
def _mocked_services_details(
187187
request, service_key: str, service_version: str
188188
) -> httpx.Response:
189+
assert "json_schema_extra" in ServiceGet.model_config
190+
assert isinstance(ServiceGet.model_config["json_schema_extra"], dict)
191+
assert isinstance(
192+
ServiceGet.model_config["json_schema_extra"]["examples"], list
193+
)
194+
assert isinstance(
195+
ServiceGet.model_config["json_schema_extra"]["examples"][0], dict
196+
)
197+
data_published = fake_service_details.model_copy(
198+
update={
199+
"key": urllib.parse.unquote(service_key),
200+
"version": service_version,
201+
}
202+
).model_dump(by_alias=True)
203+
data = {
204+
**ServiceGet.model_config["json_schema_extra"]["examples"][0],
205+
**data_published,
206+
}
207+
payload = ServiceGet.model_validate(data)
189208
return httpx.Response(
190209
200,
191210
json=jsonable_encoder(
192-
fake_service_details.model_copy(
193-
update={
194-
"key": urllib.parse.unquote(service_key),
195-
"version": service_version,
196-
}
197-
),
211+
payload,
198212
by_alias=True,
199213
),
200214
)

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

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -578,11 +578,6 @@ qx.Class.define("osparc.dashboard.StudyBrowser", {
578578
const data = e.getData();
579579
const destWorkspaceId = data["workspaceId"];
580580
const destFolderId = data["folderId"];
581-
if (destWorkspaceId !== currentWorkspaceId) {
582-
const msg = this.tr("Moving folders to Shared Workspaces are coming soon");
583-
osparc.FlashMessenger.getInstance().logAs(msg, "WARNING");
584-
return;
585-
}
586581
const moveFolder = () => {
587582
Promise.all([
588583
this.__moveFolderToWorkspace(folderId, destWorkspaceId),

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -288,8 +288,8 @@ qx.Class.define("osparc.data.Resources", {
288288
url: statics.API + "/projects/{studyId}/folders/{folderId}"
289289
},
290290
moveToWorkspace: {
291-
method: "PUT",
292-
url: statics.API + "/projects/{studyId}/workspaces/{workspaceId}"
291+
method: "POST",
292+
url: statics.API + "/projects/{studyId}/workspaces/{workspaceId}:move"
293293
},
294294
}
295295
},
@@ -342,8 +342,8 @@ qx.Class.define("osparc.data.Resources", {
342342
url: statics.API + "/folders/{folderId}"
343343
},
344344
moveToWorkspace: {
345-
method: "PUT",
346-
url: statics.API + "/folders/{folderId}/folders/{workspaceId}"
345+
method: "POST",
346+
url: statics.API + "/folders/{folderId}/folders/{workspaceId}:move"
347347
},
348348
trash: {
349349
method: "POST",

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

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2944,6 +2944,59 @@ paths:
29442944
schema:
29452945
$ref: '#/components/schemas/EnvelopedError'
29462946
description: Service Unavailable
2947+
/v0/folders/{folder_id}/workspaces/{workspace_id}:move:
2948+
post:
2949+
tags:
2950+
- folders
2951+
- workspaces
2952+
summary: Move folder to the workspace
2953+
operationId: move_folder_to_workspace
2954+
parameters:
2955+
- name: folder_id
2956+
in: path
2957+
required: true
2958+
schema:
2959+
type: integer
2960+
exclusiveMinimum: true
2961+
title: Folder Id
2962+
minimum: 0
2963+
- name: workspace_id
2964+
in: path
2965+
required: true
2966+
schema:
2967+
anyOf:
2968+
- type: integer
2969+
exclusiveMinimum: true
2970+
minimum: 0
2971+
- type: 'null'
2972+
title: Workspace Id
2973+
responses:
2974+
'204':
2975+
description: Successful Response
2976+
'404':
2977+
content:
2978+
application/json:
2979+
schema:
2980+
$ref: '#/components/schemas/EnvelopedError'
2981+
description: Not Found
2982+
'403':
2983+
content:
2984+
application/json:
2985+
schema:
2986+
$ref: '#/components/schemas/EnvelopedError'
2987+
description: Forbidden
2988+
'409':
2989+
content:
2990+
application/json:
2991+
schema:
2992+
$ref: '#/components/schemas/EnvelopedError'
2993+
description: Conflict
2994+
'503':
2995+
content:
2996+
application/json:
2997+
schema:
2998+
$ref: '#/components/schemas/EnvelopedError'
2999+
description: Service Unavailable
29473000
/v0/tasks:
29483001
get:
29493002
tags:
@@ -4706,13 +4759,13 @@ paths:
47064759
application/json:
47074760
schema:
47084761
$ref: '#/components/schemas/Envelope_WalletGet_'
4709-
/v0/projects/{project_id}/workspaces/{workspace_id}:
4710-
put:
4762+
/v0/projects/{project_id}/workspaces/{workspace_id}:move:
4763+
post:
47114764
tags:
47124765
- projects
47134766
- workspaces
47144767
summary: Move project to the workspace
4715-
operationId: replace_project_workspace
4768+
operationId: move_project_to_workspace
47164769
parameters:
47174770
- name: project_id
47184771
in: path

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

Lines changed: 60 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import logging
88
from datetime import datetime
9-
from typing import Any, Final, cast
9+
from typing import Final, cast
1010

1111
import sqlalchemy as sa
1212
from aiohttp import web
@@ -33,6 +33,7 @@
3333
from simcore_postgres_database.utils_workspaces_sql import (
3434
create_my_workspace_access_rights_subquery,
3535
)
36+
from simcore_service_webserver.utils import UnSet, as_dict_exclude_unset
3637
from sqlalchemy import func
3738
from sqlalchemy.ext.asyncio import AsyncConnection
3839
from sqlalchemy.orm import aliased
@@ -43,18 +44,9 @@
4344

4445
_logger = logging.getLogger(__name__)
4546

46-
47-
class UnSet:
48-
...
49-
50-
5147
_unset: Final = UnSet()
5248

5349

54-
def as_dict_exclude_unset(**params) -> dict[str, Any]:
55-
return {k: v for k, v in params.items() if not isinstance(v, UnSet)}
56-
57-
5850
_SELECTION_ARGS = (
5951
folders_v2.c.folder_id,
6052
folders_v2.c.name,
@@ -324,6 +316,8 @@ async def update(
324316
parent_folder_id: FolderID | None | UnSet = _unset,
325317
trashed_at: datetime | None | UnSet = _unset,
326318
trashed_explicitly: bool | UnSet = _unset,
319+
workspace_id: WorkspaceID | None | UnSet = _unset,
320+
user_id: UserID | None | UnSet = _unset,
327321
) -> FolderDB:
328322
"""
329323
Batch/single patch of folder/s
@@ -334,6 +328,8 @@ async def update(
334328
parent_folder_id=parent_folder_id,
335329
trashed_at=trashed_at,
336330
trashed_explicitly=trashed_explicitly,
331+
workspace_id=workspace_id,
332+
user_id=user_id,
337333
)
338334

339335
query = (
@@ -467,6 +463,60 @@ async def get_projects_recursively_only_if_user_is_owner(
467463
return [ProjectID(row[0]) async for row in result]
468464

469465

466+
async def get_all_folders_and_projects_ids_recursively(
467+
app: web.Application,
468+
connection: AsyncConnection | None = None,
469+
*,
470+
folder_id: FolderID,
471+
private_workspace_user_id_or_none: UserID | None,
472+
product_name: ProductName,
473+
) -> tuple[list[FolderID], list[ProjectID]]:
474+
"""
475+
The purpose of this function is to retrieve all projects within the provided folder ID.
476+
"""
477+
478+
async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn:
479+
480+
# Step 1: Define the base case for the recursive CTE
481+
base_query = select(
482+
folders_v2.c.folder_id, folders_v2.c.parent_folder_id
483+
).where(
484+
(folders_v2.c.folder_id == folder_id) # <-- specified folder id
485+
& (folders_v2.c.product_name == product_name)
486+
)
487+
folder_hierarchy_cte = base_query.cte(name="folder_hierarchy", recursive=True)
488+
489+
# Step 2: Define the recursive case
490+
folder_alias = aliased(folders_v2)
491+
recursive_query = select(
492+
folder_alias.c.folder_id, folder_alias.c.parent_folder_id
493+
).select_from(
494+
folder_alias.join(
495+
folder_hierarchy_cte,
496+
folder_alias.c.parent_folder_id == folder_hierarchy_cte.c.folder_id,
497+
)
498+
)
499+
500+
# Step 3: Combine base and recursive cases into a CTE
501+
folder_hierarchy_cte = folder_hierarchy_cte.union_all(recursive_query)
502+
503+
# Step 4: Execute the query to get all descendants
504+
final_query = select(folder_hierarchy_cte)
505+
result = await conn.stream(final_query)
506+
# list of tuples [(folder_id, parent_folder_id), ...] ex. [(1, None), (2, 1)]
507+
folder_ids = [item.folder_id async for item in result]
508+
509+
query = select(projects_to_folders.c.project_uuid).where(
510+
(projects_to_folders.c.folder_id.in_(folder_ids))
511+
& (projects_to_folders.c.user_id == private_workspace_user_id_or_none)
512+
)
513+
514+
result = await conn.stream(query)
515+
project_ids = [ProjectID(row.project_uuid) async for row in result]
516+
517+
return folder_ids, project_ids
518+
519+
470520
async def get_folders_recursively(
471521
app: web.Application,
472522
connection: AsyncConnection | None = None,

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

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,9 @@
1818
null_or_none_str_to_none_validator,
1919
)
2020
from models_library.workspaces import WorkspaceID
21-
from pydantic import BeforeValidator, ConfigDict, Field
22-
from servicelib.request_keys import RQT_USERID_KEY
21+
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field
2322

24-
from .._constants import RQ_PRODUCT_KEY
23+
from .._constants import RQ_PRODUCT_KEY, RQT_USERID_KEY
2524

2625
_logger = logging.getLogger(__name__)
2726

@@ -88,3 +87,12 @@ class FolderSearchQueryParams(
8887

8988
class FolderTrashQueryParams(RemoveQueryParams):
9089
...
90+
91+
92+
class _FolderWorkspacesPathParams(BaseModel):
93+
folder_id: FolderID
94+
workspace_id: Annotated[
95+
WorkspaceID | None, BeforeValidator(null_or_none_str_to_none_validator)
96+
] = Field(default=None)
97+
98+
model_config = ConfigDict(extra="forbid")

0 commit comments

Comments
 (0)