Skip to content

Commit 5e3f6be

Browse files
🎨 Enhance study cloning endpoint to allow modifying title, description, hidden (#7892)
1 parent 522bfc6 commit 5e3f6be

File tree

4 files changed

+178
-3
lines changed

4 files changed

+178
-3
lines changed

services/api-server/openapi.json

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4459,6 +4459,16 @@
44594459
"title": "Study Id"
44604460
}
44614461
},
4462+
{
4463+
"name": "hidden",
4464+
"in": "query",
4465+
"required": false,
4466+
"schema": {
4467+
"type": "boolean",
4468+
"default": false,
4469+
"title": "Hidden"
4470+
}
4471+
},
44624472
{
44634473
"name": "x-simcore-parent-project-uuid",
44644474
"in": "header",
@@ -4494,6 +4504,15 @@
44944504
}
44954505
}
44964506
],
4507+
"requestBody": {
4508+
"content": {
4509+
"application/json": {
4510+
"schema": {
4511+
"$ref": "#/components/schemas/Body_clone_study_v0_studies__study_id__clone_post"
4512+
}
4513+
}
4514+
}
4515+
},
44974516
"responses": {
44984517
"201": {
44994518
"description": "Successful Response",
@@ -7762,6 +7781,36 @@
77627781
],
77637782
"title": "Body_abort_multipart_upload_v0_files__file_id__abort_post"
77647783
},
7784+
"Body_clone_study_v0_studies__study_id__clone_post": {
7785+
"properties": {
7786+
"title": {
7787+
"anyOf": [
7788+
{
7789+
"type": "string"
7790+
},
7791+
{
7792+
"type": "null"
7793+
}
7794+
],
7795+
"title": "Title",
7796+
"empty": true
7797+
},
7798+
"description": {
7799+
"anyOf": [
7800+
{
7801+
"type": "string"
7802+
},
7803+
{
7804+
"type": "null"
7805+
}
7806+
],
7807+
"title": "Description",
7808+
"empty": true
7809+
}
7810+
},
7811+
"type": "object",
7812+
"title": "Body_clone_study_v0_studies__study_id__clone_post"
7813+
},
77657814
"Body_complete_multipart_upload_v0_files__file_id__complete_post": {
77667815
"properties": {
77677816
"client_file": {

services/api-server/src/simcore_service_api_server/api/routes/studies.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import logging
22
from typing import Annotated, Final
33

4-
from fastapi import APIRouter, Depends, Header, status
4+
from fastapi import APIRouter, Body, Depends, Header, Query, status
55
from fastapi_pagination.api import create_page
6-
from models_library.api_schemas_webserver.projects import ProjectGet
6+
from models_library.api_schemas_webserver.projects import ProjectGet, ProjectPatch
7+
from models_library.basic_types import LongTruncatedStr, ShortTruncatedStr
78
from models_library.projects import ProjectID
89
from models_library.projects_nodes_io import NodeID
910

@@ -94,13 +95,24 @@ async def clone_study(
9495
webserver_api: Annotated[AuthSession, Depends(get_webserver_session)],
9596
x_simcore_parent_project_uuid: Annotated[ProjectID | None, Header()] = None,
9697
x_simcore_parent_node_id: Annotated[NodeID | None, Header()] = None,
98+
hidden: Annotated[bool, Query()] = False,
99+
title: Annotated[ShortTruncatedStr | None, Body(empty=True)] = None,
100+
description: Annotated[LongTruncatedStr | None, Body(empty=True)] = None,
97101
):
98102
project: ProjectGet = await webserver_api.clone_project(
99103
project_id=study_id,
100-
hidden=False,
104+
hidden=hidden,
101105
parent_project_uuid=x_simcore_parent_project_uuid,
102106
parent_node_id=x_simcore_parent_node_id,
103107
)
108+
if title or description:
109+
patch_params = ProjectPatch(
110+
name=title,
111+
description=description,
112+
)
113+
await webserver_api.patch_project(
114+
project_id=study_id, patch_params=patch_params
115+
)
104116
return _create_study_from_project(project)
105117

106118

services/api-server/tests/unit/api_studies/test_api_routes_studies.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
# pylint: disable=unused-variable
44

55

6+
import json
67
from collections.abc import Callable
78
from pathlib import Path
89
from typing import Any, TypedDict
@@ -188,6 +189,7 @@ def clone_project_side_effect(request: httpx.Request):
188189
_headers[X_SIMCORE_PARENT_PROJECT_UUID] = f"{parent_project_id}"
189190
if parent_node_id is not None:
190191
_headers[X_SIMCORE_PARENT_NODE_ID] = f"{parent_node_id}"
192+
191193
resp = await client.post(
192194
f"/{API_VTAG}/studies/{study_id}:clone", headers=_headers, auth=auth
193195
)
@@ -197,6 +199,97 @@ def clone_project_side_effect(request: httpx.Request):
197199
assert resp.status_code == status.HTTP_201_CREATED
198200

199201

202+
# string length limits: https://github.com/ITISFoundation/osparc-simcore/blob/master/packages/models-library/src/models_library/api_schemas_webserver/projects.py#L242
203+
@pytest.mark.parametrize("hidden", [True, False, None])
204+
@pytest.mark.parametrize(
205+
"title, description, expected_status_code",
206+
[
207+
(
208+
_faker.text(max_nb_chars=600),
209+
_faker.text(max_nb_chars=65536),
210+
status.HTTP_201_CREATED,
211+
),
212+
("a" * 999, "b" * 99999, status.HTTP_201_CREATED),
213+
(None, None, status.HTTP_201_CREATED),
214+
],
215+
ids=[
216+
"valid_title_and_description",
217+
"very_long_title_and_description",
218+
"no_title_or_description",
219+
],
220+
)
221+
async def test_clone_study_with_title(
222+
client: httpx.AsyncClient,
223+
auth: httpx.BasicAuth,
224+
study_id: StudyID,
225+
mocked_webserver_rest_api_base: MockRouter,
226+
patch_webserver_long_running_project_tasks: Callable[[MockRouter], MockRouter],
227+
mock_webserver_patch_project: Callable[
228+
[
229+
MockRouter,
230+
],
231+
MockRouter,
232+
],
233+
hidden: bool | None,
234+
title: str | None,
235+
description: str | None,
236+
expected_status_code: int,
237+
):
238+
# Mocks /projects
239+
patch_webserver_long_running_project_tasks(mocked_webserver_rest_api_base)
240+
mock_webserver_patch_project(mocked_webserver_rest_api_base)
241+
242+
create_callback = mocked_webserver_rest_api_base["create_projects"].side_effect
243+
assert create_callback is not None
244+
patch_callback = mocked_webserver_rest_api_base["project_patch"].side_effect
245+
assert patch_callback is not None
246+
247+
def clone_project_side_effect(request: httpx.Request):
248+
if hidden is not None:
249+
_hidden = request.url.params.get("hidden")
250+
assert _hidden == str(hidden).lower()
251+
return create_callback(request)
252+
253+
def patch_project_side_effect(request: httpx.Request, *args, **kwargs):
254+
body = json.loads(request.content.decode("utf-8"))
255+
if title is not None:
256+
_name = body.get("name")
257+
assert _name is not None and _name in title
258+
if description is not None:
259+
_description = body.get("description")
260+
assert _description is not None and _description in description
261+
return patch_callback(request, *args, **kwargs)
262+
263+
mocked_webserver_rest_api_base["create_projects"].side_effect = (
264+
clone_project_side_effect
265+
)
266+
mocked_webserver_rest_api_base["project_patch"].side_effect = (
267+
patch_project_side_effect
268+
)
269+
270+
query = dict()
271+
if hidden is not None:
272+
query["hidden"] = str(hidden).lower()
273+
274+
body = dict()
275+
if hidden is not None:
276+
body["hidden"] = hidden
277+
if title is not None:
278+
body["title"] = title
279+
if description is not None:
280+
body["description"] = description
281+
282+
resp = await client.post(
283+
f"/{API_VTAG}/studies/{study_id}:clone", auth=auth, json=body, params=query
284+
)
285+
286+
assert mocked_webserver_rest_api_base["create_projects"].called
287+
if title or description:
288+
assert mocked_webserver_rest_api_base["project_patch"].called
289+
290+
assert resp.status_code == expected_status_code
291+
292+
200293
async def test_clone_study_not_found(
201294
client: httpx.AsyncClient,
202295
auth: httpx.BasicAuth,

services/api-server/tests/unit/conftest.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -715,6 +715,27 @@ def _mock(webserver_mock_router: MockRouter) -> MockRouter:
715715
return _mock
716716

717717

718+
@pytest.fixture
719+
def mock_webserver_patch_project(
720+
app: FastAPI, faker: Faker, services_mocks_enabled: bool
721+
) -> Callable[[MockRouter], MockRouter]:
722+
settings: ApplicationSettings = app.state.settings
723+
assert settings.API_SERVER_WEBSERVER is not None
724+
725+
def _mock(webserver_mock_router: MockRouter) -> MockRouter:
726+
def _patch_project(request: httpx.Request, *args, **kwargs):
727+
return httpx.Response(status.HTTP_200_OK)
728+
729+
if services_mocks_enabled:
730+
webserver_mock_router.patch(
731+
path__regex=r"/projects/(?P<project_id>[\w-]+)$",
732+
name="project_patch",
733+
).mock(side_effect=_patch_project)
734+
return webserver_mock_router
735+
736+
return _mock
737+
738+
718739
@pytest.fixture
719740
def openapi_dev_specs(project_slug_dir: Path) -> dict[str, Any]:
720741
openapi_file = (project_slug_dir / "openapi-dev.json").resolve()

0 commit comments

Comments
 (0)