Skip to content

Commit 549a572

Browse files
committed
Merge branch 'master' into 7846-require-parent-info-when-running-function
2 parents d273dfa + 5e3f6be commit 549a572

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",
@@ -7834,6 +7853,36 @@
78347853
],
78357854
"title": "Body_abort_multipart_upload_v0_files__file_id__abort_post"
78367855
},
7856+
"Body_clone_study_v0_studies__study_id__clone_post": {
7857+
"properties": {
7858+
"title": {
7859+
"anyOf": [
7860+
{
7861+
"type": "string"
7862+
},
7863+
{
7864+
"type": "null"
7865+
}
7866+
],
7867+
"title": "Title",
7868+
"empty": true
7869+
},
7870+
"description": {
7871+
"anyOf": [
7872+
{
7873+
"type": "string"
7874+
},
7875+
{
7876+
"type": "null"
7877+
}
7878+
],
7879+
"title": "Description",
7880+
"empty": true
7881+
}
7882+
},
7883+
"type": "object",
7884+
"title": "Body_clone_study_v0_studies__study_id__clone_post"
7885+
},
78377886
"Body_complete_multipart_upload_v0_files__file_id__complete_post": {
78387887
"properties": {
78397888
"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)