Skip to content

Commit 37ce5ae

Browse files
🎨 moving projects between workspaces (#6312)
1 parent 1c16908 commit 37ce5ae

File tree

11 files changed

+524
-0
lines changed

11 files changed

+524
-0
lines changed
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
""" Helper script to automatically generate OAS
2+
3+
This OAS are the source of truth
4+
"""
5+
6+
# pylint: disable=redefined-outer-name
7+
# pylint: disable=unused-argument
8+
# pylint: disable=unused-variable
9+
# pylint: disable=too-many-arguments
10+
11+
12+
from typing import Annotated
13+
14+
from fastapi import APIRouter, Depends, status
15+
from simcore_service_webserver._meta import API_VTAG
16+
from simcore_service_webserver.projects._workspaces_handlers import (
17+
_ProjectWorkspacesPathParams,
18+
)
19+
20+
router = APIRouter(
21+
prefix=f"/{API_VTAG}",
22+
tags=["projects", "workspaces"],
23+
)
24+
25+
26+
@router.put(
27+
"/projects/{project_id}/workspaces/{workspace_id}",
28+
status_code=status.HTTP_204_NO_CONTENT,
29+
summary="Move project to the workspace",
30+
)
31+
async def replace_project_workspace(
32+
_path: Annotated[_ProjectWorkspacesPathParams, Depends()],
33+
):
34+
...

api/specs/web-server/openapi.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"_projects_states",
4343
"_projects_tags",
4444
"_projects_wallet",
45+
"_projects_workspaces",
4546
"_publications",
4647
"_resource_usage",
4748
"_statics",

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3482,6 +3482,32 @@ paths:
34823482
application/json:
34833483
schema:
34843484
$ref: '#/components/schemas/Envelope_WalletGet_'
3485+
/v0/projects/{project_id}/workspaces/{workspace_id}:
3486+
put:
3487+
tags:
3488+
- projects
3489+
- workspaces
3490+
summary: Move project to the workspace
3491+
operationId: replace_project_workspace
3492+
parameters:
3493+
- required: true
3494+
schema:
3495+
title: Project Id
3496+
type: string
3497+
format: uuid
3498+
name: project_id
3499+
in: path
3500+
- required: true
3501+
schema:
3502+
title: Workspace Id
3503+
exclusiveMinimum: true
3504+
type: integer
3505+
minimum: 0
3506+
name: workspace_id
3507+
in: path
3508+
responses:
3509+
'204':
3510+
description: Successful Response
34853511
/v0/publications/service-submission:
34863512
post:
34873513
tags:

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,8 @@ async def create_project( # pylint: disable=too-many-arguments,too-many-branche
287287
user_id=user_id if workspace_id is None else None,
288288
workspace_id=workspace_id,
289289
)
290+
# Folder ID is not part of the project resource
291+
predefined_project.pop("folderId")
290292

291293
if from_study:
292294
# 1.1 prepare copy

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,15 @@ async def delete_project_to_folder(
9898
& (projects_to_folders.c.user_id == private_workspace_user_id_or_none)
9999
)
100100
)
101+
102+
103+
async def delete_all_project_to_folder_by_project_id(
104+
app: web.Application,
105+
project_id: ProjectID,
106+
) -> None:
107+
async with get_database_engine(app).acquire() as conn:
108+
await conn.execute(
109+
projects_to_folders.delete().where(
110+
projects_to_folders.c.project_uuid == f"{project_id}"
111+
)
112+
)

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,3 +190,15 @@ async def delete_project_group(
190190
& (project_to_groups.c.gid == group_id)
191191
)
192192
)
193+
194+
195+
async def delete_all_project_groups(
196+
app: web.Application,
197+
project_id: ProjectID,
198+
) -> None:
199+
async with get_database_engine(app).acquire() as conn:
200+
await conn.execute(
201+
project_to_groups.delete().where(
202+
project_to_groups.c.project_uuid == f"{project_id}"
203+
)
204+
)
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import logging
2+
3+
from aiohttp import web
4+
from models_library.products import ProductName
5+
from models_library.projects import ProjectID
6+
from models_library.users import UserID
7+
from models_library.workspaces import WorkspaceID
8+
9+
from ..projects._access_rights_api import get_user_project_access_rights
10+
from ..users.api import get_user
11+
from ..workspaces.api import check_user_workspace_access
12+
from . import _folders_db as project_to_folders_db
13+
from . import _groups_db as project_groups_db
14+
from .db import APP_PROJECT_DBAPI, ProjectDBAPI
15+
from .exceptions import ProjectInvalidRightsError
16+
17+
_logger = logging.getLogger(__name__)
18+
19+
20+
async def move_project_into_workspace(
21+
app: web.Application,
22+
*,
23+
user_id: UserID,
24+
project_id: ProjectID,
25+
workspace_id: WorkspaceID | None,
26+
product_name: ProductName,
27+
) -> None:
28+
project_api: ProjectDBAPI = app[APP_PROJECT_DBAPI]
29+
30+
# 1. User needs to have delete permission on project
31+
project_access_rights = await get_user_project_access_rights(
32+
app, project_id=project_id, user_id=user_id, product_name=product_name
33+
)
34+
if project_access_rights.delete is False:
35+
raise ProjectInvalidRightsError(user_id=user_id, project_uuid=project_id)
36+
37+
# 2. User needs to have write permission on workspace
38+
if workspace_id:
39+
await check_user_workspace_access(
40+
app,
41+
user_id=user_id,
42+
workspace_id=workspace_id,
43+
product_name=product_name,
44+
permission="write",
45+
)
46+
47+
# 3. Delete project to folders (for everybody)
48+
await project_to_folders_db.delete_all_project_to_folder_by_project_id(
49+
app,
50+
project_id=project_id,
51+
)
52+
53+
# 4. Update workspace ID on the project resource
54+
await project_api.patch_project(
55+
project_uuid=project_id,
56+
new_partial_project_data={"workspace_id": workspace_id},
57+
)
58+
59+
# 5. Remove all project permissions, leave only the user who moved the project
60+
user = await get_user(app, user_id=user_id)
61+
await project_groups_db.delete_all_project_groups(app, project_id=project_id)
62+
await project_groups_db.update_or_insert_project_group(
63+
app,
64+
project_id=project_id,
65+
group_id=user["primary_gid"],
66+
read=True,
67+
write=True,
68+
delete=True,
69+
)
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import functools
2+
import logging
3+
4+
from aiohttp import web
5+
from models_library.projects import ProjectID
6+
from models_library.utils.common_validators import null_or_none_str_to_none_validator
7+
from models_library.workspaces import WorkspaceID
8+
from pydantic import BaseModel, Extra, validator
9+
from servicelib.aiohttp.requests_validation import parse_request_path_parameters_as
10+
from servicelib.aiohttp.typing_extension import Handler
11+
from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON
12+
13+
from .._meta import api_version_prefix as VTAG
14+
from ..folders.errors import FolderAccessForbiddenError, FolderNotFoundError
15+
from ..login.decorators import login_required
16+
from ..security.decorators import permission_required
17+
from ..workspaces.errors import WorkspaceAccessForbiddenError, WorkspaceNotFoundError
18+
from . import _workspaces_api
19+
from ._common_models import RequestContext
20+
from .exceptions import ProjectInvalidRightsError, ProjectNotFoundError
21+
22+
_logger = logging.getLogger(__name__)
23+
24+
25+
def _handle_projects_workspaces_exceptions(handler: Handler):
26+
@functools.wraps(handler)
27+
async def wrapper(request: web.Request) -> web.StreamResponse:
28+
try:
29+
return await handler(request)
30+
31+
except (
32+
ProjectNotFoundError,
33+
FolderNotFoundError,
34+
WorkspaceNotFoundError,
35+
) as exc:
36+
raise web.HTTPNotFound(reason=f"{exc}") from exc
37+
38+
except (
39+
ProjectInvalidRightsError,
40+
FolderAccessForbiddenError,
41+
WorkspaceAccessForbiddenError,
42+
) as exc:
43+
raise web.HTTPForbidden(reason=f"{exc}") from exc
44+
45+
return wrapper
46+
47+
48+
routes = web.RouteTableDef()
49+
50+
51+
class _ProjectWorkspacesPathParams(BaseModel):
52+
project_id: ProjectID
53+
workspace_id: WorkspaceID | None
54+
55+
class Config:
56+
extra = Extra.forbid
57+
58+
# validators
59+
_null_or_none_str_to_none_validator = validator(
60+
"workspace_id", allow_reuse=True, pre=True
61+
)(null_or_none_str_to_none_validator)
62+
63+
64+
@routes.put(
65+
f"/{VTAG}/projects/{{project_id}}/workspaces/{{workspace_id}}",
66+
name="replace_project_workspace",
67+
)
68+
@login_required
69+
@permission_required("project.workspaces.*")
70+
@_handle_projects_workspaces_exceptions
71+
async def replace_project_workspace(request: web.Request):
72+
req_ctx = RequestContext.parse_obj(request)
73+
path_params = parse_request_path_parameters_as(
74+
_ProjectWorkspacesPathParams, request
75+
)
76+
77+
await _workspaces_api.move_project_into_workspace(
78+
app=request.app,
79+
user_id=req_ctx.user_id,
80+
project_id=path_params.project_id,
81+
workspace_id=path_params.workspace_id,
82+
product_name=req_ctx.product_name,
83+
)
84+
raise web.HTTPNoContent(content_type=MIMETYPE_APPLICATION_JSON)

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
_states_handlers,
2222
_tags_handlers,
2323
_wallets_handlers,
24+
_workspaces_handlers,
2425
)
2526
from ._observer import setup_project_observer_events
2627
from ._projects_access import setup_projects_access
@@ -59,5 +60,6 @@ def setup_projects(app: web.Application) -> bool:
5960
app.router.add_routes(_wallets_handlers.routes)
6061
app.router.add_routes(_folders_handlers.routes)
6162
app.router.add_routes(_projects_nodes_pricing_unit_handlers.routes)
63+
app.router.add_routes(_workspaces_handlers.routes)
6264

6365
return True

services/web/server/src/simcore_service_webserver/security/_authz_access_roles.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ class PermissionDict(TypedDict, total=False):
7373
"project.tag.*",
7474
"project.template.create",
7575
"project.wallet.*",
76+
"project.workspaces.*",
7677
"resource-usage.read",
7778
"tag.crud.*",
7879
"user.apikey.*",

0 commit comments

Comments
 (0)