diff --git a/README.md b/README.md index cdd64b4de708..08ab8d4df351 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ The SIM-CORE, named **o2S2PARC** – **O**pen **O**nline * The aim of o2S2PARC is to establish a comprehensive, freely accessible, intuitive, and interactive online platform for simulating peripheral nerve system neuromodulation/ stimulation and its impact on organ physiology in a precise and predictive manner. To achieve this, the platform will comprise both state-of-the art and highly detailed animal and human anatomical models with realistic tissue property distributions that make it possible to perform simulations ranging from the molecular scale up to the complexity of the human body. + ## Getting Started A production instance of **o2S2PARC** is running at [oSPARC.io](https://osparc.io). diff --git a/packages/models-library/src/models_library/api_schemas_api_server/functions.py b/packages/models-library/src/models_library/api_schemas_api_server/functions.py index 9cf4441f65b1..988544b13fc0 100644 --- a/packages/models-library/src/models_library/api_schemas_api_server/functions.py +++ b/packages/models-library/src/models_library/api_schemas_api_server/functions.py @@ -15,6 +15,7 @@ FunctionJobCollection, FunctionJobCollectionID, FunctionJobCollectionIDNotFoundError, + FunctionJobCollectionsListFilters, FunctionJobCollectionStatus, FunctionJobID, FunctionJobIDNotFoundError, @@ -53,6 +54,7 @@ "FunctionJobCollectionID", "FunctionJobCollectionIDNotFoundError", "FunctionJobCollectionStatus", + "FunctionJobCollectionsListFilters", "FunctionJobID", "FunctionJobIDNotFoundError", "FunctionJobStatus", diff --git a/packages/models-library/src/models_library/api_schemas_webserver/functions.py b/packages/models-library/src/models_library/api_schemas_webserver/functions.py index b994cfcc4726..7e1e4b99a353 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/functions.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/functions.py @@ -18,6 +18,7 @@ FunctionJobCollection, FunctionJobCollectionID, FunctionJobCollectionIDNotFoundError, + FunctionJobCollectionsListFilters, FunctionJobCollectionStatus, FunctionJobID, FunctionJobIDNotFoundError, @@ -70,6 +71,7 @@ "FunctionJobCollectionIDNotFoundError", "FunctionJobCollectionStatus", "FunctionJobCollectionStatus", + "FunctionJobCollectionsListFilters", "FunctionJobID", "FunctionJobID", "FunctionJobIDNotFoundError", diff --git a/packages/models-library/src/models_library/functions.py b/packages/models-library/src/models_library/functions.py index b13750b4ff03..6dd02d86f35a 100644 --- a/packages/models-library/src/models_library/functions.py +++ b/packages/models-library/src/models_library/functions.py @@ -5,6 +5,8 @@ from common_library.errors_classes import OsparcErrorMixin from models_library import projects +from models_library.basic_regex import UUID_RE_BASE +from models_library.basic_types import ConstrainedStr from models_library.services_types import ServiceKey, ServiceVersion from pydantic import BaseModel, Field @@ -274,3 +276,13 @@ class FunctionJobCollectionDB(BaseModel): class RegisteredFunctionJobCollectionDB(FunctionJobCollectionDB): uuid: FunctionJobCollectionID + + +class FunctionIDString(ConstrainedStr): + pattern = UUID_RE_BASE + + +class FunctionJobCollectionsListFilters(BaseModel): + """Filters for listing function job collections""" + + has_function_id: FunctionIDString | None = None diff --git a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/functions/functions_rpc_interface.py b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/functions/functions_rpc_interface.py index abdd05275a73..5d1a4b756c5f 100644 --- a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/functions/functions_rpc_interface.py +++ b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/functions/functions_rpc_interface.py @@ -9,6 +9,7 @@ FunctionJob, FunctionJobCollection, FunctionJobCollectionID, + FunctionJobCollectionsListFilters, FunctionJobID, FunctionOutputSchema, RegisteredFunction, @@ -146,12 +147,14 @@ async def list_function_job_collections( *, pagination_limit: int, pagination_offset: int, + filters: FunctionJobCollectionsListFilters | None = None, ) -> tuple[list[RegisteredFunctionJobCollection], PageMetaInfoLimitOffset]: result = await rabbitmq_rpc_client.request( WEBSERVER_RPC_NAMESPACE, TypeAdapter(RPCMethodName).validate_python("list_function_job_collections"), pagination_offset=pagination_offset, pagination_limit=pagination_limit, + filters=filters, ) return TypeAdapter( tuple[list[RegisteredFunctionJobCollection], PageMetaInfoLimitOffset] diff --git a/services/api-server/openapi.json b/services/api-server/openapi.json index 8c4f9376a934..839877be699f 100644 --- a/services/api-server/openapi.json +++ b/services/api-server/openapi.json @@ -5695,6 +5695,23 @@ "default": 0, "title": "Offset" } + }, + { + "name": "has_function_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "pattern": "[0-9a-fA-F]{8}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{12}" + }, + { + "type": "null" + } + ], + "title": "Has Function Id" + } } ], "responses": { diff --git a/services/api-server/src/simcore_service_api_server/api/dependencies/_utils.py b/services/api-server/src/simcore_service_api_server/api/dependencies/_utils.py new file mode 100644 index 000000000000..2982df786ad4 --- /dev/null +++ b/services/api-server/src/simcore_service_api_server/api/dependencies/_utils.py @@ -0,0 +1,15 @@ +from typing import Any + +from pydantic.fields import FieldInfo + + +def _get_query_params(field: FieldInfo) -> dict[str, Any]: + params = {} + + if field.description: + params["description"] = field.description + if field.examples: + params["example"] = next( + (example for example in field.examples if "*" in example), field.examples[0] + ) + return params diff --git a/services/api-server/src/simcore_service_api_server/api/dependencies/models_schemas_function_filters.py b/services/api-server/src/simcore_service_api_server/api/dependencies/models_schemas_function_filters.py new file mode 100644 index 000000000000..640d0f9b743d --- /dev/null +++ b/services/api-server/src/simcore_service_api_server/api/dependencies/models_schemas_function_filters.py @@ -0,0 +1,22 @@ +from typing import Annotated + +from fastapi import Query +from models_library.functions import FunctionIDString, FunctionJobCollectionsListFilters + +from ._utils import _get_query_params + + +def get_function_job_collections_filters( + # pylint: disable=unsubscriptable-object + has_function_id: Annotated[ + FunctionIDString | None, + Query( + **_get_query_params( + FunctionJobCollectionsListFilters.model_fields["has_function_id"] + ) + ), + ] = None, +) -> FunctionJobCollectionsListFilters: + return FunctionJobCollectionsListFilters( + has_function_id=has_function_id, + ) diff --git a/services/api-server/src/simcore_service_api_server/api/dependencies/models_schemas_solvers_filters.py b/services/api-server/src/simcore_service_api_server/api/dependencies/models_schemas_solvers_filters.py index 2fafb34f984f..4f09d90947cb 100644 --- a/services/api-server/src/simcore_service_api_server/api/dependencies/models_schemas_solvers_filters.py +++ b/services/api-server/src/simcore_service_api_server/api/dependencies/models_schemas_solvers_filters.py @@ -1,22 +1,10 @@ -from typing import Annotated, Any +from typing import Annotated from fastapi import Query from models_library.basic_types import SafeQueryStr -from pydantic.fields import FieldInfo from ...models.schemas.solvers_filters import SolversListFilters - - -def _get_query_params(field: FieldInfo) -> dict[str, Any]: - params = {} - - if field.description: - params["description"] = field.description - if field.examples: - params["example"] = next( - (example for example in field.examples if "*" in example), field.examples[0] - ) - return params +from ._utils import _get_query_params def get_solvers_filters( diff --git a/services/api-server/src/simcore_service_api_server/api/routes/function_job_collections_routes.py b/services/api-server/src/simcore_service_api_server/api/routes/function_job_collections_routes.py index 1c380022d165..3ece899ec08a 100644 --- a/services/api-server/src/simcore_service_api_server/api/routes/function_job_collections_routes.py +++ b/services/api-server/src/simcore_service_api_server/api/routes/function_job_collections_routes.py @@ -6,6 +6,7 @@ from models_library.api_schemas_webserver.functions import ( FunctionJobCollection, FunctionJobCollectionID, + FunctionJobCollectionsListFilters, FunctionJobCollectionStatus, RegisteredFunctionJob, RegisteredFunctionJobCollection, @@ -17,6 +18,9 @@ from ...services_http.director_v2 import DirectorV2Api from ...services_rpc.wb_api_server import WbApiRpcClient from ..dependencies.authentication import get_current_user_id +from ..dependencies.models_schemas_function_filters import ( + get_function_job_collections_filters, +) from ..dependencies.services import get_api_client from ..dependencies.webserver_rpc import get_wb_api_rpc_client from ._constants import FMSG_CHANGELOG_NEW_IN_VERSION, create_route_description @@ -48,10 +52,14 @@ async def list_function_job_collections( wb_api_rpc: Annotated[WbApiRpcClient, Depends(get_wb_api_rpc_client)], page_params: Annotated[PaginationParams, Depends()], + filters: Annotated[ + FunctionJobCollectionsListFilters, Depends(get_function_job_collections_filters) + ], ): function_job_collection_list, meta = await wb_api_rpc.list_function_job_collections( pagination_offset=page_params.offset, pagination_limit=page_params.limit, + filters=filters, ) return create_page( function_job_collection_list, diff --git a/services/api-server/src/simcore_service_api_server/services_rpc/wb_api_server.py b/services/api-server/src/simcore_service_api_server/services_rpc/wb_api_server.py index 1e70de80648c..c448e0cdec8b 100644 --- a/services/api-server/src/simcore_service_api_server/services_rpc/wb_api_server.py +++ b/services/api-server/src/simcore_service_api_server/services_rpc/wb_api_server.py @@ -14,6 +14,7 @@ FunctionJob, FunctionJobCollection, FunctionJobCollectionID, + FunctionJobCollectionsListFilters, FunctionJobID, FunctionOutputSchema, RegisteredFunction, @@ -324,11 +325,13 @@ async def list_function_job_collections( *, pagination_offset: PageOffsetInt = 0, pagination_limit: PageLimitInt = DEFAULT_NUMBER_OF_ITEMS_PER_PAGE, + filters: FunctionJobCollectionsListFilters | None = None, ) -> tuple[list[RegisteredFunctionJobCollection], PageMetaInfoLimitOffset]: return await functions_rpc_interface.list_function_job_collections( self._client, pagination_offset=pagination_offset, pagination_limit=pagination_limit, + filters=filters, ) async def run_function( diff --git a/services/api-server/tests/unit/api_functions/conftest.py b/services/api-server/tests/unit/api_functions/conftest.py index ecf94cc79ff4..f133d6485d76 100644 --- a/services/api-server/tests/unit/api_functions/conftest.py +++ b/services/api-server/tests/unit/api_functions/conftest.py @@ -16,6 +16,7 @@ FunctionClass, FunctionIDNotFoundError, FunctionJob, + FunctionJobCollection, JSONFunctionInputSchema, JSONFunctionOutputSchema, ProjectFunction, @@ -25,6 +26,7 @@ RegisteredProjectFunction, RegisteredProjectFunctionJob, ) +from models_library.functions import RegisteredFunctionJobCollection from models_library.projects import ProjectID from pytest_mock import MockerFixture from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict @@ -98,7 +100,7 @@ def raise_function_id_not_found() -> FunctionIDNotFoundError: @pytest.fixture -def sample_function( +def mock_function( project_id: ProjectID, sample_input_schema: JSONFunctionInputSchema, sample_output_schema: JSONFunctionOutputSchema, @@ -116,14 +118,14 @@ def sample_function( @pytest.fixture -def sample_registered_function(sample_function: Function) -> RegisteredFunction: - return RegisteredProjectFunction(**{**sample_function.dict(), "uid": str(uuid4())}) +def mock_registered_function(mock_function: Function) -> RegisteredFunction: + return RegisteredProjectFunction(**{**mock_function.dict(), "uid": str(uuid4())}) @pytest.fixture -def sample_function_job(sample_registered_function: RegisteredFunction) -> FunctionJob: +def mock_function_job(mock_registered_function: RegisteredFunction) -> FunctionJob: mock_function_job = { - "function_uid": sample_registered_function.uid, + "function_uid": mock_registered_function.uid, "title": "Test Function Job", "description": "A test function job", "inputs": {"key": "value"}, @@ -135,11 +137,35 @@ def sample_function_job(sample_registered_function: RegisteredFunction) -> Funct @pytest.fixture -def sample_registered_function_job( - sample_function_job: FunctionJob, +def mock_registered_function_job( + mock_function_job: FunctionJob, ) -> RegisteredFunctionJob: return RegisteredProjectFunctionJob( - **{**sample_function_job.dict(), "uid": str(uuid4())} + **{**mock_function_job.dict(), "uid": str(uuid4())} + ) + + +@pytest.fixture +def mock_function_job_collection( + mock_registered_function_job: RegisteredFunctionJob, +) -> FunctionJobCollection: + mock_function_job_collection = { + "title": "Test Function Job Collection", + "description": "A test function job collection", + "function_uid": mock_registered_function_job.function_uid, + "function_class": FunctionClass.PROJECT, + "project_id": str(uuid4()), + "function_job_ids": [mock_registered_function_job.uid for _ in range(5)], + } + return FunctionJobCollection(**mock_function_job_collection) + + +@pytest.fixture +def mock_registered_function_job_collection( + mock_function_job_collection: FunctionJobCollection, +) -> RegisteredFunctionJobCollection: + return RegisteredFunctionJobCollection( + **{**mock_function_job_collection.model_dump(), "uid": str(uuid4())} ) diff --git a/services/api-server/tests/unit/api_functions/test_api_routers_functions.py b/services/api-server/tests/unit/api_functions/test_api_routers_functions.py index 835215c4b4ad..06cf653c1cea 100644 --- a/services/api-server/tests/unit/api_functions/test_api_routers_functions.py +++ b/services/api-server/tests/unit/api_functions/test_api_routers_functions.py @@ -24,16 +24,16 @@ async def test_register_function( client: AsyncClient, mock_handler_in_functions_rpc_interface: Callable[[str, Any], None], - sample_function: ProjectFunction, + mock_function: ProjectFunction, ) -> None: registered_function = RegisteredProjectFunction( - **{**sample_function.model_dump(), "uid": str(uuid4())} + **{**mock_function.model_dump(), "uid": str(uuid4())} ) mock_handler_in_functions_rpc_interface("register_function", registered_function) response = await client.post( f"{API_VTAG}/functions", - json=sample_function.model_dump(mode="json"), + json=mock_function.model_dump(mode="json"), ) assert response.status_code == status.HTTP_200_OK data = response.json() @@ -62,15 +62,15 @@ async def test_register_function_invalid( async def test_get_function( client: AsyncClient, mock_handler_in_functions_rpc_interface: Callable[[str, Any], None], - sample_registered_function: RegisteredProjectFunction, + mock_registered_function: RegisteredProjectFunction, ) -> None: function_id = str(uuid4()) - mock_handler_in_functions_rpc_interface("get_function", sample_registered_function) + mock_handler_in_functions_rpc_interface("get_function", mock_registered_function) response = await client.get(f"{API_VTAG}/functions/{function_id}") assert response.status_code == status.HTTP_200_OK returned_function = RegisteredProjectFunction.model_validate(response.json()) - assert returned_function == sample_registered_function + assert returned_function == mock_registered_function async def test_get_function_not_found( @@ -93,13 +93,13 @@ async def test_get_function_not_found( async def test_list_functions( client: AsyncClient, mock_handler_in_functions_rpc_interface: Callable[[str, Any], None], - sample_registered_function: RegisteredProjectFunction, + mock_registered_function: RegisteredProjectFunction, ) -> None: mock_handler_in_functions_rpc_interface( "list_functions", ( - [sample_registered_function for _ in range(5)], + [mock_registered_function for _ in range(5)], PageMetaInfoLimitOffset(total=5, count=5, limit=10, offset=0), ), ) @@ -110,20 +110,20 @@ async def test_list_functions( assert response.status_code == status.HTTP_200_OK data = response.json()["items"] assert len(data) == 5 - assert data[0]["title"] == sample_registered_function.title + assert data[0]["title"] == mock_registered_function.title async def test_update_function_title( client: AsyncClient, mock_handler_in_functions_rpc_interface: Callable[[str, Any], None], - sample_registered_function: RegisteredProjectFunction, + mock_registered_function: RegisteredProjectFunction, ) -> None: mock_handler_in_functions_rpc_interface( "update_function_title", RegisteredProjectFunction( **{ - **sample_registered_function.model_dump(), + **mock_registered_function.model_dump(), "title": "updated_example_function", } ), @@ -132,7 +132,7 @@ async def test_update_function_title( # Update the function title updated_title = {"title": "updated_example_function"} response = await client.patch( - f"{API_VTAG}/functions/{sample_registered_function.uid}/title", + f"{API_VTAG}/functions/{mock_registered_function.uid}/title", params=updated_title, ) assert response.status_code == status.HTTP_200_OK @@ -143,13 +143,13 @@ async def test_update_function_title( async def test_update_function_description( client: AsyncClient, mock_handler_in_functions_rpc_interface: Callable[[str, Any], None], - sample_registered_function: RegisteredProjectFunction, + mock_registered_function: RegisteredProjectFunction, ) -> None: mock_handler_in_functions_rpc_interface( "update_function_description", RegisteredProjectFunction( **{ - **sample_registered_function.model_dump(), + **mock_registered_function.model_dump(), "description": "updated_example_function", } ), @@ -158,7 +158,7 @@ async def test_update_function_description( # Update the function description updated_description = {"description": "updated_example_function"} response = await client.patch( - f"{API_VTAG}/functions/{sample_registered_function.uid}/description", + f"{API_VTAG}/functions/{mock_registered_function.uid}/description", params=updated_description, ) assert response.status_code == status.HTTP_200_OK @@ -169,54 +169,53 @@ async def test_update_function_description( async def test_get_function_input_schema( client: AsyncClient, mock_handler_in_functions_rpc_interface: Callable[[str, Any], None], - sample_registered_function: RegisteredProjectFunction, + mock_registered_function: RegisteredProjectFunction, ) -> None: - mock_handler_in_functions_rpc_interface("get_function", sample_registered_function) + mock_handler_in_functions_rpc_interface("get_function", mock_registered_function) response = await client.get( - f"{API_VTAG}/functions/{sample_registered_function.uid}/input_schema" + f"{API_VTAG}/functions/{mock_registered_function.uid}/input_schema" ) assert response.status_code == status.HTTP_200_OK data = response.json() assert ( - data["schema_content"] == sample_registered_function.input_schema.schema_content + data["schema_content"] == mock_registered_function.input_schema.schema_content ) async def test_get_function_output_schema( client: AsyncClient, mock_handler_in_functions_rpc_interface: Callable[[str, Any], None], - sample_registered_function: RegisteredProjectFunction, + mock_registered_function: RegisteredProjectFunction, ) -> None: - mock_handler_in_functions_rpc_interface("get_function", sample_registered_function) + mock_handler_in_functions_rpc_interface("get_function", mock_registered_function) response = await client.get( - f"{API_VTAG}/functions/{sample_registered_function.uid}/output_schema" + f"{API_VTAG}/functions/{mock_registered_function.uid}/output_schema" ) assert response.status_code == status.HTTP_200_OK data = response.json() assert ( - data["schema_content"] - == sample_registered_function.output_schema.schema_content + data["schema_content"] == mock_registered_function.output_schema.schema_content ) async def test_validate_function_inputs( client: AsyncClient, mock_handler_in_functions_rpc_interface: Callable[[str, Any], None], - sample_registered_function: RegisteredProjectFunction, + mock_registered_function: RegisteredProjectFunction, ) -> None: - mock_handler_in_functions_rpc_interface("get_function", sample_registered_function) + mock_handler_in_functions_rpc_interface("get_function", mock_registered_function) # Validate inputs validate_payload = {"input1": 10} response = await client.post( - f"{API_VTAG}/functions/{sample_registered_function.uid}:validate_inputs", + f"{API_VTAG}/functions/{mock_registered_function.uid}:validate_inputs", json=validate_payload, ) assert response.status_code == status.HTTP_200_OK @@ -227,13 +226,13 @@ async def test_validate_function_inputs( async def test_delete_function( client: AsyncClient, mock_handler_in_functions_rpc_interface: Callable[[str, Any], None], - sample_registered_function: RegisteredProjectFunction, + mock_registered_function: RegisteredProjectFunction, ) -> None: mock_handler_in_functions_rpc_interface("delete_function", None) # Delete the function response = await client.delete( - f"{API_VTAG}/functions/{sample_registered_function.uid}" + f"{API_VTAG}/functions/{mock_registered_function.uid}" ) assert response.status_code == status.HTTP_200_OK @@ -241,57 +240,57 @@ async def test_delete_function( async def test_register_function_job( client: AsyncClient, mock_handler_in_functions_rpc_interface: Callable[[str, Any], None], - sample_function_job: ProjectFunctionJob, - sample_registered_function_job: RegisteredProjectFunctionJob, + mock_function_job: ProjectFunctionJob, + mock_registered_function_job: RegisteredProjectFunctionJob, ) -> None: """Test the register_function_job endpoint.""" mock_handler_in_functions_rpc_interface( - "register_function_job", sample_registered_function_job + "register_function_job", mock_registered_function_job ) response = await client.post( - f"{API_VTAG}/function_jobs", json=sample_function_job.model_dump(mode="json") + f"{API_VTAG}/function_jobs", json=mock_function_job.model_dump(mode="json") ) assert response.status_code == status.HTTP_200_OK assert ( RegisteredProjectFunctionJob.model_validate(response.json()) - == sample_registered_function_job + == mock_registered_function_job ) async def test_get_function_job( client: AsyncClient, mock_handler_in_functions_rpc_interface: Callable[[str, Any], None], - sample_registered_function_job: RegisteredProjectFunctionJob, + mock_registered_function_job: RegisteredProjectFunctionJob, ) -> None: mock_handler_in_functions_rpc_interface( - "get_function_job", sample_registered_function_job + "get_function_job", mock_registered_function_job ) # Now, get the function job response = await client.get( - f"{API_VTAG}/function_jobs/{sample_registered_function_job.uid}" + f"{API_VTAG}/function_jobs/{mock_registered_function_job.uid}" ) assert response.status_code == status.HTTP_200_OK assert ( RegisteredProjectFunctionJob.model_validate(response.json()) - == sample_registered_function_job + == mock_registered_function_job ) async def test_list_function_jobs( client: AsyncClient, mock_handler_in_functions_rpc_interface: Callable[[str, Any], None], - sample_registered_function_job: RegisteredProjectFunctionJob, + mock_registered_function_job: RegisteredProjectFunctionJob, ) -> None: mock_handler_in_functions_rpc_interface( "list_function_jobs", ( - [sample_registered_function_job for _ in range(5)], + [mock_registered_function_job for _ in range(5)], PageMetaInfoLimitOffset(total=5, count=5, limit=10, offset=0), ), ) @@ -303,28 +302,28 @@ async def test_list_function_jobs( assert len(data) == 5 assert ( RegisteredProjectFunctionJob.model_validate(data[0]) - == sample_registered_function_job + == mock_registered_function_job ) async def test_list_function_jobs_with_function_filter( client: AsyncClient, mock_handler_in_functions_rpc_interface: Callable[[str, Any], None], - sample_registered_function_job: RegisteredProjectFunctionJob, - sample_registered_function: RegisteredProjectFunction, + mock_registered_function_job: RegisteredProjectFunctionJob, + mock_registered_function: RegisteredProjectFunction, ) -> None: mock_handler_in_functions_rpc_interface( "list_function_jobs", ( - [sample_registered_function_job for _ in range(5)], + [mock_registered_function_job for _ in range(5)], PageMetaInfoLimitOffset(total=5, count=5, limit=10, offset=0), ), ) # Now, list function jobs with a filter response = await client.get( - f"{API_VTAG}/functions/{sample_registered_function.uid}/jobs" + f"{API_VTAG}/functions/{mock_registered_function.uid}/jobs" ) assert response.status_code == status.HTTP_200_OK @@ -332,21 +331,21 @@ async def test_list_function_jobs_with_function_filter( assert len(data) == 5 assert ( RegisteredProjectFunctionJob.model_validate(data[0]) - == sample_registered_function_job + == mock_registered_function_job ) async def test_delete_function_job( client: AsyncClient, mock_handler_in_functions_rpc_interface: Callable[[str, Any], None], - sample_registered_function_job: RegisteredProjectFunctionJob, + mock_registered_function_job: RegisteredProjectFunctionJob, ) -> None: mock_handler_in_functions_rpc_interface("delete_function_job", None) # Now, delete the function job response = await client.delete( - f"{API_VTAG}/function_jobs/{sample_registered_function_job.uid}" + f"{API_VTAG}/function_jobs/{mock_registered_function_job.uid}" ) assert response.status_code == status.HTTP_200_OK @@ -416,3 +415,102 @@ async def test_get_function_job_collection( RegisteredFunctionJobCollection.model_validate(response.json()) == mock_registered_function_job_collection ) + + +async def test_list_function_job_collections( + client: AsyncClient, + mock_handler_in_functions_rpc_interface: Callable[[str, Any], None], +) -> None: + mock_registered_function_job_collection = ( + RegisteredFunctionJobCollection.model_validate( + { + "uid": str(uuid4()), + "title": "Test Collection", + "description": "A test function job collection", + "job_ids": [str(uuid4()), str(uuid4())], + } + ) + ) + + mock_handler_in_functions_rpc_interface( + "list_function_job_collections", + ( + [mock_registered_function_job_collection for _ in range(5)], + PageMetaInfoLimitOffset(total=5, count=5, limit=10, offset=0), + ), + ) + + response = await client.get(f"{API_VTAG}/function_job_collections") + assert response.status_code == status.HTTP_200_OK + data = response.json()["items"] + assert len(data) == 5 + assert ( + RegisteredFunctionJobCollection.model_validate(data[0]) + == mock_registered_function_job_collection + ) + + +async def test_delete_function_job_collection( + client: AsyncClient, + mock_handler_in_functions_rpc_interface: Callable[[str, Any], None], + mock_registered_function_job_collection: RegisteredFunctionJobCollection, +) -> None: + + mock_handler_in_functions_rpc_interface("delete_function_job_collection", None) + + # Now, delete the function job collection + response = await client.delete( + f"{API_VTAG}/function_job_collections/{mock_registered_function_job_collection.uid}" + ) + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data is None + + +async def test_get_function_job_collection_jobs( + client: AsyncClient, + mock_handler_in_functions_rpc_interface: Callable[[str, Any], None], + mock_registered_function_job_collection: RegisteredFunctionJobCollection, +) -> None: + + mock_handler_in_functions_rpc_interface( + "get_function_job_collection", mock_registered_function_job_collection + ) + + response = await client.get( + f"{API_VTAG}/function_job_collections/{mock_registered_function_job_collection.uid}/function_jobs" + ) + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data) == len(mock_registered_function_job_collection.job_ids) + + +async def test_list_function_job_collections_with_function_filter( + client: AsyncClient, + mock_handler_in_functions_rpc_interface: Callable[[str, Any], None], + mock_registered_function_job_collection: RegisteredFunctionJobCollection, + mock_registered_function: RegisteredProjectFunction, +) -> None: + + mock_handler_in_functions_rpc_interface( + "list_function_job_collections", + ( + [mock_registered_function_job_collection for _ in range(2)], + PageMetaInfoLimitOffset(total=5, count=2, limit=2, offset=1), + ), + ) + + response = await client.get( + f"{API_VTAG}/function_job_collections?function_id={mock_registered_function.uid}&limit=2&offset=1" + ) + assert response.status_code == status.HTTP_200_OK + data = response.json() + + assert data["total"] == 5 + assert data["limit"] == 2 + assert data["offset"] == 1 + assert len(data["items"]) == 2 + assert ( + RegisteredFunctionJobCollection.model_validate(data["items"][0]) + == mock_registered_function_job_collection + ) diff --git a/services/web/server/src/simcore_service_webserver/functions/_controller/_functions_rpc.py b/services/web/server/src/simcore_service_webserver/functions/_controller/_functions_rpc.py index 9b2f8b97bc1c..b02690fe78f3 100644 --- a/services/web/server/src/simcore_service_webserver/functions/_controller/_functions_rpc.py +++ b/services/web/server/src/simcore_service_webserver/functions/_controller/_functions_rpc.py @@ -9,6 +9,7 @@ FunctionJob, FunctionJobCollection, FunctionJobCollectionIDNotFoundError, + FunctionJobCollectionsListFilters, FunctionJobID, FunctionJobIDNotFoundError, FunctionOutputSchema, @@ -115,11 +116,13 @@ async def list_function_job_collections( app: web.Application, pagination_limit: int, pagination_offset: int, + filters: FunctionJobCollectionsListFilters | None = None, ) -> tuple[list[RegisteredFunctionJobCollection], PageMetaInfoLimitOffset]: return await _functions_service.list_function_job_collections( app=app, pagination_limit=pagination_limit, pagination_offset=pagination_offset, + filters=filters, ) diff --git a/services/web/server/src/simcore_service_webserver/functions/_functions_repository.py b/services/web/server/src/simcore_service_webserver/functions/_functions_repository.py index cea16855c5ed..ded8862c3cdb 100644 --- a/services/web/server/src/simcore_service_webserver/functions/_functions_repository.py +++ b/services/web/server/src/simcore_service_webserver/functions/_functions_repository.py @@ -1,5 +1,6 @@ import json +import sqlalchemy from aiohttp import web from models_library.functions import ( FunctionClass, @@ -9,6 +10,7 @@ FunctionInputSchema, FunctionJobClassSpecificData, FunctionJobCollectionIDNotFoundError, + FunctionJobCollectionsListFilters, FunctionJobID, FunctionJobIDNotFoundError, FunctionOutputs, @@ -18,6 +20,7 @@ RegisteredFunctionJobDB, ) from models_library.rest_pagination import PageMetaInfoLimitOffset +from pydantic import TypeAdapter from simcore_postgres_database.models.funcapi_function_job_collections_table import ( function_job_collections_table, ) @@ -279,21 +282,48 @@ async def list_function_job_collections( *, pagination_limit: int, pagination_offset: int, + filters: FunctionJobCollectionsListFilters | None = None, ) -> tuple[ list[tuple[RegisteredFunctionJobCollectionDB, list[FunctionJobID]]], PageMetaInfoLimitOffset, ]: """ Returns a list of function job collections and their associated job ids. + Filters the collections to include only those that have function jobs with the specified function id if filters.has_function_id is provided. """ + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + filter_condition: sqlalchemy.sql.ColumnElement = sqlalchemy.sql.true() + + if filters and filters.has_function_id: + function_id = TypeAdapter(FunctionID).validate_python( + filters.has_function_id + ) + subquery = ( + function_job_collections_to_function_jobs_table.select() + .with_only_columns( + func.distinct( + function_job_collections_to_function_jobs_table.c.function_job_collection_uuid + ) + ) + .join( + function_jobs_table, + function_job_collections_to_function_jobs_table.c.function_job_uuid + == function_jobs_table.c.uuid, + ) + .where(function_jobs_table.c.function_uuid == function_id) + ) + filter_condition = function_job_collections_table.c.uuid.in_(subquery) total_count_result = await conn.scalar( - func.count().select().select_from(function_job_collections_table) + func.count() + .select() + .select_from(function_job_collections_table) + .where(filter_condition) ) + query = function_job_collections_table.select().where(filter_condition) + result = await conn.stream( - function_job_collections_table.select() - .offset(pagination_offset) - .limit(pagination_limit) + query.offset(pagination_offset).limit(pagination_limit) ) rows = await result.all() if rows is None: diff --git a/services/web/server/src/simcore_service_webserver/functions/_functions_service.py b/services/web/server/src/simcore_service_webserver/functions/_functions_service.py index b99f2ec4c81a..b818e62d5b55 100644 --- a/services/web/server/src/simcore_service_webserver/functions/_functions_service.py +++ b/services/web/server/src/simcore_service_webserver/functions/_functions_service.py @@ -12,6 +12,7 @@ FunctionJobClassSpecificData, FunctionJobCollection, FunctionJobCollectionIDNotFoundError, + FunctionJobCollectionsListFilters, FunctionJobDB, FunctionJobID, FunctionJobIDNotFoundError, @@ -178,12 +179,14 @@ async def list_function_job_collections( app: web.Application, pagination_limit: int, pagination_offset: int, + filters: FunctionJobCollectionsListFilters | None = None, ) -> tuple[list[RegisteredFunctionJobCollection], PageMetaInfoLimitOffset]: returned_function_job_collections, page = ( await _functions_repository.list_function_job_collections( app=app, pagination_limit=pagination_limit, pagination_offset=pagination_offset, + filters=filters, ) ) return [ diff --git a/services/web/server/tests/unit/with_dbs/04/functions_rpc/conftest.py b/services/web/server/tests/unit/with_dbs/04/functions_rpc/conftest.py index b76ed75f2a31..f2b6fe0102dc 100644 --- a/services/web/server/tests/unit/with_dbs/04/functions_rpc/conftest.py +++ b/services/web/server/tests/unit/with_dbs/04/functions_rpc/conftest.py @@ -4,8 +4,13 @@ import pytest +from fastapi.testclient import TestClient from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict from pytest_simcore.helpers.typing_env import EnvVarsDict +from servicelib.rabbitmq import RabbitMQRPCClient +from servicelib.rabbitmq.rpc_interfaces.webserver.functions import ( + functions_rpc_interface as functions_rpc, +) @pytest.fixture @@ -21,3 +26,34 @@ def app_environment( "WEBSERVER_FUNCTIONS": "1", }, ) + + +@pytest.fixture +async def clean_functions(client: TestClient, rpc_client: RabbitMQRPCClient) -> None: + assert client.app + + functions, _ = await functions_rpc.list_functions( + rabbitmq_rpc_client=rpc_client, pagination_limit=100, pagination_offset=0 + ) + for function in functions: + assert function.uid is not None + await functions_rpc.delete_function( + rabbitmq_rpc_client=rpc_client, function_id=function.uid + ) + + +@pytest.fixture +async def clean_function_job_collections( + client: TestClient, rpc_client: RabbitMQRPCClient +) -> None: + assert client.app + + job_collections, _ = await functions_rpc.list_function_job_collections( + rabbitmq_rpc_client=rpc_client, pagination_limit=100, pagination_offset=0 + ) + for function_job_collection in job_collections: + assert function_job_collection.uid is not None + await functions_rpc.delete_function_job_collection( + rabbitmq_rpc_client=rpc_client, + function_job_collection_id=function_job_collection.uid, + ) diff --git a/services/web/server/tests/unit/with_dbs/04/functions_rpc/test_functions_controller_rpc.py b/services/web/server/tests/unit/with_dbs/04/functions_rpc/test_functions_controller_rpc.py index c707345fc277..8e4c358390a0 100644 --- a/services/web/server/tests/unit/with_dbs/04/functions_rpc/test_functions_controller_rpc.py +++ b/services/web/server/tests/unit/with_dbs/04/functions_rpc/test_functions_controller_rpc.py @@ -17,7 +17,10 @@ ProjectFunction, ProjectFunctionJob, ) -from models_library.functions import FunctionIDNotFoundError +from models_library.functions import ( + FunctionIDNotFoundError, + FunctionJobCollectionsListFilters, +) from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict from pytest_simcore.helpers.typing_env import EnvVarsDict from servicelib.rabbitmq import RabbitMQRPCClient @@ -30,21 +33,6 @@ pytest_simcore_core_services_selection = ["rabbit"] -@pytest.fixture -async def clean_functions(client: TestClient, rpc_client: RabbitMQRPCClient) -> None: - assert client.app - # This function is a placeholder for the actual implementation - # that deletes all registered functions from the database. - functions, _ = await functions_rpc.list_functions( - rabbitmq_rpc_client=rpc_client, pagination_limit=100, pagination_offset=0 - ) - for function in functions: - assert function.uid is not None - await functions_rpc.delete_function( - rabbitmq_rpc_client=rpc_client, function_id=function.uid - ) - - @pytest.fixture def app_environment( rabbit_service: RabbitSettings, @@ -606,7 +594,10 @@ async def test_function_job_collection( async def test_list_function_job_collections( - client: TestClient, mock_function: ProjectFunction, rpc_client: RabbitMQRPCClient + client: TestClient, + mock_function: ProjectFunction, + rpc_client: RabbitMQRPCClient, + clean_functions: None, ): assert client.app # Register the function first @@ -653,10 +644,104 @@ async def test_list_function_job_collections( ) # List function job collections - collections, _ = await functions_rpc.list_function_job_collections( - rabbitmq_rpc_client=rpc_client, pagination_limit=1, pagination_offset=1 + collections, page_params = await functions_rpc.list_function_job_collections( + rabbitmq_rpc_client=rpc_client, pagination_limit=2, pagination_offset=1 ) # Assert the list contains the registered collection - assert len(collections) == 1 + assert page_params.count == 2 + assert page_params.total == 3 + assert page_params.offset == 1 + assert len(collections) == 2 + assert collections[0].uid == registered_collections[1].uid + assert collections[1].uid == registered_collections[2].uid + + +async def test_list_function_job_collections_empty( + client: TestClient, + rpc_client: RabbitMQRPCClient, + clean_functions: None, + clean_function_job_collections: None, +): + # List function job collections when none are registered + collections, page_meta = await functions_rpc.list_function_job_collections( + rabbitmq_rpc_client=rpc_client, pagination_limit=10, pagination_offset=0 + ) + + # Assert the list is empty + assert page_meta.count == 0 + assert page_meta.total == 0 + assert page_meta.offset == 0 + assert len(collections) == 0 + + +async def test_list_function_job_collections_filtered_function_id( + client: TestClient, + rpc_client: RabbitMQRPCClient, + mock_function: ProjectFunction, + clean_functions: None, + clean_function_job_collections: None, +): + # Register the function first + registered_function = await functions_rpc.register_function( + rabbitmq_rpc_client=rpc_client, function=mock_function + ) + other_registered_function = await functions_rpc.register_function( + rabbitmq_rpc_client=rpc_client, function=mock_function + ) + + registered_collections = [] + for coll_i in range(5): + if coll_i < 3: + function_id = registered_function.uid + else: + function_id = other_registered_function.uid + # Create a function job collection + function_job_ids = [] + for _ in range(3): + registered_function_job = ProjectFunctionJob( + function_uid=function_id, + title="Test Function Job", + description="A test function job", + project_job_id=uuid4(), + inputs={"input1": "value1"}, + outputs={"output1": "result1"}, + ) + # Register the function job + registered_job = await functions_rpc.register_function_job( + rabbitmq_rpc_client=rpc_client, function_job=registered_function_job + ) + assert registered_job.uid is not None + function_job_ids.append(registered_job.uid) + + function_job_collection = FunctionJobCollection( + title="Test Function Job Collection", + description="A test function job collection", + job_ids=function_job_ids, + ) + + # Register the function job collection + registered_collection = await functions_rpc.register_function_job_collection( + rabbitmq_rpc_client=rpc_client, + function_job_collection=function_job_collection, + ) + registered_collections.append(registered_collection) + + # List function job collections with a specific function ID + collections, page_meta = await functions_rpc.list_function_job_collections( + rabbitmq_rpc_client=rpc_client, + pagination_limit=10, + pagination_offset=1, + filters=FunctionJobCollectionsListFilters( + has_function_id=str(registered_function.uid) + ), + ) + + # Assert the list contains the registered collection + assert page_meta.count == 2 + assert page_meta.total == 3 + assert page_meta.offset == 1 + + assert len(collections) == 2 assert collections[0].uid == registered_collections[1].uid + assert collections[1].uid == registered_collections[2].uid