Skip to content

Commit 2296255

Browse files
committed
🎨 Enhance StudyService with create_job method and update tests for job creation
1 parent 6cc2f0b commit 2296255

File tree

3 files changed

+247
-2
lines changed

3 files changed

+247
-2
lines changed

services/api-server/src/simcore_service_api_server/_service_studies.py

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
from dataclasses import dataclass
2+
from uuid import UUID
23

4+
from models_library.api_schemas_webserver.projects import ProjectPatch
5+
from models_library.api_schemas_webserver.projects_nodes import NodeOutputs
6+
from models_library.function_services_catalog.services import file_picker
37
from models_library.products import ProductName
8+
from models_library.projects import ProjectID
9+
from models_library.projects_nodes import InputID, InputTypes
10+
from models_library.projects_nodes_io import NodeID
411
from models_library.rest_pagination import (
512
MAXIMUM_NUMBER_OF_ITEMS_PER_PAGE,
613
PageMetaInfoLimitOffset,
@@ -11,9 +18,15 @@
1118

1219
from ._service_jobs import JobService
1320
from ._service_utils import check_user_product_consistency
21+
from .api.dependencies.webserver_http import AuthSession
22+
from .api.dependencies.webserver_rpc import WbApiRpcClient
1423
from .models.api_resources import compose_resource_name
15-
from .models.schemas.jobs import Job
24+
from .models.schemas.jobs import Job, JobInputs
1625
from .models.schemas.studies import StudyID
26+
from .services_http.study_job_models_converters import (
27+
create_job_from_study,
28+
get_project_and_file_inputs_from_job_inputs,
29+
)
1730

1831
DEFAULT_PAGINATION_LIMIT = MAXIMUM_NUMBER_OF_ITEMS_PER_PAGE - 1
1932

@@ -23,6 +36,8 @@ class StudyService:
2336
job_service: JobService
2437
user_id: UserID
2538
product_name: ProductName
39+
webserver_api: AuthSession
40+
wb_api_rpc: WbApiRpcClient
2641

2742
def __post_init__(self):
2843
check_user_product_consistency(
@@ -56,3 +71,70 @@ async def list_jobs(
5671
pagination_offset=pagination_offset,
5772
pagination_limit=pagination_limit,
5873
)
74+
75+
async def create_job(
76+
self,
77+
study_id: StudyID,
78+
job_inputs: JobInputs,
79+
hidden: bool = True,
80+
parent_project_uuid: ProjectID | None = None,
81+
parent_node_id: NodeID | None = None,
82+
) -> Job:
83+
"""Creates a job from a study"""
84+
project = await self.webserver_api.clone_project(
85+
project_id=study_id,
86+
hidden=hidden,
87+
parent_project_uuid=parent_project_uuid,
88+
parent_node_id=parent_node_id,
89+
)
90+
job = create_job_from_study(
91+
study_key=study_id, project=project, job_inputs=job_inputs
92+
)
93+
94+
await self.webserver_api.patch_project(
95+
project_id=job.id,
96+
patch_params=ProjectPatch(name=job.name), # type: ignore[arg-type]
97+
)
98+
99+
await self.wb_api_rpc.mark_project_as_job(
100+
product_name=self.product_name,
101+
user_id=self.user_id,
102+
project_uuid=job.id,
103+
job_parent_resource_name=job.runner_name,
104+
)
105+
106+
project_inputs = await self.webserver_api.get_project_inputs(
107+
project_id=project.uuid
108+
)
109+
110+
file_param_nodes = {}
111+
for node_id, node in project.workbench.items():
112+
if (
113+
node.key == file_picker.META.key
114+
and node.outputs is not None
115+
and len(node.outputs) == 0
116+
):
117+
file_param_nodes[node.label] = node_id
118+
119+
file_inputs: dict[InputID, InputTypes] = {}
120+
121+
(
122+
new_project_inputs,
123+
new_project_file_inputs,
124+
) = get_project_and_file_inputs_from_job_inputs(
125+
project_inputs, file_inputs, job_inputs
126+
)
127+
128+
for node_label, file_link in new_project_file_inputs.items():
129+
await self.webserver_api.update_node_outputs(
130+
project_id=project.uuid,
131+
node_id=UUID(file_param_nodes[node_label]),
132+
new_node_outputs=NodeOutputs(outputs={"outFile": file_link}),
133+
)
134+
135+
if len(new_project_inputs) > 0:
136+
await self.webserver_api.update_project_inputs(
137+
project_id=project.uuid, new_inputs=new_project_inputs
138+
)
139+
140+
return job

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,12 +132,16 @@ def solver_service(
132132
@pytest.fixture
133133
def study_service(
134134
job_service: JobService,
135+
auth_session: AuthSession,
136+
wb_api_rpc_client: WbApiRpcClient,
135137
product_name: ProductName,
136138
user_id: UserID,
137139
) -> StudyService:
138140

139141
return StudyService(
140142
job_service=job_service,
143+
webserver_api=auth_session,
144+
wb_api_rpc=wb_api_rpc_client,
141145
user_id=user_id,
142146
product_name=product_name,
143147
)

services/api-server/tests/unit/service/test_service_studies.py

Lines changed: 160 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,15 @@
33
# pylint: disable=unused-argument
44
# pylint: disable=unused-variable
55

6-
from pytest_mock import MockType
6+
import uuid
7+
8+
import pytest
9+
from faker import Faker
10+
from models_library.api_schemas_webserver.projects_nodes import NodeOutputs
11+
from pytest_mock import MockerFixture, MockType
712
from simcore_service_api_server._service_studies import StudyService
13+
from simcore_service_api_server.models.domain.files import File
14+
from simcore_service_api_server.models.schemas.jobs import JobInputs
815
from simcore_service_api_server.models.schemas.studies import StudyID
916

1017

@@ -60,3 +67,155 @@ async def test_list_jobs_with_study_id(
6067
# Check pagination parameters were passed correctly
6168
assert mocked_rpc_client.request.call_args.kwargs["offset"] == page_meta.offset
6269
assert mocked_rpc_client.request.call_args.kwargs["limit"] == page_meta.limit
70+
71+
72+
@pytest.fixture
73+
def job_inputs():
74+
return JobInputs(values={"param1": "value1", "param2": 42})
75+
76+
77+
@pytest.fixture
78+
def mock_project(mocker: MockerFixture):
79+
"""Creates a mock project with a file-picker node in its workbench"""
80+
mock_project = mocker.MagicMock()
81+
mock_project.uuid = uuid.uuid4()
82+
83+
# Create a file-picker node mock
84+
node_id = str(uuid.uuid4())
85+
mock_node = mocker.MagicMock()
86+
mock_node.key = "simcore/services/frontend/file-picker"
87+
mock_node.outputs = {}
88+
mock_node.label = "test_file_picker"
89+
90+
# Set up workbench with the node
91+
mock_project.workbench = {node_id: mock_node}
92+
93+
return {
94+
"project": mock_project,
95+
"node_id": node_id,
96+
"node": mock_node,
97+
}
98+
99+
100+
async def test_create_job(
101+
study_service: StudyService,
102+
job_inputs: JobInputs,
103+
mock_project: dict,
104+
mocker: MockerFixture,
105+
faker: Faker,
106+
):
107+
"""Tests creating a job from a study"""
108+
# Setup
109+
study_id = StudyID(faker.uuid4())
110+
111+
# Mock the webserver_api.clone_project to return our mock project
112+
study_service.webserver_api.clone_project = mocker.AsyncMock(
113+
return_value=mock_project["project"]
114+
)
115+
116+
# Mock other necessary methods
117+
study_service.webserver_api.patch_project = mocker.AsyncMock()
118+
study_service.webserver_api.get_project_inputs = mocker.AsyncMock(return_value={})
119+
study_service.webserver_api.update_node_outputs = mocker.AsyncMock()
120+
study_service.webserver_api.update_project_inputs = mocker.AsyncMock()
121+
study_service.wb_api_rpc.mark_project_as_job = mocker.AsyncMock()
122+
123+
# Execute
124+
job = await study_service.create_job(
125+
study_id=study_id,
126+
job_inputs=job_inputs,
127+
)
128+
129+
# Assert
130+
# Check that the job was created and has the expected properties
131+
assert job is not None
132+
133+
# Verify clone_project was called correctly
134+
study_service.webserver_api.clone_project.assert_awaited_once_with(
135+
project_id=study_id,
136+
hidden=True,
137+
parent_project_uuid=None,
138+
parent_node_id=None,
139+
)
140+
141+
# Verify patch_project was called to update the job name
142+
study_service.webserver_api.patch_project.assert_awaited_once()
143+
assert (
144+
study_service.webserver_api.patch_project.call_args[1]["project_id"] == job.id
145+
)
146+
147+
# Verify mark_project_as_job was called
148+
study_service.wb_api_rpc.mark_project_as_job.assert_awaited_once()
149+
assert (
150+
study_service.wb_api_rpc.mark_project_as_job.call_args[1]["user_id"]
151+
== study_service.user_id
152+
)
153+
assert (
154+
study_service.wb_api_rpc.mark_project_as_job.call_args[1]["product_name"]
155+
== study_service.product_name
156+
)
157+
assert (
158+
study_service.wb_api_rpc.mark_project_as_job.call_args[1]["project_uuid"]
159+
== job.id
160+
)
161+
162+
163+
async def test_create_job_with_inputs(
164+
study_service: StudyService,
165+
mock_project: dict,
166+
mocker: MockerFixture,
167+
faker: Faker,
168+
):
169+
"""Tests creating a job with inputs that are processed properly"""
170+
# Setup
171+
study_id = StudyID(faker.uuid4())
172+
173+
job_inputs = JobInputs(
174+
values={
175+
"param1": "test_value",
176+
"file_param": File.model_json_schema()["examples"][0],
177+
}
178+
)
179+
180+
# Mock clone_project
181+
study_service.webserver_api.clone_project = mocker.AsyncMock(
182+
return_value=mock_project["project"]
183+
)
184+
185+
# Mock other methods
186+
study_service.webserver_api.patch_project = mocker.AsyncMock()
187+
study_service.webserver_api.get_project_inputs = mocker.AsyncMock(return_value={})
188+
study_service.webserver_api.update_node_outputs = mocker.AsyncMock()
189+
study_service.webserver_api.update_project_inputs = mocker.AsyncMock()
190+
study_service.wb_api_rpc.mark_project_as_job = mocker.AsyncMock()
191+
192+
# Mock get_project_and_file_inputs_from_job_inputs to return test data
193+
project_inputs = {"param1": "test_value"}
194+
file_inputs = {"test_file_picker": File.model_json_schema()["examples"][0]}
195+
196+
mocker.patch(
197+
"simcore_service_api_server.services_http.study_job_models_converters.get_project_and_file_inputs_from_job_inputs",
198+
return_value=(project_inputs, file_inputs),
199+
)
200+
201+
# Execute
202+
job = await study_service.create_job(
203+
study_id=study_id,
204+
job_inputs=job_inputs,
205+
)
206+
207+
# Assert
208+
assert job is not None
209+
210+
# Verify update_node_outputs was called for file inputs
211+
study_service.webserver_api.update_node_outputs.assert_awaited_once()
212+
call_args = study_service.webserver_api.update_node_outputs.call_args[1]
213+
assert call_args["project_id"] == mock_project["project"].uuid
214+
assert isinstance(call_args["new_node_outputs"], NodeOutputs)
215+
assert "outFile" in call_args["new_node_outputs"].outputs
216+
217+
# Verify update_project_inputs was called with project inputs
218+
study_service.webserver_api.update_project_inputs.assert_awaited_once_with(
219+
project_id=mock_project["project"].uuid,
220+
new_inputs=project_inputs,
221+
)

0 commit comments

Comments
 (0)