diff --git a/packages/models-library/src/models_library/rpc/webserver/projects.py b/packages/models-library/src/models_library/rpc/webserver/projects.py index 010c1c41d84f..195358a72132 100644 --- a/packages/models-library/src/models_library/rpc/webserver/projects.py +++ b/packages/models-library/src/models_library/rpc/webserver/projects.py @@ -29,7 +29,7 @@ class ProjectJobRpcGet(BaseModel): workbench: NodesDict # timestamps - creation_at: datetime + created_at: datetime modified_at: datetime # Specific to jobs @@ -43,12 +43,30 @@ def _update_json_schema_extra(schema: JsonDict) -> None: "examples": [ { "uuid": "12345678-1234-5678-1234-123456789012", - "name": "My project", - "description": "My project description", + "name": "A solver job", + "description": "A description of a solver job with a single node", "workbench": {f"{uuid4()}": n for n in nodes_examples[2:3]}, - "creation_at": "2023-01-01T00:00:00Z", + "created_at": "2023-01-01T00:00:00Z", "modified_at": "2023-01-01T00:00:00Z", - "job_parent_resource_name": "solvers/foo/release/1.2.3", + "job_parent_resource_name": "solvers/simcore%2Fservices%2Fcomp%2Fitis%2Fsleeper/releases/2.0.2", + }, + { + "uuid": "00000000-1234-5678-1234-123456789012", + "name": "A study job", + "description": "A description of a study job with many node", + "workbench": {f"{uuid4()}": n for n in nodes_examples}, + "created_at": "2023-02-01T00:00:00Z", + "modified_at": "2023-02-01T00:00:00Z", + "job_parent_resource_name": "studies/96642f2a-a72c-11ef-8776-02420a00087d", + }, + { + "uuid": "00000000-0000-5678-1234-123456789012", + "name": "A program job", + "description": "A program of a solver job with a single node", + "workbench": {f"{uuid4()}": n for n in nodes_examples[2:3]}, + "created_at": "2023-03-01T00:00:00Z", + "modified_at": "2023-03-01T00:00:00Z", + "job_parent_resource_name": "program/simcore%2Fservices%2Fdynamic%2Fjupyter/releases/5.0.2", }, ] } diff --git a/packages/models-library/src/models_library/utils/common_validators.py b/packages/models-library/src/models_library/utils/common_validators.py index 7caf338e61b1..c55db09c5f52 100644 --- a/packages/models-library/src/models_library/utils/common_validators.py +++ b/packages/models-library/src/models_library/utils/common_validators.py @@ -1,4 +1,4 @@ -""" Reusable validators +"""Reusable validators Example: @@ -22,10 +22,19 @@ class MyModel(BaseModel): from common_library.json_serialization import json_loads from orjson import JSONDecodeError -from pydantic import BaseModel +from pydantic import BaseModel, BeforeValidator from pydantic.alias_generators import to_camel +def trim_string_before(max_length: int) -> BeforeValidator: + def _trim(value: str): + if isinstance(value, str): + return value[:max_length] + return value + + return BeforeValidator(_trim) + + def empty_str_to_none_pre_validator(value: Any): if isinstance(value, str) and value.strip() == "": return None diff --git a/packages/models-library/tests/test_utils_common_validators.py b/packages/models-library/tests/test_utils_common_validators.py index db9df708b0f4..5212f5d5babc 100644 --- a/packages/models-library/tests/test_utils_common_validators.py +++ b/packages/models-library/tests/test_utils_common_validators.py @@ -1,4 +1,5 @@ from enum import Enum +from typing import Annotated import pytest from models_library.utils.common_validators import ( @@ -6,8 +7,9 @@ empty_str_to_none_pre_validator, none_to_empty_str_pre_validator, null_or_none_str_to_none_validator, + trim_string_before, ) -from pydantic import BaseModel, ValidationError, field_validator +from pydantic import BaseModel, StringConstraints, ValidationError, field_validator def test_enums_pre_validator(): @@ -89,3 +91,67 @@ class Model(BaseModel): model = Model.model_validate({"message": ""}) assert model == Model.model_validate({"message": ""}) + + +def test_trim_string_before(): + max_length = 10 + + class ModelWithTrim(BaseModel): + text: Annotated[str, trim_string_before(max_length=max_length)] + + # Test with string shorter than max_length + short_text = "Short" + model = ModelWithTrim(text=short_text) + assert model.text == short_text + + # Test with string equal to max_length + exact_text = "1234567890" # 10 characters + model = ModelWithTrim(text=exact_text) + assert model.text == exact_text + + # Test with string longer than max_length + long_text = "This is a very long text that should be trimmed" + model = ModelWithTrim(text=long_text) + assert model.text == long_text[:max_length] + assert len(model.text) == max_length + + # Test with non-string value (should be left unchanged) + class ModelWithTrimOptional(BaseModel): + text: Annotated[str | None, trim_string_before(max_length=max_length)] + + model = ModelWithTrimOptional(text=None) + assert model.text is None + + +def test_trim_string_before_with_string_constraints(): + max_length = 10 + + class ModelWithTrimAndConstraints(BaseModel): + text: Annotated[ + str | None, + StringConstraints( + max_length=max_length + ), # NOTE: order does not matter for validation but has an effect in the openapi schema + trim_string_before(max_length=max_length), + ] + + # Check that the OpenAPI schema contains the string constraint + schema = ModelWithTrimAndConstraints.model_json_schema() + assert schema["properties"]["text"] == { + "anyOf": [{"maxLength": max_length, "type": "string"}, {"type": "null"}], + "title": "Text", + } + + # Test with string longer than max_length + # This should pass because trim_string_before runs first and trims the input + # before StringConstraints validation happens + long_text = "This is a very long text that should be trimmed" + model = ModelWithTrimAndConstraints(text=long_text) + assert model.text is not None + assert model.text == long_text[:max_length] + assert len(model.text) == max_length + + # Test with string exactly at max_length + exact_text = "1234567890" # 10 characters + model = ModelWithTrimAndConstraints(text=exact_text) + assert model.text == exact_text diff --git a/packages/pytest-simcore/src/pytest_simcore/helpers/webserver_rpc_server.py b/packages/pytest-simcore/src/pytest_simcore/helpers/webserver_rpc_server.py index 5bdbb2336b25..ca645218579a 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/webserver_rpc_server.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/webserver_rpc_server.py @@ -8,7 +8,10 @@ from models_library.products import ProductName from models_library.projects import ProjectID from models_library.rest_pagination import PageOffsetInt -from models_library.rpc.webserver.projects import PageRpcProjectJobRpcGet +from models_library.rpc.webserver.projects import ( + PageRpcProjectJobRpcGet, + ProjectJobRpcGet, +) from models_library.rpc_pagination import ( DEFAULT_NUMBER_OF_ITEMS_PER_PAGE, PageLimitInt, @@ -54,19 +57,28 @@ async def list_projects_marked_as_jobs( offset: PageOffsetInt = 0, limit: PageLimitInt = DEFAULT_NUMBER_OF_ITEMS_PER_PAGE, # filters - job_parent_resource_name_filter: str | None = None, + job_parent_resource_name_prefix: str | None = None, ) -> PageRpcProjectJobRpcGet: assert rpc_client assert product_name assert user_id - if job_parent_resource_name_filter: - assert not job_parent_resource_name_filter.startswith("/") + if job_parent_resource_name_prefix: + assert not job_parent_resource_name_prefix.startswith("/") + assert not job_parent_resource_name_prefix.endswith("%") + assert not job_parent_resource_name_prefix.startswith("%") - items = PageRpcProjectJobRpcGet.model_json_schema()["examples"] + items = [ + item + for item in ProjectJobRpcGet.model_json_schema()["examples"] + if job_parent_resource_name_prefix is None + or item.get("job_parent_resource_name").startswith( + job_parent_resource_name_prefix + ) + ] return PageRpcProjectJobRpcGet.create( - items[offset, : offset + limit], + items[offset : offset + limit], total=len(items), limit=limit, offset=offset, diff --git a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/projects.py b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/projects.py index ccfaff4028cc..f2e261b7d6a4 100644 --- a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/projects.py +++ b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/projects.py @@ -52,7 +52,7 @@ async def list_projects_marked_as_jobs( offset: PageOffsetInt = 0, limit: PageLimitInt = DEFAULT_NUMBER_OF_ITEMS_PER_PAGE, # filters - job_parent_resource_name_filter: str | None = None, + job_parent_resource_name_prefix: str | None = None, ) -> PageRpcProjectJobRpcGet: result = await rpc_client.request( WEBSERVER_RPC_NAMESPACE, @@ -61,7 +61,7 @@ async def list_projects_marked_as_jobs( user_id=user_id, offset=offset, limit=limit, - job_parent_resource_name_filter=job_parent_resource_name_filter, + job_parent_resource_name_prefix=job_parent_resource_name_prefix, ) assert TypeAdapter(PageRpcProjectJobRpcGet).validate_python(result) # nosec return cast(PageRpcProjectJobRpcGet, result) diff --git a/services/api-server/docs/api-server.drawio.svg b/services/api-server/docs/api-server.drawio.svg new file mode 100644 index 000000000000..440d1dcaaa7d --- /dev/null +++ b/services/api-server/docs/api-server.drawio.svg @@ -0,0 +1,745 @@ + + + + + + + + + + + + + + + + CONTROLLER + + + + + + CONTROLLER + + + + + + + + + + + + + + + SERVICE + + + + + + SERVICE + + + + + + + + + + + + + + + REPOSITORY + + + + + + REPOSITORY + + + + + + + + + + + + + + + CLIENTS + + + + + + CLIENTS + + + + + + + + + + + + + + + + CONTROLLER + + + + + + CONTROLLER + + + + + + + + + + + + + + + SERVICE + + + + + + SERVICE + + + + + + + + + + + + + + + REPOSITORY + + + + + + REPOSITORY + + + + + + + + + + + + + + + CLIENTS + + + + + + CLIENTS + + + + + + + + + + + + + + + + + + + rest + + + + + + rest + + + + + + + + + + + + + + + + + + + rpc + + + + + + rpc + + + + + + + + + + + + + + + projects + + + + + + projects + + + + + + + + + + + + + + + + CONTROLLER + + + + + + CONTROLLER + + + + + + + + + + + + + + + SERVICE + + + + + + SERVICE + + + + + + + + + + + + + + + REPOSITORY + + + + + + REPOSITORY + + + + + + + + + + + + + + + CLIENTS + + + + + + CLIENTS + + + + + + + + + + + + + + + rest + + + + + + rest + + + + + + + + + + + + + + + rpc + + + + + + rpc + + + + + + + + + + + + + + + + postgres + + + + + + postgres + + + + + + + + + + + + + + + + + + + + + + + SolverService + + + + + + SolverService + + + + + + + + + + + + + + + CatalogService + + + + + + CatalogService + + + + + + + + + + + + + + + + rabbitmq-rpc + + + + + + rabbitmq-rpc + + + + + + + + + + + + + + + + httpx + + + + + + httpx + + + + + + + + + + + + + + + AuthSession + + + + + + AuthSession + + + + + + + + + + + + + + + + rabbitmq-rpc + + + + + + rabbitmq-rpc + + + + + + + + + + + + + + + WbApiRpcClient + + + + + + WbApiRpcClient + + + + + + + + + + + + + + + + asyncpg + + + + + + asyncpg + + + + + + + + + + + + + + + AsyncEngine + + + + + + AsyncEngine + + + + + + + + + + + + + + + simcore_service_catalog + + + + + + simcore_ser... + + + + + + + + + + + + + + + simcore_service_webserver + + + + + + simcore_ser... + + + + + + + + + + + + + + + simcore_service_api_server + + + + + + simcore_ser... + + + + + + + + + + + + + + + + + + + /solvers + + + + + + /solvers + + + + + + + + + + + + + + + + + + + ProgramsService + + + + + + ProgramsService + + + + + + + + + + + + + + + + + + + /programs + + + + + + /programs + + + + + + + + + + Text is not SVG - cannot display + + + + diff --git a/services/api-server/openapi.json b/services/api-server/openapi.json index ec0da749b275..d8e32bbd4736 100644 --- a/services/api-server/openapi.json +++ b/services/api-server/openapi.json @@ -1256,140 +1256,6 @@ } } }, - "/v0/programs": { - "get": { - "tags": [ - "programs" - ], - "summary": "List Programs", - "description": "Lists the latest of all available programs", - "operationId": "list_programs", - "security": [ - { - "HTTPBasic": [] - } - ], - "parameters": [ - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "maximum": 50, - "minimum": 1, - "default": 20, - "title": "Limit" - } - }, - { - "name": "offset", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "minimum": 0, - "default": 0, - "title": "Offset" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Page_Program_" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/v0/programs/{program_key}/releases": { - "get": { - "tags": [ - "programs" - ], - "summary": "List Program History", - "description": "Lists the latest of all available programs", - "operationId": "list_program_history", - "security": [ - { - "HTTPBasic": [] - } - ], - "parameters": [ - { - "name": "program_key", - "in": "path", - "required": true, - "schema": { - "type": "string", - "pattern": "^simcore/services/dynamic/([a-z0-9][a-z0-9_.-]*/)*([a-z0-9-_]+[a-z0-9])$", - "title": "Program Key" - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "maximum": 50, - "minimum": 1, - "default": 20, - "title": "Limit" - } - }, - { - "name": "offset", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "minimum": 0, - "default": 0, - "title": "Offset" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Page_Program_" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, "/v0/programs/{program_key}/releases/{version}": { "get": { "tags": [ @@ -1642,68 +1508,6 @@ ] } }, - "/v0/solvers/page": { - "get": { - "tags": [ - "solvers" - ], - "summary": "Get Solvers Page", - "description": "Lists all available solvers (latest version) with pagination", - "operationId": "get_solvers_page", - "security": [ - { - "HTTPBasic": [] - } - ], - "parameters": [ - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "maximum": 50, - "minimum": 1, - "default": 20, - "title": "Limit" - } - }, - { - "name": "offset", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "minimum": 0, - "default": 0, - "title": "Offset" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Page_Solver_" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, "/v0/solvers/releases": { "get": { "tags": [ @@ -2017,77 +1821,6 @@ } } }, - "/v0/solvers/{solver_key}/releases/page": { - "get": { - "tags": [ - "solvers" - ], - "summary": "Get Solver Releases Page", - "operationId": "get_solver_releases_page", - "security": [ - { - "HTTPBasic": [] - } - ], - "parameters": [ - { - "name": "solver_key", - "in": "path", - "required": true, - "schema": { - "type": "string", - "pattern": "^simcore/services/comp/([a-z0-9][a-z0-9_.-]*/)*([a-z0-9-_]+[a-z0-9])$", - "title": "Solver Key" - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "maximum": 50, - "minimum": 1, - "default": 20, - "title": "Limit" - } - }, - { - "name": "offset", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "minimum": 0, - "default": 0, - "title": "Offset" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Page_Solver_" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, "/v0/solvers/{solver_key}/releases/{version}": { "get": { "tags": [ @@ -7750,124 +7483,6 @@ ], "title": "Page[LicensedItemGet]" }, - "Page_Program_": { - "properties": { - "items": { - "items": { - "$ref": "#/components/schemas/Program" - }, - "type": "array", - "title": "Items" - }, - "total": { - "anyOf": [ - { - "type": "integer", - "minimum": 0 - }, - { - "type": "null" - } - ], - "title": "Total" - }, - "limit": { - "anyOf": [ - { - "type": "integer", - "minimum": 1 - }, - { - "type": "null" - } - ], - "title": "Limit" - }, - "offset": { - "anyOf": [ - { - "type": "integer", - "minimum": 0 - }, - { - "type": "null" - } - ], - "title": "Offset" - }, - "links": { - "$ref": "#/components/schemas/Links" - } - }, - "type": "object", - "required": [ - "items", - "total", - "limit", - "offset", - "links" - ], - "title": "Page[Program]" - }, - "Page_Solver_": { - "properties": { - "items": { - "items": { - "$ref": "#/components/schemas/Solver" - }, - "type": "array", - "title": "Items" - }, - "total": { - "anyOf": [ - { - "type": "integer", - "minimum": 0 - }, - { - "type": "null" - } - ], - "title": "Total" - }, - "limit": { - "anyOf": [ - { - "type": "integer", - "minimum": 1 - }, - { - "type": "null" - } - ], - "title": "Limit" - }, - "offset": { - "anyOf": [ - { - "type": "integer", - "minimum": 0 - }, - { - "type": "null" - } - ], - "title": "Offset" - }, - "links": { - "$ref": "#/components/schemas/Links" - } - }, - "type": "object", - "required": [ - "items", - "total", - "limit", - "offset", - "links" - ], - "title": "Page[Solver]" - }, "Page_Study_": { "properties": { "items": { @@ -8116,7 +7731,7 @@ "anyOf": [ { "type": "string", - "maxLength": 500 + "maxLength": 1000 }, { "type": "null" @@ -8260,7 +7875,7 @@ "anyOf": [ { "type": "string", - "maxLength": 500 + "maxLength": 1000 }, { "type": "null" diff --git a/services/api-server/src/simcore_service_api_server/_service_solvers.py b/services/api-server/src/simcore_service_api_server/_service_solvers.py index 18120ff6a60e..81144072f568 100644 --- a/services/api-server/src/simcore_service_api_server/_service_solvers.py +++ b/services/api-server/src/simcore_service_api_server/_service_solvers.py @@ -1,44 +1,59 @@ -from typing import Annotated - from common_library.pagination_tools import iter_pagination_params -from fastapi import Depends from models_library.api_schemas_catalog.services import ServiceListFilters from models_library.basic_types import VersionStr from models_library.products import ProductName +from models_library.projects_nodes import Node from models_library.rest_pagination import ( MAXIMUM_NUMBER_OF_ITEMS_PER_PAGE, PageMetaInfoLimitOffset, + PageOffsetInt, ) +from models_library.rpc_pagination import PageLimitInt from models_library.services_enums import ServiceType from models_library.services_history import ServiceRelease from models_library.users import UserID from packaging.version import Version from pydantic import NonNegativeInt, PositiveInt +from simcore_service_api_server.exceptions.custom_errors import ( + SolverServiceListJobsFiltersError, +) +from .models.api_resources import compose_resource_name +from .models.schemas.jobs import Job, JobInputs from .models.schemas.solvers import Solver, SolverKeyId +from .services_http.solver_job_models_converters import ( + create_job_inputs_from_node_inputs, +) from .services_rpc.catalog import CatalogService +from .services_rpc.wb_api_server import WbApiRpcClient DEFAULT_PAGINATION_LIMIT = MAXIMUM_NUMBER_OF_ITEMS_PER_PAGE - 1 class SolverService: _catalog_service: CatalogService + _webserver_client: WbApiRpcClient - def __init__(self, catalog_service: Annotated[CatalogService, Depends()]): + def __init__( + self, + catalog_service: CatalogService, + webserver_client: WbApiRpcClient, + ): self._catalog_service = catalog_service + self._webserver_client = webserver_client async def get_solver( self, *, - user_id: UserID, - name: SolverKeyId, - version: VersionStr, product_name: ProductName, + user_id: UserID, + solver_key: SolverKeyId, + solver_version: VersionStr, ) -> Solver: service = await self._catalog_service.get( user_id=user_id, - name=name, - version=version, + name=solver_key, + version=solver_version, product_name=product_name, ) assert ( # nosec @@ -50,9 +65,9 @@ async def get_solver( async def get_latest_release( self, *, + product_name: str, user_id: int, solver_key: SolverKeyId, - product_name: str, ) -> Solver: service_releases: list[ServiceRelease] = [] for page_params in iter_pagination_params(limit=DEFAULT_PAGINATION_LIMIT): @@ -76,6 +91,76 @@ async def get_latest_release( return Solver.create_from_service(service) + async def list_jobs( + self, + *, + product_name: ProductName, + user_id: UserID, + # filters + solver_key: SolverKeyId | None = None, + solver_version: VersionStr | None = None, + # pagination + offset: PageOffsetInt = 0, + limit: PageLimitInt = DEFAULT_PAGINATION_LIMIT, + ) -> tuple[list[Job], PageMetaInfoLimitOffset]: + """Lists all solver jobs for a user with pagination""" + + # 1. Compose job parent resource name prefix + collection_or_resource_ids = [ + "solvers", # solver_id, "releases", solver_version, "jobs", + ] + if solver_key: + collection_or_resource_ids.append(solver_key) + if solver_version: + collection_or_resource_ids.append("releases") + collection_or_resource_ids.append(solver_version) + elif solver_version: + raise SolverServiceListJobsFiltersError + + job_parent_resource_name_prefix = compose_resource_name( + *collection_or_resource_ids + ) + + # 2. List projects marked as jobs + projects_page = await self._webserver_client.list_projects_marked_as_jobs( + product_name=product_name, + user_id=user_id, + offset=offset, + limit=limit, + job_parent_resource_name_prefix=job_parent_resource_name_prefix, + ) + + # 3. Convert projects to jobs + jobs: list[Job] = [] + for project_job in projects_page.data: + + assert ( # nosec + len(project_job.workbench) == 1 + ), "Expected only one solver node in workbench" + + solver_node: Node = next(iter(project_job.workbench.values())) + job_inputs: JobInputs = create_job_inputs_from_node_inputs( + inputs=solver_node.inputs or {} + ) + assert project_job.job_parent_resource_name # nosec + + jobs.append( + Job( + id=project_job.uuid, + name=Job.compose_resource_name( + project_job.job_parent_resource_name, project_job.uuid + ), + inputs_checksum=job_inputs.compute_checksum(), + created_at=project_job.created_at, + runner_name=project_job.job_parent_resource_name, + url=None, + runner_url=None, + outputs_url=None, + ) + ) + + return jobs, projects_page.meta + async def solver_release_history( self, *, diff --git a/services/api-server/src/simcore_service_api_server/api/dependencies/services.py b/services/api-server/src/simcore_service_api_server/api/dependencies/services.py index 902432867c6e..8f3c2c51d7e3 100644 --- a/services/api-server/src/simcore_service_api_server/api/dependencies/services.py +++ b/services/api-server/src/simcore_service_api_server/api/dependencies/services.py @@ -1,10 +1,17 @@ """Dependences with any other services (except webserver)""" from collections.abc import Callable +from typing import Annotated -from fastapi import HTTPException, Request, status +from fastapi import Depends, HTTPException, Request, status +from servicelib.rabbitmq import RabbitMQRPCClient +from ..._service_solvers import SolverService +from ...services_rpc.catalog import CatalogService +from ...services_rpc.wb_api_server import WbApiRpcClient from ...utils.client_base import BaseServiceClientApi +from .rabbitmq import get_rabbitmq_rpc_client +from .webserver_rpc import get_wb_api_rpc_client def get_api_client(client_type: type[BaseServiceClientApi]) -> Callable: @@ -30,3 +37,28 @@ def _get_client_from_app(request: Request) -> BaseServiceClientApi: return client_obj return _get_client_from_app + + +def get_catalog_service( + rpc_client: Annotated[RabbitMQRPCClient, Depends(get_rabbitmq_rpc_client)], +): + """ + "Assembles" the CatalogService layer to the RabbitMQ client + in the context of the rest controller (i.e. api/dependencies) + """ + return CatalogService(client=rpc_client) + + +def get_solver_service( + catalog_service: Annotated[CatalogService, Depends(get_catalog_service)], + webserver_client: Annotated[WbApiRpcClient, Depends(get_wb_api_rpc_client)], +) -> SolverService: + """ + "Assembles" the SolverService layer to the underlying service and client interfaces + in the context of the rest controller (i.e. api/dependencies) + """ + + return SolverService( + catalog_service=catalog_service, + webserver_client=webserver_client, + ) diff --git a/services/api-server/src/simcore_service_api_server/api/routes/programs.py b/services/api-server/src/simcore_service_api_server/api/routes/programs.py index 5c94ebbf2b04..2610aa4074cc 100644 --- a/services/api-server/src/simcore_service_api_server/api/routes/programs.py +++ b/services/api-server/src/simcore_service_api_server/api/routes/programs.py @@ -21,7 +21,11 @@ from ..._service_job import JobService from ..._service_programs import ProgramService -from ...api.routes._constants import DEFAULT_MAX_STRING_LENGTH +from ...api.routes._constants import ( + DEFAULT_MAX_STRING_LENGTH, + FMSG_CHANGELOG_NEW_IN_VERSION, + create_route_description, +) from ...models.basic_types import VersionStr from ...models.pagination import Page, PaginationParams from ...models.schemas.jobs import Job, JobInputs @@ -35,6 +39,13 @@ @router.get( "", response_model=Page[Program], + description=create_route_description( + base="Lists the latest of all available programs", + changelog=[ + FMSG_CHANGELOG_NEW_IN_VERSION.format("0.8"), + ], + ), + include_in_schema=False, # TO BE RELEASED in 0.8 ) async def list_programs( user_id: Annotated[PositiveInt, Depends(get_current_user_id)], @@ -43,7 +54,6 @@ async def list_programs( product_name: Annotated[str, Depends(get_product_name)], page_params: Annotated[PaginationParams, Depends()], ): - """Lists the latest of all available programs""" programs, page_meta = await program_service.list_latest_programs( user_id=user_id, product_name=product_name, @@ -68,6 +78,13 @@ async def list_programs( @router.get( "/{program_key:path}/releases", response_model=Page[Program], + description=create_route_description( + base="Lists the latest of all available programs", + changelog=[ + FMSG_CHANGELOG_NEW_IN_VERSION.format("0.8"), + ], + ), + include_in_schema=False, # TO BE RELEASED in 0.8 ) async def list_program_history( program_key: ProgramKeyId, @@ -77,7 +94,6 @@ async def list_program_history( product_name: Annotated[str, Depends(get_product_name)], page_params: Annotated[PaginationParams, Depends()], ): - """Lists the latest of all available programs""" programs, page_meta = await program_service.list_program_history( program_key=program_key, user_id=user_id, diff --git a/services/api-server/src/simcore_service_api_server/api/routes/solvers.py b/services/api-server/src/simcore_service_api_server/api/routes/solvers.py index 0bab65213261..b9d7cb015d91 100644 --- a/services/api-server/src/simcore_service_api_server/api/routes/solvers.py +++ b/services/api-server/src/simcore_service_api_server/api/routes/solvers.py @@ -22,6 +22,7 @@ from ...services_rpc.catalog import CatalogService from ..dependencies.application import get_reverse_url_mapper from ..dependencies.authentication import get_current_user_id, get_product_name +from ..dependencies.services import get_solver_service from ..dependencies.webserver_http import AuthSession, get_webserver_session from ._constants import ( FMSG_CHANGELOG_NEW_IN_VERSION, @@ -97,15 +98,21 @@ async def list_solvers( @router.get( "/page", response_model=Page[Solver], + description=create_route_description( + base="Lists the latest version of all available solvers (paginated)", + changelog=[ + FMSG_CHANGELOG_NEW_IN_VERSION.format("0.8"), + ], + ), + include_in_schema=False, # TO BE RELEASED in 0.8 ) async def get_solvers_page( page_params: Annotated[PaginationParams, Depends()], user_id: Annotated[int, Depends(get_current_user_id)], product_name: Annotated[str, Depends(get_product_name)], - solver_service: Annotated[SolverService, Depends(SolverService)], + solver_service: Annotated[SolverService, Depends(get_solver_service)], url_for: Annotated[Callable, Depends(get_reverse_url_mapper)], ): - """Lists all available solvers (latest version) with pagination""" solvers, page_meta = await solver_service.latest_solvers( user_id=user_id, product_name=product_name, @@ -143,7 +150,7 @@ async def get_solvers_page( ) async def list_solvers_releases( user_id: Annotated[int, Depends(get_current_user_id)], - solver_service: Annotated[SolverService, Depends(SolverService)], + solver_service: Annotated[SolverService, Depends(get_solver_service)], url_for: Annotated[Callable, Depends(get_reverse_url_mapper)], product_name: Annotated[str, Depends(get_product_name)], ): @@ -189,10 +196,10 @@ async def list_solvers_releases( async def get_solver( solver_key: SolverKeyId, user_id: Annotated[int, Depends(get_current_user_id)], - solver_service: Annotated[SolverService, Depends(SolverService)], + solver_service: Annotated[SolverService, Depends(get_solver_service)], url_for: Annotated[Callable, Depends(get_reverse_url_mapper)], product_name: Annotated[str, Depends(get_product_name)], -) -> Solver: +): """Gets latest release of a solver""" # IMPORTANT: by adding /latest, we avoid changing the order of this entry in the router list # otherwise, {solver_key:path} will override and consume any of the paths that follow. @@ -223,7 +230,7 @@ async def get_solver( async def list_solver_releases( solver_key: SolverKeyId, user_id: Annotated[int, Depends(get_current_user_id)], - solver_service: Annotated[SolverService, Depends(SolverService)], + solver_service: Annotated[SolverService, Depends(get_solver_service)], url_for: Annotated[Callable, Depends(get_reverse_url_mapper)], product_name: Annotated[str, Depends(get_product_name)], ): @@ -254,6 +261,13 @@ async def list_solver_releases( @router.get( "/{solver_key:path}/releases/page", response_model=Page[Solver], + description=create_route_description( + base="Lists all releases of a give solver (paginated)", + changelog=[ + FMSG_CHANGELOG_NEW_IN_VERSION.format("0.8"), + ], + ), + include_in_schema=False, # TO BE RELEASED in 0.8 ) async def get_solver_releases_page( solver_key: SolverKeyId, @@ -261,7 +275,7 @@ async def get_solver_releases_page( user_id: Annotated[int, Depends(get_current_user_id)], product_name: Annotated[str, Depends(get_product_name)], url_for: Annotated[Callable, Depends(get_reverse_url_mapper)], - solver_service: Annotated[SolverService, Depends(SolverService)], + solver_service: Annotated[SolverService, Depends(get_solver_service)], ): solvers, page_meta = await solver_service.solver_release_history( user_id=user_id, @@ -293,16 +307,16 @@ async def get_solver_release( solver_key: SolverKeyId, version: VersionStr, user_id: Annotated[int, Depends(get_current_user_id)], - solver_service: Annotated[SolverService, Depends(SolverService)], + solver_service: Annotated[SolverService, Depends(get_solver_service)], url_for: Annotated[Callable, Depends(get_reverse_url_mapper)], product_name: Annotated[str, Depends(get_product_name)], -) -> Solver: +): """Gets a specific release of a solver""" try: solver: Solver = await solver_service.get_solver( user_id=user_id, - name=solver_key, - version=version, + solver_key=solver_key, + solver_version=version, product_name=product_name, ) diff --git a/services/api-server/src/simcore_service_api_server/api/routes/solvers_jobs.py b/services/api-server/src/simcore_service_api_server/api/routes/solvers_jobs.py index f594e6373389..e7af6aa16e2b 100644 --- a/services/api-server/src/simcore_service_api_server/api/routes/solvers_jobs.py +++ b/services/api-server/src/simcore_service_api_server/api/routes/solvers_jobs.py @@ -34,7 +34,7 @@ ) from ..dependencies.application import get_reverse_url_mapper from ..dependencies.authentication import get_current_user_id, get_product_name -from ..dependencies.services import get_api_client +from ..dependencies.services import get_api_client, get_solver_service from ..dependencies.webserver_http import AuthSession, get_webserver_session from ._constants import ( FMSG_CHANGELOG_ADDED_IN_VERSION, @@ -91,7 +91,7 @@ async def create_solver_job( version: VersionStr, inputs: JobInputs, user_id: Annotated[PositiveInt, Depends(get_current_user_id)], - solver_service: Annotated[SolverService, Depends()], + solver_service: Annotated[SolverService, Depends(get_solver_service)], job_service: Annotated[JobService, Depends()], url_for: Annotated[Callable, Depends(get_reverse_url_mapper)], product_name: Annotated[str, Depends(get_product_name)], @@ -107,8 +107,8 @@ async def create_solver_job( # ensures user has access to solver solver = await solver_service.get_solver( user_id=user_id, - name=solver_key, - version=version, + solver_key=solver_key, + solver_version=version, product_name=product_name, ) job, _ = await job_service.create_job( diff --git a/services/api-server/src/simcore_service_api_server/api/routes/solvers_jobs_getters.py b/services/api-server/src/simcore_service_api_server/api/routes/solvers_jobs_getters.py index a0e751fa2dcb..8c4bea421b87 100644 --- a/services/api-server/src/simcore_service_api_server/api/routes/solvers_jobs_getters.py +++ b/services/api-server/src/simcore_service_api_server/api/routes/solvers_jobs_getters.py @@ -14,10 +14,11 @@ from models_library.projects_nodes_io import BaseFileLink from models_library.users import UserID from models_library.wallets import ZERO_CREDITS -from pydantic import NonNegativeInt +from pydantic import HttpUrl, NonNegativeInt from pydantic.types import PositiveInt from servicelib.fastapi.requests_decorators import cancel_on_disconnect from servicelib.logging_utils import log_context +from simcore_service_api_server.models.api_resources import parse_resources_ids from sqlalchemy.ext.asyncio import AsyncEngine from ..._service_solvers import SolverService @@ -54,7 +55,7 @@ from ..dependencies.authentication import get_current_user_id, get_product_name from ..dependencies.database import get_db_asyncpg_engine from ..dependencies.rabbitmq import get_log_check_timeout, get_log_distributor -from ..dependencies.services import get_api_client +from ..dependencies.services import get_api_client, get_solver_service from ..dependencies.webserver_http import AuthSession, get_webserver_session from ._constants import ( FMSG_CHANGELOG_NEW_IN_VERSION, @@ -116,9 +117,77 @@ **DEFAULT_BACKEND_SERVICE_STATUS_CODES, } + +def _update_job_urls( + job: Job, + solver_key: SolverKeyId, + solver_version: VersionStr, + job_id: JobID | str, + url_for: Callable[..., HttpUrl], +) -> Job: + job.url = url_for( + "get_job", + solver_key=solver_key, + version=solver_version, + job_id=job_id, + ) + + job.runner_url = url_for( + "get_solver_release", + solver_key=solver_key, + version=solver_version, + ) + + job.outputs_url = url_for( + "get_job_outputs", + solver_key=solver_key, + version=solver_version, + job_id=job_id, + ) + + return job + + router = APIRouter() +@router.get( + "/-/releases/-/jobs", + response_model=Page[Job], + description=create_route_description( + base="List of all jobs created for any released solver (paginated)", + changelog=[ + FMSG_CHANGELOG_NEW_IN_VERSION.format("0.8"), + ], + ), + include_in_schema=False, # TO BE RELEASED in 0.8 +) +async def list_all_solvers_jobs( + user_id: Annotated[PositiveInt, Depends(get_current_user_id)], + page_params: Annotated[PaginationParams, Depends()], + solver_service: Annotated[SolverService, Depends(get_solver_service)], + url_for: Annotated[Callable, Depends(get_reverse_url_mapper)], + product_name: Annotated[str, Depends(get_product_name)], +): + + jobs, meta = await solver_service.list_jobs( + product_name=product_name, + user_id=user_id, + offset=page_params.offset, + limit=page_params.limit, + ) + + for job in jobs: + solver_key, version, job_id = parse_resources_ids(job.resource_name) + _update_job_urls(job, solver_key, version, job_id, url_for) + + return create_page( + jobs, + total=meta.total, + params=page_params, + ) + + @router.get( "/{solver_key:path}/releases/{version}/jobs", response_model=list[Job], @@ -140,7 +209,7 @@ async def list_jobs( solver_key: SolverKeyId, version: VersionStr, user_id: Annotated[PositiveInt, Depends(get_current_user_id)], - solver_service: Annotated[SolverService, Depends(SolverService)], + solver_service: Annotated[SolverService, Depends(get_solver_service)], webserver_api: Annotated[AuthSession, Depends(get_webserver_session)], url_for: Annotated[Callable, Depends(get_reverse_url_mapper)], product_name: Annotated[str, Depends(get_product_name)], @@ -149,8 +218,8 @@ async def list_jobs( solver = await solver_service.get_solver( user_id=user_id, - name=solver_key, - version=version, + solver_key=solver_key, + solver_version=version, product_name=product_name, ) _logger.debug("Listing Jobs in Solver '%s'", solver.name) @@ -186,7 +255,7 @@ async def get_jobs_page( version: VersionStr, user_id: Annotated[PositiveInt, Depends(get_current_user_id)], page_params: Annotated[PaginationParams, Depends()], - solver_service: Annotated[SolverService, Depends(SolverService)], + solver_service: Annotated[SolverService, Depends(get_solver_service)], webserver_api: Annotated[AuthSession, Depends(get_webserver_session)], url_for: Annotated[Callable, Depends(get_reverse_url_mapper)], product_name: Annotated[str, Depends(get_product_name)], @@ -196,8 +265,8 @@ async def get_jobs_page( solver = await solver_service.get_solver( user_id=user_id, - name=solver_key, - version=version, + solver_key=solver_key, + solver_version=version, product_name=product_name, ) _logger.debug("Listing Jobs in Solver '%s'", solver.name) @@ -230,7 +299,7 @@ async def get_job( user_id: Annotated[PositiveInt, Depends(get_current_user_id)], product_name: Annotated[str, Depends(get_product_name)], webserver_api: Annotated[AuthSession, Depends(get_webserver_session)], - solver_service: Annotated[SolverService, Depends(SolverService)], + solver_service: Annotated[SolverService, Depends(get_solver_service)], url_for: Annotated[Callable, Depends(get_reverse_url_mapper)], ): """Gets job of a given solver""" @@ -240,8 +309,8 @@ async def get_job( solver = await solver_service.get_solver( user_id=user_id, - name=solver_key, - version=version, + solver_key=solver_key, + solver_version=version, product_name=product_name, ) project: ProjectGet = await webserver_api.get_project(project_id=job_id) diff --git a/services/api-server/src/simcore_service_api_server/api/routes/studies_jobs.py b/services/api-server/src/simcore_service_api_server/api/routes/studies_jobs.py index 0043b5daa705..f268cfb1e6e1 100644 --- a/services/api-server/src/simcore_service_api_server/api/routes/studies_jobs.py +++ b/services/api-server/src/simcore_service_api_server/api/routes/studies_jobs.py @@ -52,8 +52,11 @@ from ..dependencies.webserver_rpc import ( get_wb_api_rpc_client, ) -from ._common import API_SERVER_DEV_FEATURES_ENABLED -from ._constants import FMSG_CHANGELOG_CHANGED_IN_VERSION, FMSG_CHANGELOG_NEW_IN_VERSION +from ._constants import ( + FMSG_CHANGELOG_CHANGED_IN_VERSION, + FMSG_CHANGELOG_NEW_IN_VERSION, + create_route_description, +) from .solvers_jobs import JOBS_STATUS_CODES _logger = logging.getLogger(__name__) @@ -71,8 +74,13 @@ def _compose_job_resource_name(study_key, job_id) -> str: @router.get( "/{study_id:uuid}/jobs", response_model=Page[Job], - include_in_schema=API_SERVER_DEV_FEATURES_ENABLED, - status_code=status.HTTP_501_NOT_IMPLEMENTED, + description=create_route_description( + base="List of all jobs created for a given study (paginated)", + changelog=[ + FMSG_CHANGELOG_NEW_IN_VERSION.format("0.8"), + ], + ), + include_in_schema=False, # TO BE RELEASED in 0.8 ) async def list_study_jobs( study_id: StudyID, @@ -174,8 +182,14 @@ async def create_study_job( @router.get( "/{study_id:uuid}/jobs/{job_id:uuid}", response_model=Job, - include_in_schema=API_SERVER_DEV_FEATURES_ENABLED, status_code=status.HTTP_501_NOT_IMPLEMENTED, + description=create_route_description( + base="Gets a jobs for a given study", + changelog=[ + FMSG_CHANGELOG_NEW_IN_VERSION.format("0.8"), + ], + ), + include_in_schema=False, # TO BE RELEASED in 0.8 ) async def get_study_job( study_id: StudyID, diff --git a/services/api-server/src/simcore_service_api_server/exceptions/custom_errors.py b/services/api-server/src/simcore_service_api_server/exceptions/custom_errors.py index 17f22d16faea..4157f23eee48 100644 --- a/services/api-server/src/simcore_service_api_server/exceptions/custom_errors.py +++ b/services/api-server/src/simcore_service_api_server/exceptions/custom_errors.py @@ -16,3 +16,7 @@ class MissingWalletError(CustomBaseError): class ApplicationSetupError(CustomBaseError): pass + + +class SolverServiceListJobsFiltersError(CustomBaseError, ValueError): + msg_template = "solver_version is set but solver_id is not. Please provide both or none of them" diff --git a/services/api-server/src/simcore_service_api_server/models/api_resources.py b/services/api-server/src/simcore_service_api_server/models/api_resources.py index 1f3e4e71e38c..939012bbf571 100644 --- a/services/api-server/src/simcore_service_api_server/models/api_resources.py +++ b/services/api-server/src/simcore_service_api_server/models/api_resources.py @@ -53,26 +53,41 @@ def compose_resource_name(*collection_or_resource_ids) -> RelativeResourceName: return TypeAdapter(RelativeResourceName).validate_python("/".join(quoted_parts)) -def split_resource_name(resource_name: RelativeResourceName) -> list[str]: +def split_resource_name(resource_name: RelativeResourceName) -> tuple[str, ...]: + """ + Example: + resource_name = "solvers/simcore%2Fservices%2Fcomp%2Fisolve/releases/1.3.4/jobs/f622946d-fd29-35b9-a193-abdd1095167c/outputs/output+22" + returns ("solvers", "simcore/services/comp/isolve", "releases", "1.3.4", "jobs", "f622946d-fd29-35b9-a193-abdd1095167c", "outputs", "output 22") + """ quoted_parts = resource_name.split("/") - return [f"{urllib.parse.unquote_plus(p)}" for p in quoted_parts] + return tuple(f"{urllib.parse.unquote_plus(p)}" for p in quoted_parts) -def split_resource_name_as_dict( - resource_name: RelativeResourceName, -) -> dict[str, str | None]: - """Returns a map with - resource_ids[Collection-ID] == Resource-ID +def parse_collections_ids(resource_name: RelativeResourceName) -> tuple[str, ...]: + """ + Example: + resource_name = "solvers/simcore%2Fservices%2Fcomp%2Fisolve/releases/1.3.4/jobs/f622946d-fd29-35b9-a193-abdd1095167c/outputs/output+22" + returns ("solvers", "releases", "jobs", "outputs") """ parts = split_resource_name(resource_name) - return dict(zip(parts[::2], parts[1::2], strict=False)) + return parts[::2] -def parse_collections_ids(resource_name: RelativeResourceName) -> list[str]: +def parse_resources_ids(resource_name: RelativeResourceName) -> tuple[str, ...]: + """ + Example: + resource_name = "solvers/simcore%2Fservices%2Fcomp%2Fisolve/releases/1.3.4/jobs/f622946d-fd29-35b9-a193-abdd1095167c/outputs/output+22" + returns ("simcore/services/comp/isolve", "1.3.4", "f622946d-fd29-35b9-a193-abdd1095167c", "output 22") + """ parts = split_resource_name(resource_name) - return parts[::2] + return parts[1::2] -def parse_resources_ids(resource_name: RelativeResourceName) -> list[str]: +def split_resource_name_as_dict( + resource_name: RelativeResourceName, +) -> dict[str, str | None]: + """ + Returns a map such as resource_ids[Collection-ID] == Resource-ID + """ parts = split_resource_name(resource_name) - return parts[1::2] + return dict(zip(parts[::2], parts[1::2], strict=False)) diff --git a/services/api-server/src/simcore_service_api_server/models/schemas/_base.py b/services/api-server/src/simcore_service_api_server/models/schemas/_base.py index 30d9ca3ba31a..07144ba5b766 100644 --- a/services/api-server/src/simcore_service_api_server/models/schemas/_base.py +++ b/services/api-server/src/simcore_service_api_server/models/schemas/_base.py @@ -3,6 +3,7 @@ import packaging.version from models_library.utils.change_case import camel_to_snake +from models_library.utils.common_validators import trim_string_before from pydantic import BaseModel, ConfigDict, Field, HttpUrl, StringConstraints from ...models._utils_pydantic import UriSchema @@ -28,22 +29,35 @@ class ApiServerInputSchema(BaseModel): class BaseService(BaseModel): - id: Annotated[str, Field(..., description="Resource identifier")] + id: Annotated[ + str, + Field(description="Resource identifier"), + ] version: Annotated[ - VersionStr, Field(..., description="Semantic version number of the resource") + VersionStr, + Field(description="Semantic version number of the resource"), ] title: Annotated[ str, + trim_string_before(max_length=100), StringConstraints(max_length=100), - Field(..., description="Human readable name"), + Field(description="Human readable name"), ] description: Annotated[ str | None, - StringConstraints(max_length=500), + StringConstraints( + # NOTE: Place `StringConstraints` before `trim_string_before` for valid OpenAPI schema due to a Pydantic limitation. + # SEE `test_trim_string_before_with_string_constraints` + max_length=1000 + ), + trim_string_before(max_length=1000), Field(default=None, description="Description of the resource"), ] + url: Annotated[ - HttpUrl | None, UriSchema(), Field(..., description="Link to get this resource") + HttpUrl | None, + UriSchema(), + Field(description="Link to get this resource"), ] @property diff --git a/services/api-server/src/simcore_service_api_server/models/schemas/jobs.py b/services/api-server/src/simcore_service_api_server/models/schemas/jobs.py index c6479bece287..86abb0a87414 100644 --- a/services/api-server/src/simcore_service_api_server/models/schemas/jobs.py +++ b/services/api-server/src/simcore_service_api_server/models/schemas/jobs.py @@ -307,6 +307,8 @@ def create_job_from_solver_or_program( def compose_resource_name( cls, parent_name: RelativeResourceName, job_id: UUID ) -> RelativeResourceName: + assert "jobs" not in parent_name # nosec + # CAREFUL, this is not guarantee a UNIQUE identifier since the resource # could have some alias entrypoints and the wrong parent_name might be introduced here collection_or_resource_ids = [ diff --git a/services/api-server/src/simcore_service_api_server/services_http/solver_job_models_converters.py b/services/api-server/src/simcore_service_api_server/services_http/solver_job_models_converters.py index 1d7e8b7f92ca..3a6728f478a8 100644 --- a/services/api-server/src/simcore_service_api_server/services_http/solver_job_models_converters.py +++ b/services/api-server/src/simcore_service_api_server/services_http/solver_job_models_converters.py @@ -200,9 +200,7 @@ def create_job_from_project( """ assert len(project.workbench) == 1 # nosec - # get solver node - node_id = next(iter(project.workbench.keys())) - solver_node: Node = project.workbench[node_id] + solver_node: Node = next(iter(project.workbench.values())) job_inputs: JobInputs = create_job_inputs_from_node_inputs( inputs=solver_node.inputs or {} ) @@ -212,7 +210,7 @@ def create_job_from_project( job_id = project.uuid - job = Job( + return Job( id=job_id, name=Job.compose_resource_name( parent_name=solver_or_program_name, job_id=job_id @@ -229,8 +227,6 @@ def create_job_from_project( ), ) - return job - def create_jobstatus_from_task(task: ComputationTaskGet) -> JobStatus: return JobStatus( 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 ca771d913b19..495e72cd08a5 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 @@ -219,6 +219,24 @@ async def mark_project_as_job( job_parent_resource_name=job_parent_resource_name, ) + async def list_projects_marked_as_jobs( + self, + *, + product_name: ProductName, + user_id: UserID, + offset: int = 0, + limit: int = 50, + job_parent_resource_name_prefix: str | None = None, + ): + return await projects_rpc.list_projects_marked_as_jobs( + rpc_client=self._client, + product_name=product_name, + user_id=user_id, + offset=offset, + limit=limit, + job_parent_resource_name_prefix=job_parent_resource_name_prefix, + ) + def setup(app: FastAPI, rabbitmq_rmp_client: RabbitMQRPCClient): wb_api_rpc_client = WbApiRpcClient(_client=rabbitmq_rmp_client) diff --git a/services/api-server/tests/unit/_with_db/conftest.py b/services/api-server/tests/unit/_with_db/conftest.py index bc93e0442c08..fd2441c879e5 100644 --- a/services/api-server/tests/unit/_with_db/conftest.py +++ b/services/api-server/tests/unit/_with_db/conftest.py @@ -272,6 +272,7 @@ async def create_fake_api_keys( create_user_ids: Callable[[PositiveInt], AsyncGenerator[PositiveInt, None]], create_product_names: Callable[[PositiveInt], AsyncGenerator[str, None]], ) -> AsyncGenerator[Callable[[PositiveInt], AsyncGenerator[ApiKeyInDB, None]], None]: + async def _generate_fake_api_key(n: PositiveInt): users, products = create_user_ids(n), create_product_names(n) excluded_column = "api_secret" diff --git a/services/api-server/tests/unit/_with_db/test_product.py b/services/api-server/tests/unit/_with_db/test_product.py index 0bca5b028011..1c672da91339 100644 --- a/services/api-server/tests/unit/_with_db/test_product.py +++ b/services/api-server/tests/unit/_with_db/test_product.py @@ -29,22 +29,22 @@ async def test_product_webserver( mocked_webserver_rest_api_base: respx.MockRouter, create_fake_api_keys: Callable[[PositiveInt], AsyncGenerator[ApiKeyInDB, None]], faker: Faker, -) -> None: +): assert client - keys: dict[int, ApiKeyInDB] = {} - wallet_id: int = faker.pyint(min_value=1) - async for key in create_fake_api_keys(2): - wallet_id += faker.pyint(min_value=1) - keys[wallet_id] = key + wallet_to_api_keys_map: dict[int, ApiKeyInDB] = {} + _wallet_id = faker.pyint(min_value=1) + async for api_key in create_fake_api_keys(2): + _wallet_id += faker.pyint(min_value=1) + wallet_to_api_keys_map[_wallet_id] = api_key def _check_key_product_compatibility(request: httpx.Request, **kwargs): assert ( received_product_name := request.headers.get("x-simcore-products-name") ) is not None assert (wallet_id := kwargs.get("wallet_id")) is not None - assert (key := keys[int(wallet_id)]) is not None - assert key.product_name == received_product_name + assert (api_key := wallet_to_api_keys_map[int(wallet_id)]) is not None + assert api_key.product_name == received_product_name return httpx.Response( status.HTTP_200_OK, json=jsonable_encoder( @@ -53,7 +53,7 @@ def _check_key_product_compatibility(request: httpx.Request, **kwargs): wallet_id=wallet_id, name="my_wallet", description="this is my wallet", - owner=key.id_, + owner=api_key.id_, thumbnail="something", status=WalletStatus.ACTIVE, created=datetime.datetime.now(), @@ -68,30 +68,29 @@ def _check_key_product_compatibility(request: httpx.Request, **kwargs): path__regex=r"/wallets/(?P[-+]?\d+)" ).mock(side_effect=_check_key_product_compatibility) - for wallet_id in keys: - key = keys[wallet_id] + for wallet_id, api_key in wallet_to_api_keys_map.items(): response = await client.get( f"{API_VTAG}/wallets/{wallet_id}", - auth=httpx.BasicAuth(key.api_key, key.api_secret), + auth=httpx.BasicAuth(api_key.api_key, api_key.api_secret), ) assert response.status_code == status.HTTP_200_OK - assert wallet_get_mock.call_count == len(keys) + assert wallet_get_mock.call_count == len(wallet_to_api_keys_map) async def test_product_catalog( client: httpx.AsyncClient, mocked_catalog_rpc_api: dict[str, MockType], create_fake_api_keys: Callable[[PositiveInt], AsyncGenerator[ApiKeyInDB, None]], -) -> None: +): assert client - keys: list[ApiKeyInDB] = [key async for key in create_fake_api_keys(2)] - assert len({key.product_name for key in keys}) == 2 + valid_api_auths: list[ApiKeyInDB] = [key async for key in create_fake_api_keys(2)] + assert len({key.product_name for key in valid_api_auths}) == 2 - for key in keys: + for api_auth in valid_api_auths: await client.get( f"{API_VTAG}/solvers/simcore/services/comp/isolve/releases/2.0.24", - auth=httpx.BasicAuth(key.api_key, key.api_secret), + auth=httpx.BasicAuth(api_auth.api_key, api_auth.api_secret), ) assert mocked_catalog_rpc_api["get_service"].called diff --git a/services/api-server/tests/unit/api_solvers/test_api_routers_solvers_jobs_read.py b/services/api-server/tests/unit/api_solvers/test_api_routers_solvers_jobs_read.py index 36f028455254..b7dca6511a62 100644 --- a/services/api-server/tests/unit/api_solvers/test_api_routers_solvers_jobs_read.py +++ b/services/api-server/tests/unit/api_solvers/test_api_routers_solvers_jobs_read.py @@ -18,13 +18,15 @@ class MockBackendRouters(NamedTuple): - webserver: MockRouter - catalog: dict[str, MockType] + webserver_rest: MockRouter + webserver_rpc: dict[str, MockType] + catalog_rpc: dict[str, MockType] @pytest.fixture def mocked_backend( mocked_webserver_rest_api_base: MockRouter, + mocked_webserver_rpc_api: dict[str, MockType], mocked_catalog_rpc_api: dict[str, MockType], project_tests_dir: Path, ) -> MockBackendRouters: @@ -46,8 +48,9 @@ def mocked_backend( ) return MockBackendRouters( - webserver=mocked_webserver_rest_api_base, - catalog=mocked_catalog_rpc_api, + webserver_rest=mocked_webserver_rest_api_base, + webserver_rpc=mocked_webserver_rpc_api, + catalog_rpc=mocked_catalog_rpc_api, ) @@ -81,5 +84,43 @@ async def test_list_solver_jobs( assert jobs_page.items == jobs # check calls to the deep-backend services - assert mocked_backend.webserver["list_projects"].called - assert mocked_backend.catalog["get_service"].called + assert mocked_backend.webserver_rest["list_projects"].called + assert mocked_backend.catalog_rpc["get_service"].called + + +async def test_list_all_solvers_jobs( + auth: httpx.BasicAuth, + client: httpx.AsyncClient, + mocked_backend: MockBackendRouters, +): + """Tests the endpoint that lists all jobs across all solvers.""" + + # Call the endpoint with pagination parameters + resp = await client.get( + f"/{API_VTAG}/solvers/-/releases/-/jobs", + auth=auth, + params={"limit": 10, "offset": 0}, + ) + + # Verify the response + assert resp.status_code == status.HTTP_200_OK + + # Parse and validate the response + jobs_page = TypeAdapter(Page[Job]).validate_python(resp.json()) + + # Basic assertions on the response structure + assert isinstance(jobs_page.items, list) + assert jobs_page.total > 0 + assert jobs_page.limit == 10 + assert jobs_page.offset == 0 + assert jobs_page.total <= len(jobs_page.items) + + # Each job should have the expected structure + for job in jobs_page.items: + assert job.id + assert job.name + assert job.url is not None + assert job.runner_url is not None + assert job.outputs_url is not None + + assert mocked_backend.webserver_rpc["list_projects_marked_as_jobs"].called diff --git a/services/api-server/tests/unit/conftest.py b/services/api-server/tests/unit/conftest.py index 704bbc84f57d..3389a9b4d2f9 100644 --- a/services/api-server/tests/unit/conftest.py +++ b/services/api-server/tests/unit/conftest.py @@ -2,10 +2,11 @@ # pylint: disable=redefined-outer-name # pylint: disable=unused-argument # pylint: disable=unused-variable +# pylint: disable=broad-exception-caught import json import subprocess -from collections.abc import AsyncIterator, Callable, Iterable, Iterator +from collections.abc import AsyncIterator, Callable, Iterator from copy import deepcopy from pathlib import Path from typing import Any @@ -45,13 +46,11 @@ from pytest_simcore.simcore_webserver_projects_rest_api import GET_PROJECT from requests.auth import HTTPBasicAuth from respx import MockRouter -from servicelib.rabbitmq._client_rpc import RabbitMQRPCClient -from servicelib.rabbitmq.rpc_interfaces.catalog import services as catalog_rpc -from simcore_service_api_server.api.dependencies.rabbitmq import get_rabbitmq_rpc_client from simcore_service_api_server.core.application import init_app from simcore_service_api_server.core.settings import ApplicationSettings from simcore_service_api_server.repository.api_keys import UserAndProductTuple from simcore_service_api_server.services_http.solver_job_outputs import ResultsTypes +from simcore_service_api_server.services_rpc.wb_api_server import WbApiRpcClient @pytest.fixture @@ -92,10 +91,19 @@ def app_environment( def mock_missing_plugins(app_environment: EnvVarsDict, mocker: MockerFixture): settings = ApplicationSettings.create_from_envs() if settings.API_SERVER_RABBITMQ is None: - mocker.patch("simcore_service_api_server.core.application.setup_rabbitmq") - mocker.patch( - "simcore_service_api_server.core._prometheus_instrumentation.setup_prometheus_instrumentation" + import simcore_service_api_server.core.application + + mocker.patch.object( + simcore_service_api_server.core.application, + "setup_rabbitmq", + autospec=True, ) + mocker.patch.object( + simcore_service_api_server.core.application, + "setup_prometheus_instrumentation", + autospec=True, + ) + return app_environment @@ -220,6 +228,37 @@ def mocked_s3_server_url() -> Iterator[HttpUrl]: ## MOCKED res/web APIs from simcore services ------------------------------------------ +@pytest.fixture +def mocked_app_dependencies(app: FastAPI, mocker: MockerFixture) -> Iterator[None]: + """ + Mocks some dependency overrides for the FastAPI app. + """ + assert app.state.settings.API_SERVER_RABBITMQ is None + + from simcore_service_api_server.api.dependencies.rabbitmq import ( + get_rabbitmq_rpc_client, + ) + from simcore_service_api_server.api.dependencies.webserver_rpc import ( + get_wb_api_rpc_client, + ) + + def _get_rabbitmq_rpc_client_override(): + return mocker.MagicMock() + + async def _get_wb_api_rpc_client_override(): + return WbApiRpcClient(_client=mocker.MagicMock()) + + app.dependency_overrides[get_rabbitmq_rpc_client] = ( + _get_rabbitmq_rpc_client_override + ) + app.dependency_overrides[get_wb_api_rpc_client] = _get_wb_api_rpc_client_override + + yield + + app.dependency_overrides.pop(get_wb_api_rpc_client, None) + app.dependency_overrides.pop(get_rabbitmq_rpc_client, None) + + @pytest.fixture def directorv2_service_openapi_specs( osparc_simcore_services_dir: Path, @@ -330,40 +369,6 @@ def mocked_webserver_rest_api_base( yield respx_mock -@pytest.fixture -def mocked_webserver_rpc_api( - app: FastAPI, mocker: MockerFixture -) -> dict[str, MockType]: - from servicelib.rabbitmq.rpc_interfaces.webserver import projects as projects_rpc - from simcore_service_api_server.services_rpc import wb_api_server - - # NOTE: mock_missing_plugins patches `setup_rabbitmq` - try: - wb_api_server.WbApiRpcClient.get_from_app_state(app) - except AttributeError: - wb_api_server.setup( - app, RabbitMQRPCClient("fake_rpc_client", settings=mocker.MagicMock()) - ) - - settings: ApplicationSettings = app.state.settings - assert settings.API_SERVER_WEBSERVER - - side_effects = WebserverRpcSideEffects() - - return { - "mark_project_as_job": mocker.patch.object( - projects_rpc, - "mark_project_as_job", - side_effects.mark_project_as_job, - ), - "list_projects_marked_as_jobs": mocker.patch.object( - projects_rpc, - "list_projects_marked_as_jobs", - side_effects.list_projects_marked_as_jobs, - ), - } - - @pytest.fixture def mocked_storage_rest_api_base( app: FastAPI, @@ -458,20 +463,47 @@ def mocked_catalog_rest_api_base( @pytest.fixture -def mocked_catalog_rpc_api( - app: FastAPI, mocker: MockerFixture -) -> Iterable[dict[str, MockType]]: +def mocked_webserver_rpc_api( + mocked_app_dependencies: None, mocker: MockerFixture +) -> dict[str, MockType]: """ - Mocks the RPC catalog service API for testing purposes. + Mocks the webserver's simcore service RPC API for testing purposes. """ + from servicelib.rabbitmq.rpc_interfaces.webserver import ( + projects as projects_rpc, # keep import here + ) - def get_mock_rabbitmq_rpc_client(): - return MagicMock() + side_effects = WebserverRpcSideEffects() - app.dependency_overrides[get_rabbitmq_rpc_client] = get_mock_rabbitmq_rpc_client - side_effects = CatalogRpcSideEffects() + return { + "mark_project_as_job": mocker.patch.object( + projects_rpc, + "mark_project_as_job", + autospec=True, + side_effect=side_effects.mark_project_as_job, + ), + "list_projects_marked_as_jobs": mocker.patch.object( + projects_rpc, + "list_projects_marked_as_jobs", + autospec=True, + side_effect=side_effects.list_projects_marked_as_jobs, + ), + } - yield { + +@pytest.fixture +def mocked_catalog_rpc_api( + mocked_app_dependencies: None, mocker: MockerFixture +) -> dict[str, MockType]: + """ + Mocks the catalog's simcore service RPC API for testing purposes. + """ + from servicelib.rabbitmq.rpc_interfaces.catalog import ( + services as catalog_rpc, # keep import here + ) + + side_effects = CatalogRpcSideEffects() + return { "list_services_paginated": mocker.patch.object( catalog_rpc, "list_services_paginated", @@ -503,7 +535,6 @@ def get_mock_rabbitmq_rpc_client(): side_effect=side_effects.get_service_ports, ), } - app.dependency_overrides.pop(get_rabbitmq_rpc_client) # @@ -633,6 +664,7 @@ def clone_project_task(self, request: httpx.Request, *, project_id: str): return self._set_result_and_get_reponse(project_get) def get_result(self, request: httpx.Request, *, task_id: str): + assert request return httpx.Response( status.HTTP_200_OK, json={"data": self._results[task_id]} ) diff --git a/services/api-server/tests/unit/test_api_solver_jobs.py b/services/api-server/tests/unit/test_api_solver_jobs.py index a618cd6b8564..518d183603dc 100644 --- a/services/api-server/tests/unit/test_api_solver_jobs.py +++ b/services/api-server/tests/unit/test_api_solver_jobs.py @@ -40,7 +40,7 @@ def _start_job_side_effect( return capture.response_body -def get_inspect_job_side_effect(job_id: str) -> SideEffectCallback: +def _get_inspect_job_side_effect(job_id: str) -> SideEffectCallback: def _inspect_job_side_effect( request: httpx.Request, path_params: dict[str, Any], @@ -250,7 +250,7 @@ def _put_pricing_plan_and_unit_side_effect( _start_job_side_effect, ] if expected_status_code == status.HTTP_202_ACCEPTED: - callbacks.append(get_inspect_job_side_effect(job_id=_job_id)) + callbacks.append(_get_inspect_job_side_effect(job_id=_job_id)) _put_pricing_plan_and_unit_side_effect.was_called = False create_respx_mock_from_capture( @@ -296,7 +296,7 @@ async def test_get_solver_job_pricing_unit_no_payment( capture_path=project_tests_dir / "mocks" / "start_job_no_payment.json", side_effects_callbacks=[ _start_job_side_effect, - get_inspect_job_side_effect(job_id=_job_id), + _get_inspect_job_side_effect(job_id=_job_id), ], ) @@ -329,7 +329,7 @@ async def test_start_solver_job_conflict( capture_path=project_tests_dir / "mocks" / "start_solver_job.json", side_effects_callbacks=[ _start_job_side_effect, - get_inspect_job_side_effect(job_id=_job_id), + _get_inspect_job_side_effect(job_id=_job_id), ], ) @@ -370,7 +370,7 @@ def _stop_job_side_effect( capture_path=project_tests_dir / "mocks" / "stop_job.json", side_effects_callbacks=[ _stop_job_side_effect, - get_inspect_job_side_effect(job_id=_job_id), + _get_inspect_job_side_effect(job_id=_job_id), ], ) diff --git a/services/api-server/tests/unit/test_api_solvers.py b/services/api-server/tests/unit/test_api_solvers.py index 0f76b520e01f..9d0eac22ac04 100644 --- a/services/api-server/tests/unit/test_api_solvers.py +++ b/services/api-server/tests/unit/test_api_solvers.py @@ -7,9 +7,11 @@ import httpx import pytest +import respx from fastapi import status from httpx import AsyncClient from models_library.api_schemas_api_server.pricing_plans import ServicePricingPlanGet +from pytest_mock import MockType from pytest_simcore.helpers.httpx_calls_capture_models import CreateRespxMockCallback from simcore_service_api_server._meta import API_VTAG @@ -26,7 +28,7 @@ ) async def test_get_solver_pricing_plan( client: AsyncClient, - mocked_webserver_rest_api_base, + mocked_webserver_rest_api_base: respx.MockRouter, create_respx_mock_from_capture: CreateRespxMockCallback, auth: httpx.BasicAuth, project_tests_dir: Path, @@ -60,7 +62,7 @@ async def test_get_solver_pricing_plan( ) async def test_get_latest_solver_release( client: AsyncClient, - mocked_catalog_rpc_api, + mocked_catalog_rpc_api: dict[str, MockType], auth: httpx.BasicAuth, solver_key: str, expected_status_code: int, diff --git a/services/api-server/tests/unit/test_models_api_resources.py b/services/api-server/tests/unit/test_models_api_resources.py index 39137bcb8d07..d204b32215da 100644 --- a/services/api-server/tests/unit/test_models_api_resources.py +++ b/services/api-server/tests/unit/test_models_api_resources.py @@ -17,7 +17,7 @@ def test_parse_resource_id(): resource_name = "solvers/simcore%2Fservices%2Fcomp%2Fisolve/releases/1.3.4/jobs/f622946d-fd29-35b9-a193-abdd1095167c/outputs/output+22" - parts = [ + parts = ( "solvers", "simcore/services/comp/isolve", "releases", @@ -26,7 +26,7 @@ def test_parse_resource_id(): "f622946d-fd29-35b9-a193-abdd1095167c", "outputs", "output 22", - ] + ) # cannot use this because cannot convert into URL? except {:path} in starlette ??? assert str(Path(*parts)) == urllib.parse.unquote_plus(resource_name) @@ -44,10 +44,10 @@ def test_parse_resource_id(): collection_to_resource_id_map = split_resource_name_as_dict(resource_name) # Collection-ID -> Resource-ID - assert list(collection_to_resource_id_map.keys()) == parse_collections_ids( + assert tuple(collection_to_resource_id_map.keys()) == parse_collections_ids( resource_name ) - assert list(collection_to_resource_id_map.values()) == parse_resources_ids( + assert tuple(collection_to_resource_id_map.values()) == parse_resources_ids( resource_name ) diff --git a/services/api-server/tests/unit/test_service_solvers.py b/services/api-server/tests/unit/test_service_solvers.py new file mode 100644 index 000000000000..d0fe16405a5a --- /dev/null +++ b/services/api-server/tests/unit/test_service_solvers.py @@ -0,0 +1,61 @@ +# pylint: disable=protected-access +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable + +import pytest +from models_library.products import ProductName +from models_library.users import UserID +from pytest_mock import MockerFixture, MockType +from simcore_service_api_server._service_solvers import SolverService +from simcore_service_api_server.models.schemas.solvers import Solver +from simcore_service_api_server.services_rpc.catalog import CatalogService +from simcore_service_api_server.services_rpc.wb_api_server import WbApiRpcClient + + +@pytest.fixture +def solver_service( + mocker: MockerFixture, + mocked_catalog_rpc_api: dict[str, MockType], + mocked_webserver_rpc_api: dict[str, MockType], +) -> SolverService: + return SolverService( + catalog_service=CatalogService(client=mocker.MagicMock()), + webserver_client=WbApiRpcClient(_client=mocker.MagicMock()), + ) + + +async def test_get_solver( + solver_service: SolverService, + mocked_catalog_rpc_api: dict[str, MockType], + product_name: ProductName, + user_id: UserID, +): + solver = await solver_service.get_solver( + user_id=user_id, + solver_key="simcore/services/comp/solver-1", + solver_version="1.0.0", + product_name=product_name, + ) + + assert isinstance(solver, Solver) + mocked_catalog_rpc_api["get_service"].assert_called_once() + + +async def test_list_jobs( + solver_service: SolverService, + mocked_webserver_rpc_api: dict[str, MockType], + product_name: ProductName, + user_id: UserID, +): + # Test default parameters + jobs, page_meta = await solver_service.list_jobs( + user_id=user_id, + product_name=product_name, + ) + assert isinstance(jobs, list) + mocked_webserver_rpc_api["list_projects_marked_as_jobs"].assert_called_once() + assert page_meta.total >= 0 + assert page_meta.limit == 49 + assert page_meta.offset == 0 + assert page_meta.count > 0 diff --git a/services/web/server/src/simcore_service_webserver/projects/_controller/projects_rpc.py b/services/web/server/src/simcore_service_webserver/projects/_controller/projects_rpc.py index d3bd67d0bbfb..6ee4861b6902 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_controller/projects_rpc.py +++ b/services/web/server/src/simcore_service_webserver/projects/_controller/projects_rpc.py @@ -66,7 +66,7 @@ async def list_projects_marked_as_jobs( offset: PageOffsetInt, limit: PageLimitInt, # filters - job_parent_resource_name_filter: str | None, + job_parent_resource_name_prefix: str | None, ) -> PageRpcProjectJobRpcGet: total, projects = await _jobs_service.list_my_projects_marked_as_jobs( @@ -75,7 +75,7 @@ async def list_projects_marked_as_jobs( user_id=user_id, offset=offset, limit=limit, - job_parent_resource_name_filter=job_parent_resource_name_filter, + job_parent_resource_name_prefix=job_parent_resource_name_prefix, ) job_projects = [ @@ -84,7 +84,7 @@ async def list_projects_marked_as_jobs( name=project.name, description=project.description, workbench=project.workbench, - creation_at=project.creation_date, + created_at=project.creation_date, modified_at=project.last_change_date, job_parent_resource_name=project.job_parent_resource_name, ) diff --git a/services/web/server/src/simcore_service_webserver/projects/_groups_service.py b/services/web/server/src/simcore_service_webserver/projects/_groups_service.py index 40416db7975e..4ad89126a491 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_groups_service.py +++ b/services/web/server/src/simcore_service_webserver/projects/_groups_service.py @@ -160,14 +160,16 @@ async def delete_project_group( project_db: ProjectDBAPI = app[APP_PROJECT_DBAPI] project = await project_db.get_project_db(project_id) project_owner_user: dict = await users_service.get_user(app, project.prj_owner) - if project_owner_user["primary_gid"] == group_id: - if user["primary_gid"] != project_owner_user["primary_gid"]: - # Only the owner of the project can delete the owner group - raise ProjectInvalidRightsError( - user_id=user_id, - project_uuid=project_id, - reason=f"User does not have access to modify owner project group in project {project_id}", - ) + if ( + project_owner_user["primary_gid"] == group_id + and user["primary_gid"] != project_owner_user["primary_gid"] + ): + # Only the owner of the project can delete the owner group + raise ProjectInvalidRightsError( + user_id=user_id, + project_uuid=project_id, + reason=f"User does not have access to modify owner project group in project {project_id}", + ) await _groups_repository.delete_project_group( app=app, project_id=project_id, group_id=group_id diff --git a/services/web/server/src/simcore_service_webserver/projects/_jobs_repository.py b/services/web/server/src/simcore_service_webserver/projects/_jobs_repository.py index 5981f5fe7b0f..b6fd3242c019 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_jobs_repository.py +++ b/services/web/server/src/simcore_service_webserver/projects/_jobs_repository.py @@ -62,10 +62,23 @@ async def list_projects_marked_as_jobs( user_id: UserID, offset: int = 0, limit: int = 10, - job_parent_resource_name_filter: str | None = None, + job_parent_resource_name_prefix: str | None = None, ) -> tuple[int, list[ProjectJobDBGet]]: - """ - Lists projects marked as jobs for a specific user and product + """Lists projects marked as jobs for a specific user and product + + + Arguments: + product_name -- caller's context product identifier + user_id -- caller's user identifier + + Keyword Arguments: + job_parent_resource_name_prefix -- is a prefix to filter the `job_parent_resource_name`. The latter is a + path-like string that contains a hierarchy of resources. An example of `job_parent_resource_name` is: + `/solvers/simcore%2Fservices%2Fcomp%2Fisolve/releases/1.3.4/jobs/f622946d-fd29-35b9-a193-abdd1095167c` + SEE services/api-server/src/simcore_service_api_server/models/api_resources.py (default: {None}) + + Returns: + total_count, list of projects marked as jobs """ # Step 1: Get group IDs associated with the user @@ -92,14 +105,18 @@ async def list_projects_marked_as_jobs( projects_to_products.c.product_name == product_name, project_to_groups.c.gid.in_(sa.select(user_groups_query.c.gid)), project_to_groups.c.read.is_(True), + projects.c.workspace_id.is_( + # ONLY projects in private workspaces + None + ), ) ) # Apply job_parent_resource_name_filter if provided - if job_parent_resource_name_filter: + if job_parent_resource_name_prefix: access_query = access_query.where( projects_to_jobs.c.job_parent_resource_name.like( - f"%{job_parent_resource_name_filter}%" + f"{job_parent_resource_name_prefix}%" ) ) diff --git a/services/web/server/src/simcore_service_webserver/projects/_jobs_service.py b/services/web/server/src/simcore_service_webserver/projects/_jobs_service.py index 712549bdc6c7..7b4289644c28 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_jobs_service.py +++ b/services/web/server/src/simcore_service_webserver/projects/_jobs_service.py @@ -56,7 +56,7 @@ async def list_my_projects_marked_as_jobs( user_id: UserID, offset: int = 0, limit: int = 10, - job_parent_resource_name_filter: str | None = None, + job_parent_resource_name_prefix: str | None = None, ) -> tuple[int, list[ProjectJobDBGet]]: """ Lists paginated projects marked as jobs for the given user and product. @@ -68,5 +68,5 @@ async def list_my_projects_marked_as_jobs( product_name=product_name, offset=offset, limit=limit, - job_parent_resource_name_filter=job_parent_resource_name_filter, + job_parent_resource_name_prefix=job_parent_resource_name_prefix, ) diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects__jobs_service.py b/services/web/server/tests/unit/with_dbs/02/test_projects__jobs_service.py index c33274ca609c..ebe796ad9f0e 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects__jobs_service.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects__jobs_service.py @@ -158,7 +158,7 @@ async def test_user_can_filter_marked_project( app=client.app, product_name=osparc_product_name, user_id=project_job_fixture.user_id, - job_parent_resource_name_filter=project_job_fixture.job_parent_resource_name, + job_parent_resource_name_prefix=project_job_fixture.job_parent_resource_name, ) assert total_count == 1 assert len(result) == 1 @@ -174,7 +174,7 @@ async def test_user_can_filter_marked_project( app=client.app, product_name=osparc_product_name, user_id=project_job_fixture.user_id, - job_parent_resource_name_filter="test/%", + job_parent_resource_name_prefix="test/%", ) assert total_count == 1 assert len(result) == 1 @@ -190,7 +190,7 @@ async def test_user_can_filter_marked_project( app=client.app, product_name=osparc_product_name, user_id=project_job_fixture.user_id, - job_parent_resource_name_filter="other/%", + job_parent_resource_name_prefix="other/%", ) assert total_count == 0 assert len(result) == 0 diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_rpc.py b/services/web/server/tests/unit/with_dbs/02/test_projects_rpc.py index b18067e53831..28147c93d3eb 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_rpc.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_rpc.py @@ -114,7 +114,7 @@ async def test_rpc_client_list_my_projects_marked_as_jobs( rpc_client=rpc_client, product_name=product_name, user_id=user_id, - job_parent_resource_name_filter="solvers/solver123", + job_parent_resource_name_prefix="solvers/solver123", ) assert page.meta.total == 1