diff --git a/packages/models-library/src/models_library/api_schemas_webserver/licensed_items.py b/packages/models-library/src/models_library/api_schemas_webserver/licensed_items.py index 5c170588856..3455e8a81ac 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/licensed_items.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/licensed_items.py @@ -3,7 +3,7 @@ from models_library.licensed_items import LicensedItemID, LicensedResourceType from models_library.resource_tracker import PricingPlanId -from pydantic import PositiveInt +from pydantic import ConfigDict, PositiveInt from ._base import OutputSchema @@ -15,6 +15,20 @@ class LicensedItemGet(OutputSchema): pricing_plan_id: PricingPlanId created_at: datetime modified_at: datetime + model_config = ConfigDict( + json_schema_extra={ + "examples": [ + { + "licensed_item_id": "0362b88b-91f8-4b41-867c-35544ad1f7a1", + "name": "best-model", + "licensed_resource_type": f"{LicensedResourceType.VIP_MODEL}", + "pricing_plan_id": "15", + "created_at": "2024-12-12 09:59:26.422140", + "modified_at": "2024-12-12 09:59:26.422140", + } + ] + } + ) class LicensedItemGetPage(NamedTuple): diff --git a/scripts/maintenance/migrate_project/src/cli.py b/scripts/maintenance/migrate_project/src/cli.py index 6012e97bbd6..5d7525efcac 100644 --- a/scripts/maintenance/migrate_project/src/cli.py +++ b/scripts/maintenance/migrate_project/src/cli.py @@ -1,5 +1,4 @@ from pathlib import Path -from typing import Optional import typer from db import ( @@ -15,7 +14,7 @@ def main(config: Path = typer.Option(..., exists=True)): assert config.exists() # nosec settings = Settings.load_from_file(config) - typer.echo(f"Detected settings:\n{settings.json(indent=2)}\n") + typer.echo(f"Detected settings:\n{settings.model_dump_json(indent=2)}\n") r_clone_config_path = assemble_config_file( # source diff --git a/scripts/maintenance/migrate_project/src/models.py b/scripts/maintenance/migrate_project/src/models.py index 68649c2a392..964d95de550 100644 --- a/scripts/maintenance/migrate_project/src/models.py +++ b/scripts/maintenance/migrate_project/src/models.py @@ -92,5 +92,7 @@ class Config: if __name__ == "__main__": # produces an empty configuration to be saved as starting point print( - Settings.model_validate(Settings.Config.schema_extra["example"]).json(indent=2) + Settings.model_validate( + Settings.Config.schema_extra["example"] + ).model_dump_json(indent=2) ) diff --git a/services/api-server/src/simcore_service_api_server/api/dependencies/rabbitmq.py b/services/api-server/src/simcore_service_api_server/api/dependencies/rabbitmq.py index 16de774ce52..e90c60861eb 100644 --- a/services/api-server/src/simcore_service_api_server/api/dependencies/rabbitmq.py +++ b/services/api-server/src/simcore_service_api_server/api/dependencies/rabbitmq.py @@ -6,15 +6,23 @@ from servicelib.aiohttp.application_setup import ApplicationSetupError from servicelib.fastapi.dependencies import get_app from servicelib.rabbitmq import RabbitMQClient +from servicelib.rabbitmq._client_rpc import RabbitMQRPCClient from tenacity import before_sleep_log, retry, stop_after_delay, wait_fixed -from ...services.log_streaming import LogDistributor +from ...services_http.log_streaming import LogDistributor _MAX_WAIT_FOR_LOG_DISTRIBUTOR_SECONDS: Final[int] = 10 _logger = logging.getLogger(__name__) +def get_rabbitmq_rpc_client( + app: Annotated[FastAPI, Depends(get_app)] +) -> RabbitMQRPCClient: + assert app.state.rabbitmq_rpc_client # nosec + return cast(RabbitMQRPCClient, app.state.rabbitmq_rpc_client) + + def get_rabbitmq_client(app: Annotated[FastAPI, Depends(get_app)]) -> RabbitMQClient: assert app.state.rabbitmq_client # nosec return cast(RabbitMQClient, app.state.rabbitmq_client) diff --git a/services/api-server/src/simcore_service_api_server/api/dependencies/webserver.py b/services/api-server/src/simcore_service_api_server/api/dependencies/webserver_http.py similarity index 98% rename from services/api-server/src/simcore_service_api_server/api/dependencies/webserver.py rename to services/api-server/src/simcore_service_api_server/api/dependencies/webserver_http.py index 94cfed68b55..d70a64575e2 100644 --- a/services/api-server/src/simcore_service_api_server/api/dependencies/webserver.py +++ b/services/api-server/src/simcore_service_api_server/api/dependencies/webserver_http.py @@ -9,7 +9,7 @@ from ..._constants import MSG_BACKEND_SERVICE_UNAVAILABLE from ...core.settings import ApplicationSettings, WebServerSettings -from ...services.webserver import AuthSession +from ...services_http.webserver import AuthSession from .application import get_app, get_settings from .authentication import Identity, get_active_user_email, get_current_identity diff --git a/services/api-server/src/simcore_service_api_server/api/dependencies/webserver_rpc.py b/services/api-server/src/simcore_service_api_server/api/dependencies/webserver_rpc.py new file mode 100644 index 00000000000..df47e0a5be9 --- /dev/null +++ b/services/api-server/src/simcore_service_api_server/api/dependencies/webserver_rpc.py @@ -0,0 +1,13 @@ +from typing import Annotated, cast + +from fastapi import Depends, FastAPI +from servicelib.fastapi.dependencies import get_app + +from ...services_rpc.wb_api_server import WbApiRpcClient + + +async def get_wb_api_rpc_client( + app: Annotated[FastAPI, Depends(get_app)] +) -> WbApiRpcClient: + assert app.state.wb_api_rpc_client # nosec + return cast(WbApiRpcClient, app.state.wb_api_rpc_client) diff --git a/services/api-server/src/simcore_service_api_server/api/root.py b/services/api-server/src/simcore_service_api_server/api/root.py index aa715ade554..d73247b7baf 100644 --- a/services/api-server/src/simcore_service_api_server/api/root.py +++ b/services/api-server/src/simcore_service_api_server/api/root.py @@ -7,6 +7,7 @@ from .routes import ( files, health, + licensed_items, meta, solvers, solvers_jobs, @@ -40,6 +41,9 @@ def create_router(settings: ApplicationSettings): router.include_router(studies_jobs.router, tags=["studies"], prefix="/studies") router.include_router(wallets.router, tags=["wallets"], prefix="/wallets") router.include_router(_credits.router, tags=["credits"], prefix="/credits") + router.include_router( + licensed_items.router, tags=["licensed-items"], prefix="/licensed-items" + ) # NOTE: multiple-files upload is currently disabled # Web form to upload files at http://localhost:8000/v0/upload-form-view diff --git a/services/api-server/src/simcore_service_api_server/api/routes/credits.py b/services/api-server/src/simcore_service_api_server/api/routes/credits.py index 5b5258cfb01..36069b54988 100644 --- a/services/api-server/src/simcore_service_api_server/api/routes/credits.py +++ b/services/api-server/src/simcore_service_api_server/api/routes/credits.py @@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, status from ...models.schemas.model_adapter import GetCreditPriceLegacy -from ..dependencies.webserver import AuthSession, get_webserver_session +from ..dependencies.webserver_http import AuthSession, get_webserver_session from ._constants import FMSG_CHANGELOG_NEW_IN_VERSION router = APIRouter() diff --git a/services/api-server/src/simcore_service_api_server/api/routes/files.py b/services/api-server/src/simcore_service_api_server/api/routes/files.py index 219306f693b..2187e68ad06 100644 --- a/services/api-server/src/simcore_service_api_server/api/routes/files.py +++ b/services/api-server/src/simcore_service_api_server/api/routes/files.py @@ -37,7 +37,7 @@ FileUploadData, UploadLinks, ) -from ...services.storage import StorageApi, StorageFileMetaData, to_file_api_model +from ...services_http.storage import StorageApi, StorageFileMetaData, to_file_api_model from ..dependencies.authentication import get_current_user_id from ..dependencies.services import get_api_client from ._common import API_SERVER_DEV_FEATURES_ENABLED diff --git a/services/api-server/src/simcore_service_api_server/api/routes/health.py b/services/api-server/src/simcore_service_api_server/api/routes/health.py index 1537d1a5d65..0b7bfbcbc9f 100644 --- a/services/api-server/src/simcore_service_api_server/api/routes/health.py +++ b/services/api-server/src/simcore_service_api_server/api/routes/health.py @@ -10,10 +10,10 @@ from ..._meta import API_VERSION, PROJECT_NAME from ...core.health_checker import ApiServerHealthChecker, get_health_checker -from ...services.catalog import CatalogApi -from ...services.director_v2 import DirectorV2Api -from ...services.storage import StorageApi -from ...services.webserver import WebserverApi +from ...services_http.catalog import CatalogApi +from ...services_http.director_v2 import DirectorV2Api +from ...services_http.storage import StorageApi +from ...services_http.webserver import WebserverApi from ..dependencies.application import get_reverse_url_mapper from ..dependencies.services import get_api_client diff --git a/services/api-server/src/simcore_service_api_server/api/routes/licensed_items.py b/services/api-server/src/simcore_service_api_server/api/routes/licensed_items.py new file mode 100644 index 00000000000..d02151080ad --- /dev/null +++ b/services/api-server/src/simcore_service_api_server/api/routes/licensed_items.py @@ -0,0 +1,34 @@ +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, status + +from ...api.dependencies.authentication import get_product_name +from ...api.dependencies.webserver_rpc import get_wb_api_rpc_client +from ...exceptions.service_errors_utils import DEFAULT_BACKEND_SERVICE_STATUS_CODES +from ...models.pagination import Page, PaginationParams +from ...models.schemas.model_adapter import LicensedItemGet +from ...services_rpc.wb_api_server import WbApiRpcClient + +router = APIRouter() + +_LICENSE_ITEMS_STATUS_CODES: dict[int | str, dict[str, Any]] = { + **DEFAULT_BACKEND_SERVICE_STATUS_CODES, +} + + +@router.get( + "", + response_model=Page[LicensedItemGet], + status_code=status.HTTP_200_OK, + responses=_LICENSE_ITEMS_STATUS_CODES, + description="Get all licensed items", + include_in_schema=False, +) +async def get_licensed_items( + page_params: Annotated[PaginationParams, Depends()], + web_api_rpc: Annotated[WbApiRpcClient, Depends(get_wb_api_rpc_client)], + product_name: Annotated[str, Depends(get_product_name)], +): + return await web_api_rpc.get_licensed_items( + product_name=product_name, page_params=page_params + ) 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 c85b8b39baf..f9f80b37c4c 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 @@ -13,11 +13,11 @@ from ...models.schemas.errors import ErrorGet from ...models.schemas.model_adapter import ServicePricingPlanGetLegacy from ...models.schemas.solvers import Solver, SolverKeyId, SolverPort -from ...services.catalog import CatalogApi +from ...services_http.catalog import CatalogApi 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.webserver import AuthSession, get_webserver_session +from ..dependencies.webserver_http import AuthSession, get_webserver_session from ._common import API_SERVER_DEV_FEATURES_ENABLED from ._constants import FMSG_CHANGELOG_NEW_IN_VERSION 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 af1c80c70ac..609bf0b56a6 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 @@ -26,10 +26,10 @@ JobStatus, ) from ...models.schemas.solvers import Solver, SolverKeyId -from ...services.catalog import CatalogApi -from ...services.director_v2 import DirectorV2Api -from ...services.jobs import replace_custom_metadata, start_project, stop_project -from ...services.solver_job_models_converters import ( +from ...services_http.catalog import CatalogApi +from ...services_http.director_v2 import DirectorV2Api +from ...services_http.jobs import replace_custom_metadata, start_project, stop_project +from ...services_http.solver_job_models_converters import ( create_job_from_project, create_jobstatus_from_task, create_new_project_for_job, @@ -37,7 +37,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.webserver import AuthSession, get_webserver_session +from ..dependencies.webserver_http import AuthSession, get_webserver_session from ._constants import ( FMSG_CHANGELOG_ADDED_IN_VERSION, FMSG_CHANGELOG_CHANGED_IN_VERSION, 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 4903d1cb815..3c2bd479b0a 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 @@ -38,22 +38,22 @@ WalletGetWithAvailableCreditsLegacy, ) from ...models.schemas.solvers import SolverKeyId -from ...services.catalog import CatalogApi -from ...services.director_v2 import DirectorV2Api -from ...services.jobs import ( +from ...services_http.catalog import CatalogApi +from ...services_http.director_v2 import DirectorV2Api +from ...services_http.jobs import ( get_custom_metadata, raise_if_job_not_associated_with_solver, ) -from ...services.log_streaming import LogDistributor, LogStreamer -from ...services.solver_job_models_converters import create_job_from_project -from ...services.solver_job_outputs import ResultsTypes, get_solver_output_results -from ...services.storage import StorageApi, to_file_api_model +from ...services_http.log_streaming import LogDistributor, LogStreamer +from ...services_http.solver_job_models_converters import create_job_from_project +from ...services_http.solver_job_outputs import ResultsTypes, get_solver_output_results +from ...services_http.storage import StorageApi, to_file_api_model from ..dependencies.application import get_reverse_url_mapper from ..dependencies.authentication import get_current_user_id, get_product_name from ..dependencies.database import Engine, get_db_engine from ..dependencies.rabbitmq import get_log_check_timeout, get_log_distributor from ..dependencies.services import get_api_client -from ..dependencies.webserver import AuthSession, get_webserver_session +from ..dependencies.webserver_http import AuthSession, get_webserver_session from ._constants import FMSG_CHANGELOG_NEW_IN_VERSION from .solvers_jobs import ( JOBS_STATUS_CODES, diff --git a/services/api-server/src/simcore_service_api_server/api/routes/studies.py b/services/api-server/src/simcore_service_api_server/api/routes/studies.py index 392acd8c72a..d5f3e2c821e 100644 --- a/services/api-server/src/simcore_service_api_server/api/routes/studies.py +++ b/services/api-server/src/simcore_service_api_server/api/routes/studies.py @@ -6,15 +6,13 @@ from models_library.api_schemas_webserver.projects import ProjectGet from models_library.projects import ProjectID from models_library.projects_nodes_io import NodeID -from simcore_service_api_server.api.routes._constants import ( - FMSG_CHANGELOG_NEW_IN_VERSION, -) +from ...api.routes._constants import FMSG_CHANGELOG_NEW_IN_VERSION from ...models.pagination import OnePage, Page, PaginationParams from ...models.schemas.errors import ErrorGet from ...models.schemas.studies import Study, StudyID, StudyPort -from ...services.webserver import AuthSession -from ..dependencies.webserver import get_webserver_session +from ...services_http.webserver import AuthSession +from ..dependencies.webserver_http import get_webserver_session _logger = logging.getLogger(__name__) router = APIRouter() 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 8d23def5c0b..f1e5513414b 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 @@ -18,7 +18,6 @@ from ...api.dependencies.authentication import get_current_user_id from ...api.dependencies.services import get_api_client -from ...api.dependencies.webserver import get_webserver_session from ...exceptions.backend_errors import ProjectAlreadyStartedError from ...models.pagination import Page, PaginationParams from ...models.schemas.errors import ErrorGet @@ -32,22 +31,23 @@ JobStatus, ) from ...models.schemas.studies import JobLogsMap, Study, StudyID -from ...services.director_v2 import DirectorV2Api -from ...services.jobs import ( +from ...services_http.director_v2 import DirectorV2Api +from ...services_http.jobs import ( get_custom_metadata, replace_custom_metadata, start_project, stop_project, ) -from ...services.solver_job_models_converters import create_jobstatus_from_task -from ...services.storage import StorageApi -from ...services.study_job_models_converters import ( +from ...services_http.solver_job_models_converters import create_jobstatus_from_task +from ...services_http.storage import StorageApi +from ...services_http.study_job_models_converters import ( create_job_from_study, create_job_outputs_from_project_outputs, get_project_and_file_inputs_from_job_inputs, ) -from ...services.webserver import AuthSession +from ...services_http.webserver import AuthSession from ..dependencies.application import get_reverse_url_mapper +from ..dependencies.webserver_http import get_webserver_session from ._common import API_SERVER_DEV_FEATURES_ENABLED from ._constants import FMSG_CHANGELOG_CHANGED_IN_VERSION, FMSG_CHANGELOG_NEW_IN_VERSION from .solvers_jobs import JOBS_STATUS_CODES diff --git a/services/api-server/src/simcore_service_api_server/api/routes/users.py b/services/api-server/src/simcore_service_api_server/api/routes/users.py index b63ff52f18c..1aee57c4648 100644 --- a/services/api-server/src/simcore_service_api_server/api/routes/users.py +++ b/services/api-server/src/simcore_service_api_server/api/routes/users.py @@ -6,8 +6,8 @@ from ...exceptions.service_errors_utils import DEFAULT_BACKEND_SERVICE_STATUS_CODES from ...models.schemas.errors import ErrorGet from ...models.schemas.profiles import Profile, ProfileUpdate -from ...services.webserver import AuthSession -from ..dependencies.webserver import get_webserver_session +from ...services_http.webserver import AuthSession +from ..dependencies.webserver_http import get_webserver_session _logger = logging.getLogger(__name__) diff --git a/services/api-server/src/simcore_service_api_server/api/routes/wallets.py b/services/api-server/src/simcore_service_api_server/api/routes/wallets.py index 40e2233fce0..ca34726eae3 100644 --- a/services/api-server/src/simcore_service_api_server/api/routes/wallets.py +++ b/services/api-server/src/simcore_service_api_server/api/routes/wallets.py @@ -6,7 +6,7 @@ from ...exceptions.service_errors_utils import DEFAULT_BACKEND_SERVICE_STATUS_CODES from ...models.schemas.errors import ErrorGet from ...models.schemas.model_adapter import WalletGetWithAvailableCreditsLegacy -from ..dependencies.webserver import AuthSession, get_webserver_session +from ..dependencies.webserver_http import AuthSession, get_webserver_session from ._constants import FMSG_CHANGELOG_NEW_IN_VERSION _logger = logging.getLogger(__name__) diff --git a/services/api-server/src/simcore_service_api_server/core/application.py b/services/api-server/src/simcore_service_api_server/core/application.py index 714d7993b6b..4de017698a6 100644 --- a/services/api-server/src/simcore_service_api_server/core/application.py +++ b/services/api-server/src/simcore_service_api_server/core/application.py @@ -12,8 +12,8 @@ from .._meta import API_VERSION, API_VTAG, APP_NAME from ..api.root import create_router from ..api.routes.health import router as health_router -from ..services import catalog, director_v2, storage, webserver -from ..services.rabbitmq import setup_rabbitmq +from ..services_http import catalog, director_v2, storage, webserver +from ..services_http.rabbitmq import setup_rabbitmq from ._prometheus_instrumentation import setup_prometheus_instrumentation from .events import create_start_app_handler, create_stop_app_handler from .openapi import override_openapi_method, use_route_names_as_operation_ids diff --git a/services/api-server/src/simcore_service_api_server/core/health_checker.py b/services/api-server/src/simcore_service_api_server/core/health_checker.py index 068b2d79f37..a05e046b2e8 100644 --- a/services/api-server/src/simcore_service_api_server/core/health_checker.py +++ b/services/api-server/src/simcore_service_api_server/core/health_checker.py @@ -16,7 +16,7 @@ from .._meta import PROJECT_NAME from ..models.schemas.jobs import JobID, JobLog -from ..services.log_streaming import LogDistributor +from ..services_http.log_streaming import LogDistributor METRICS_NAMESPACE: Final[str] = PROJECT_NAME.replace("-", "_") diff --git a/services/api-server/src/simcore_service_api_server/exceptions/handlers/__init__.py b/services/api-server/src/simcore_service_api_server/exceptions/handlers/__init__.py index d2779338980..91c4e0d9ccf 100644 --- a/services/api-server/src/simcore_service_api_server/exceptions/handlers/__init__.py +++ b/services/api-server/src/simcore_service_api_server/exceptions/handlers/__init__.py @@ -1,11 +1,11 @@ from fastapi import FastAPI from fastapi.exceptions import RequestValidationError from httpx import HTTPError as HttpxException -from simcore_service_api_server.exceptions.backend_errors import BaseBackEndError from starlette import status from starlette.exceptions import HTTPException from ..._constants import MSG_INTERNAL_ERROR_USER_FRIENDLY_TEMPLATE +from ...exceptions.backend_errors import BaseBackEndError from ..custom_errors import CustomBaseError from ..log_streaming_errors import LogStreamingBaseError from ._custom_errors import custom_error_handler diff --git a/services/api-server/src/simcore_service_api_server/exceptions/handlers/_handlers_backend_errors.py b/services/api-server/src/simcore_service_api_server/exceptions/handlers/_handlers_backend_errors.py index e46d0f8f977..ca335deceac 100644 --- a/services/api-server/src/simcore_service_api_server/exceptions/handlers/_handlers_backend_errors.py +++ b/services/api-server/src/simcore_service_api_server/exceptions/handlers/_handlers_backend_errors.py @@ -1,7 +1,7 @@ -from simcore_service_api_server.exceptions.backend_errors import BaseBackEndError from starlette.requests import Request from starlette.responses import JSONResponse +from ...exceptions.backend_errors import BaseBackEndError from ._utils import create_error_json_response diff --git a/services/api-server/src/simcore_service_api_server/exceptions/service_errors_utils.py b/services/api-server/src/simcore_service_api_server/exceptions/service_errors_utils.py index 39167c92040..90bdc27bad4 100644 --- a/services/api-server/src/simcore_service_api_server/exceptions/service_errors_utils.py +++ b/services/api-server/src/simcore_service_api_server/exceptions/service_errors_utils.py @@ -1,3 +1,5 @@ +# pylint: disable=dangerous-default-value +import asyncio import logging from collections.abc import Callable, Coroutine, Mapping from contextlib import contextmanager @@ -8,8 +10,9 @@ import httpx from fastapi import HTTPException, status from pydantic import ValidationError -from simcore_service_api_server.exceptions.backend_errors import BaseBackEndError +from servicelib.rabbitmq._errors import RemoteMethodNotRegisteredError +from ..exceptions.backend_errors import BaseBackEndError from ..models.schemas.errors import ErrorGet _logger = logging.getLogger(__name__) @@ -50,8 +53,12 @@ class ToApiTuple(NamedTuple): # service to public-api status maps -E = TypeVar("E", bound=BaseBackEndError) -HttpStatusMap: TypeAlias = Mapping[ServiceHTTPStatus, E] +BackEndErrorType = TypeVar("BackEndErrorType", bound=BaseBackEndError) +RpcExceptionType = TypeVar( + "RpcExceptionType", bound=Exception +) # need more specific rpc exception base class +HttpStatusMap: TypeAlias = Mapping[ServiceHTTPStatus, BackEndErrorType] +RabbitMqRpcExceptionMap: TypeAlias = Mapping[RpcExceptionType, BackEndErrorType] def _get_http_exception_kwargs( @@ -98,6 +105,7 @@ def _get_http_exception_kwargs( def service_exception_handler( service_name: str, http_status_map: HttpStatusMap, + rpc_exception_map: RabbitMqRpcExceptionMap, **context, ): status_code: int @@ -126,20 +134,50 @@ def service_exception_handler( status_code=status_code, detail=detail, headers=headers ) from exc + except BaseException as exc: # currently no baseclass for rpc errors + if ( + type(exc) == asyncio.TimeoutError + ): # https://github.com/ITISFoundation/osparc-simcore/blob/master/packages/service-library/src/servicelib/rabbitmq/_client_rpc.py#L76 + raise HTTPException( + status_code=status.HTTP_504_GATEWAY_TIMEOUT, + detail="Request to backend timed out", + ) from exc + if type(exc) in { + asyncio.exceptions.CancelledError, + RuntimeError, + RemoteMethodNotRegisteredError, + }: # https://github.com/ITISFoundation/osparc-simcore/blob/master/packages/service-library/src/servicelib/rabbitmq/_client_rpc.py#L76 + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Request to backend failed", + ) from exc + if backend_error_type := rpc_exception_map.get(type(exc)): + raise backend_error_type(**context) from exc + raise + def service_exception_mapper( + *, service_name: str, - http_status_map: HttpStatusMap, + http_status_map: HttpStatusMap = {}, + rpc_exception_map: RabbitMqRpcExceptionMap = {}, ) -> Callable[ [Callable[Concatenate[Self, P], Coroutine[Any, Any, R]]], Callable[Concatenate[Self, P], Coroutine[Any, Any, R]], ]: def _decorator(member_func: Callable[Concatenate[Self, P], Coroutine[Any, Any, R]]): - _assert_correct_kwargs(func=member_func, status_map=http_status_map) + _assert_correct_kwargs( + func=member_func, + exception_types=set(http_status_map.values()).union( + set(rpc_exception_map.values()) + ), + ) @wraps(member_func) async def _wrapper(self: Self, *args: P.args, **kwargs: P.kwargs) -> R: - with service_exception_handler(service_name, http_status_map, **kwargs): + with service_exception_handler( + service_name, http_status_map, rpc_exception_map, **kwargs + ): return await member_func(self, *args, **kwargs) return _wrapper @@ -147,13 +185,14 @@ async def _wrapper(self: Self, *args: P.args, **kwargs: P.kwargs) -> R: return _decorator -def _assert_correct_kwargs(func: Callable, status_map: HttpStatusMap): +def _assert_correct_kwargs(func: Callable, exception_types: set[BackEndErrorType]): _required_kwargs = { name for name, param in signature(func).parameters.items() if param.kind == param.KEYWORD_ONLY } - for exc_type in status_map.values(): + for exc_type in exception_types: + assert isinstance(exc_type, type) # nosec _exception_inputs = exc_type.named_fields() assert _exception_inputs.issubset( _required_kwargs diff --git a/services/api-server/src/simcore_service_api_server/main.py b/services/api-server/src/simcore_service_api_server/main.py index 8b636ac4315..493874ee6eb 100644 --- a/services/api-server/src/simcore_service_api_server/main.py +++ b/services/api-server/src/simcore_service_api_server/main.py @@ -1,7 +1,8 @@ """Main application to be deployed in for example uvicorn. """ from fastapi import FastAPI -from simcore_service_api_server.core.application import init_app + +from .core.application import init_app # SINGLETON FastAPI app the_app: FastAPI = init_app() diff --git a/services/api-server/src/simcore_service_api_server/models/schemas/meta.py b/services/api-server/src/simcore_service_api_server/models/schemas/meta.py index 9e195214ec0..4251d0f2a77 100644 --- a/services/api-server/src/simcore_service_api_server/models/schemas/meta.py +++ b/services/api-server/src/simcore_service_api_server/models/schemas/meta.py @@ -2,7 +2,8 @@ from models_library.api_schemas__common.meta import BaseMeta from pydantic import ConfigDict, HttpUrl -from simcore_service_api_server.models._utils_pydantic import UriSchema + +from ...models._utils_pydantic import UriSchema class Meta(BaseMeta): diff --git a/services/api-server/src/simcore_service_api_server/models/schemas/model_adapter.py b/services/api-server/src/simcore_service_api_server/models/schemas/model_adapter.py index 3b88cd82ef7..e5a04e198c5 100644 --- a/services/api-server/src/simcore_service_api_server/models/schemas/model_adapter.py +++ b/services/api-server/src/simcore_service_api_server/models/schemas/model_adapter.py @@ -7,6 +7,9 @@ from models_library.api_schemas_api_server.pricing_plans import ( ServicePricingPlanGet as _ServicePricingPlanGet, ) +from models_library.api_schemas_webserver.licensed_items import ( + LicensedItemGet as _LicensedItemGet, +) from models_library.api_schemas_webserver.product import ( GetCreditPrice as _GetCreditPrice, ) @@ -18,6 +21,7 @@ ) from models_library.basic_types import IDStr, NonNegativeDecimal from models_library.groups import GroupID +from models_library.licensed_items import LicensedItemID, LicensedResourceType from models_library.resource_tracker import ( PricingPlanClassification, PricingPlanId, @@ -36,9 +40,6 @@ class GetCreditPriceLegacy(BaseModel): - model_config = ConfigDict( - populate_by_name=True, - ) product_name: str = Field(alias="productName") usd_per_credit: ( Annotated[ @@ -58,6 +59,9 @@ class GetCreditPriceLegacy(BaseModel): "Can be None if this product's price is UNDEFINED", alias="minPaymentAmountUsd", ) + model_config = ConfigDict( + populate_by_name=True, + ) assert set(GetCreditPriceLegacy.model_fields.keys()) == set( @@ -66,9 +70,6 @@ class GetCreditPriceLegacy(BaseModel): class PricingUnitGetLegacy(BaseModel): - model_config = ConfigDict( - populate_by_name=True, - ) pricing_unit_id: PricingUnitId = Field(alias="pricingUnitId") unit_name: str = Field(alias="unitName") unit_extra_info: UnitExtraInfo = Field(alias="unitExtraInfo") @@ -76,6 +77,9 @@ class PricingUnitGetLegacy(BaseModel): Decimal, PlainSerializer(float, return_type=NonNegativeFloat, when_used="json") ] = Field(alias="currentCostPerUnit") default: bool + model_config = ConfigDict( + populate_by_name=True, + ) assert set(PricingUnitGetLegacy.model_fields.keys()) == set( @@ -84,9 +88,6 @@ class PricingUnitGetLegacy(BaseModel): class WalletGetWithAvailableCreditsLegacy(BaseModel): - model_config = ConfigDict( - populate_by_name=True, - ) wallet_id: WalletID = Field(alias="walletId") name: IDStr description: str | None = None @@ -98,6 +99,9 @@ class WalletGetWithAvailableCreditsLegacy(BaseModel): available_credits: Annotated[ Decimal, PlainSerializer(float, return_type=NonNegativeFloat, when_used="json") ] = Field(alias="availableCredits") + model_config = ConfigDict( + populate_by_name=True, + ) assert set(WalletGetWithAvailableCreditsLegacy.model_fields.keys()) == set( @@ -106,9 +110,6 @@ class WalletGetWithAvailableCreditsLegacy(BaseModel): class ServicePricingPlanGetLegacy(BaseModel): - model_config = ConfigDict( - populate_by_name=True, - ) pricing_plan_id: PricingPlanId = Field(alias="pricingPlanId") display_name: str = Field(alias="displayName") description: str @@ -116,8 +117,28 @@ class ServicePricingPlanGetLegacy(BaseModel): created_at: datetime = Field(alias="createdAt") pricing_plan_key: str = Field(alias="pricingPlanKey") pricing_units: list[PricingUnitGetLegacy] = Field(alias="pricingUnits") + model_config = ConfigDict( + populate_by_name=True, + ) assert set(ServicePricingPlanGetLegacy.model_fields.keys()) == set( _ServicePricingPlanGet.model_fields.keys() ) + + +class LicensedItemGet(BaseModel): + licensed_item_id: LicensedItemID + name: Annotated[str, Field(alias="display_name")] + licensed_resource_type: LicensedResourceType + pricing_plan_id: PricingPlanId + created_at: datetime + modified_at: datetime + model_config = ConfigDict( + populate_by_name=True, + ) + + +assert set(LicensedItemGet.model_fields.keys()) == set( + _LicensedItemGet.model_fields.keys() +) diff --git a/services/api-server/src/simcore_service_api_server/models/schemas/solvers.py b/services/api-server/src/simcore_service_api_server/models/schemas/solvers.py index 9db1e9696ad..f3be20211cd 100644 --- a/services/api-server/src/simcore_service_api_server/models/schemas/solvers.py +++ b/services/api-server/src/simcore_service_api_server/models/schemas/solvers.py @@ -7,8 +7,8 @@ from models_library.services_regex import COMPUTATIONAL_SERVICE_KEY_RE from packaging.version import Version from pydantic import BaseModel, ConfigDict, Field, HttpUrl, StringConstraints -from simcore_service_api_server.models._utils_pydantic import UriSchema +from ...models._utils_pydantic import UriSchema from ..api_resources import compose_resource_name from ..basic_types import VersionStr diff --git a/services/api-server/src/simcore_service_api_server/models/schemas/studies.py b/services/api-server/src/simcore_service_api_server/models/schemas/studies.py index 1905477236f..e0686713508 100644 --- a/services/api-server/src/simcore_service_api_server/models/schemas/studies.py +++ b/services/api-server/src/simcore_service_api_server/models/schemas/studies.py @@ -2,8 +2,8 @@ from models_library import projects, projects_nodes_io from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field -from simcore_service_api_server.models._utils_pydantic import UriSchema +from ...models._utils_pydantic import UriSchema from .. import api_resources from . import solvers diff --git a/services/api-server/src/simcore_service_api_server/services/__init__.py b/services/api-server/src/simcore_service_api_server/services_http/__init__.py similarity index 100% rename from services/api-server/src/simcore_service_api_server/services/__init__.py rename to services/api-server/src/simcore_service_api_server/services_http/__init__.py diff --git a/services/api-server/src/simcore_service_api_server/services/catalog.py b/services/api-server/src/simcore_service_api_server/services_http/catalog.py similarity index 94% rename from services/api-server/src/simcore_service_api_server/services/catalog.py rename to services/api-server/src/simcore_service_api_server/services_http/catalog.py index 34f092a6191..c380025c672 100644 --- a/services/api-server/src/simcore_service_api_server/services/catalog.py +++ b/services/api-server/src/simcore_service_api_server/services_http/catalog.py @@ -13,11 +13,11 @@ from pydantic import ConfigDict, TypeAdapter, ValidationError from settings_library.catalog import CatalogSettings from settings_library.tracing import TracingSettings -from simcore_service_api_server.exceptions.backend_errors import ( + +from ..exceptions.backend_errors import ( ListSolversOrStudiesError, SolverOrStudyNotFoundError, ) - from ..exceptions.service_errors_utils import service_exception_mapper from ..models.basic_types import VersionStr from ..models.schemas.solvers import LATEST_VERSION, Solver, SolverKeyId, SolverPort @@ -68,7 +68,7 @@ def to_solver(self) -> Solver: # - Error handling: What do we reraise, suppress, transform??? # -_exception_mapper = partial(service_exception_mapper, "Catalog") +_exception_mapper = partial(service_exception_mapper, service_name="Catalog") TruncatedCatalogServiceOutAdapter: Final[ TypeAdapter[TruncatedCatalogServiceOut] @@ -91,7 +91,9 @@ class CatalogApi(BaseServiceClientApi): SEE osparc-simcore/services/catalog/openapi.json """ - @_exception_mapper({status.HTTP_404_NOT_FOUND: ListSolversOrStudiesError}) + @_exception_mapper( + http_status_map={status.HTTP_404_NOT_FOUND: ListSolversOrStudiesError} + ) async def list_solvers( self, *, @@ -134,7 +136,9 @@ async def list_solvers( ) return solvers - @_exception_mapper({status.HTTP_404_NOT_FOUND: SolverOrStudyNotFoundError}) + @_exception_mapper( + http_status_map={status.HTTP_404_NOT_FOUND: SolverOrStudyNotFoundError} + ) async def get_service( self, *, user_id: int, name: SolverKeyId, version: VersionStr, product_name: str ) -> Solver: @@ -163,7 +167,9 @@ async def get_service( solver: Solver = service.to_solver() return solver - @_exception_mapper({status.HTTP_404_NOT_FOUND: SolverOrStudyNotFoundError}) + @_exception_mapper( + http_status_map={status.HTTP_404_NOT_FOUND: SolverOrStudyNotFoundError} + ) async def get_service_ports( self, *, user_id: int, name: SolverKeyId, version: VersionStr, product_name: str ): diff --git a/services/api-server/src/simcore_service_api_server/services/director_v2.py b/services/api-server/src/simcore_service_api_server/services_http/director_v2.py similarity index 91% rename from services/api-server/src/simcore_service_api_server/services/director_v2.py rename to services/api-server/src/simcore_service_api_server/services_http/director_v2.py index 45f42af73eb..5a40fae7ca4 100644 --- a/services/api-server/src/simcore_service_api_server/services/director_v2.py +++ b/services/api-server/src/simcore_service_api_server/services_http/director_v2.py @@ -60,11 +60,11 @@ class TaskLogFileGet(BaseModel): # API CLASS --------------------------------------------- -_exception_mapper = partial(service_exception_mapper, "Director V2") +_exception_mapper = partial(service_exception_mapper, service_name="Director V2") class DirectorV2Api(BaseServiceClientApi): - @_exception_mapper({}) + @_exception_mapper(http_status_map={}) async def create_computation( self, *, @@ -85,7 +85,7 @@ async def create_computation( task: ComputationTaskGet = ComputationTaskGet.model_validate_json(response.text) return task - @_exception_mapper({}) + @_exception_mapper(http_status_map={}) async def start_computation( self, *, @@ -115,7 +115,7 @@ async def start_computation( task: ComputationTaskGet = ComputationTaskGet.model_validate_json(response.text) return task - @_exception_mapper({status.HTTP_404_NOT_FOUND: JobNotFoundError}) + @_exception_mapper(http_status_map={status.HTTP_404_NOT_FOUND: JobNotFoundError}) async def get_computation( self, *, project_id: UUID, user_id: PositiveInt ) -> ComputationTaskGet: @@ -129,7 +129,7 @@ async def get_computation( task: ComputationTaskGet = ComputationTaskGet.model_validate_json(response.text) return task - @_exception_mapper({status.HTTP_404_NOT_FOUND: JobNotFoundError}) + @_exception_mapper(http_status_map={status.HTTP_404_NOT_FOUND: JobNotFoundError}) async def stop_computation( self, *, project_id: UUID, user_id: PositiveInt ) -> ComputationTaskGet: @@ -143,7 +143,7 @@ async def stop_computation( task: ComputationTaskGet = ComputationTaskGet.model_validate_json(response.text) return task - @_exception_mapper({status.HTTP_404_NOT_FOUND: JobNotFoundError}) + @_exception_mapper(http_status_map={status.HTTP_404_NOT_FOUND: JobNotFoundError}) async def delete_computation(self, *, project_id: UUID, user_id: PositiveInt): response = await self.client.request( "DELETE", @@ -155,7 +155,9 @@ async def delete_computation(self, *, project_id: UUID, user_id: PositiveInt): ) response.raise_for_status() - @_exception_mapper({status.HTTP_404_NOT_FOUND: LogFileNotFoundError}) + @_exception_mapper( + http_status_map={status.HTTP_404_NOT_FOUND: LogFileNotFoundError} + ) async def get_computation_logs( self, *, user_id: PositiveInt, project_id: UUID ) -> JobLogsMap: diff --git a/services/api-server/src/simcore_service_api_server/services/jobs.py b/services/api-server/src/simcore_service_api_server/services_http/jobs.py similarity index 97% rename from services/api-server/src/simcore_service_api_server/services/jobs.py rename to services/api-server/src/simcore_service_api_server/services_http/jobs.py index 277f9625f17..ed2ef50d588 100644 --- a/services/api-server/src/simcore_service_api_server/services/jobs.py +++ b/services/api-server/src/simcore_service_api_server/services_http/jobs.py @@ -9,7 +9,7 @@ from ..api.dependencies.authentication import get_current_user_id from ..api.dependencies.services import get_api_client -from ..api.dependencies.webserver import get_webserver_session +from ..api.dependencies.webserver_http import get_webserver_session from ..models.schemas.jobs import ( JobID, JobMetadata, diff --git a/services/api-server/src/simcore_service_api_server/services/log_streaming.py b/services/api-server/src/simcore_service_api_server/services_http/log_streaming.py similarity index 100% rename from services/api-server/src/simcore_service_api_server/services/log_streaming.py rename to services/api-server/src/simcore_service_api_server/services_http/log_streaming.py diff --git a/services/api-server/src/simcore_service_api_server/services/rabbitmq.py b/services/api-server/src/simcore_service_api_server/services_http/rabbitmq.py similarity index 72% rename from services/api-server/src/simcore_service_api_server/services/rabbitmq.py rename to services/api-server/src/simcore_service_api_server/services_http/rabbitmq.py index 7b72e54b5ea..07201402876 100644 --- a/services/api-server/src/simcore_service_api_server/services/rabbitmq.py +++ b/services/api-server/src/simcore_service_api_server/services_http/rabbitmq.py @@ -2,10 +2,13 @@ from fastapi import FastAPI from servicelib.rabbitmq import RabbitMQClient, wait_till_rabbitmq_responsive +from servicelib.rabbitmq._client_rpc import RabbitMQRPCClient from settings_library.rabbit import RabbitSettings -from simcore_service_api_server.core.health_checker import ApiServerHealthChecker +from simcore_service_api_server.api.dependencies.rabbitmq import get_rabbitmq_rpc_client +from simcore_service_api_server.services_rpc import wb_api_server -from ..services.log_streaming import LogDistributor +from ..core.health_checker import ApiServerHealthChecker +from ..services_http.log_streaming import LogDistributor _logger = logging.getLogger(__name__) @@ -18,6 +21,9 @@ def setup_rabbitmq(app: FastAPI) -> None: async def _on_startup() -> None: await wait_till_rabbitmq_responsive(settings.dsn) + app.state.rabbitmq_rpc_client = await RabbitMQRPCClient.create( + client_name="api_server", settings=settings + ) app.state.rabbitmq_client = RabbitMQClient( client_name="api_server", settings=settings ) @@ -32,6 +38,7 @@ async def _on_startup() -> None: await app.state.health_checker.setup( app.state.settings.API_SERVER_HEALTH_CHECK_TASK_PERIOD_SECONDS ) + wb_api_server.setup(app, get_rabbitmq_rpc_client(app)) async def _on_shutdown() -> None: if app.state.health_checker: @@ -40,6 +47,8 @@ async def _on_shutdown() -> None: await app.state.log_distributor.teardown() if app.state.rabbitmq_client: await app.state.rabbitmq_client.close() + if app.state.rabbitmq_rpc_client: + await app.state.rabbitmq_rpc_client.close() app.add_event_handler("startup", _on_startup) app.add_event_handler("shutdown", _on_shutdown) diff --git a/services/api-server/src/simcore_service_api_server/services/solver_job_models_converters.py b/services/api-server/src/simcore_service_api_server/services_http/solver_job_models_converters.py similarity index 100% rename from services/api-server/src/simcore_service_api_server/services/solver_job_models_converters.py rename to services/api-server/src/simcore_service_api_server/services_http/solver_job_models_converters.py diff --git a/services/api-server/src/simcore_service_api_server/services/solver_job_outputs.py b/services/api-server/src/simcore_service_api_server/services_http/solver_job_outputs.py similarity index 94% rename from services/api-server/src/simcore_service_api_server/services/solver_job_outputs.py rename to services/api-server/src/simcore_service_api_server/services_http/solver_job_outputs.py index dac7610b5a3..5457a259f8c 100644 --- a/services/api-server/src/simcore_service_api_server/services/solver_job_outputs.py +++ b/services/api-server/src/simcore_service_api_server/services_http/solver_job_outputs.py @@ -7,9 +7,8 @@ from pydantic import StrictBool, StrictFloat, StrictInt, TypeAdapter from simcore_sdk import node_ports_v2 from simcore_sdk.node_ports_v2 import DBManager, Nodeports -from simcore_service_api_server.exceptions.backend_errors import ( - SolverOutputNotFoundError, -) + +from ..exceptions.backend_errors import SolverOutputNotFoundError log = logging.getLogger(__name__) diff --git a/services/api-server/src/simcore_service_api_server/services/storage.py b/services/api-server/src/simcore_service_api_server/services_http/storage.py similarity index 95% rename from services/api-server/src/simcore_service_api_server/services/storage.py rename to services/api-server/src/simcore_service_api_server/services_http/storage.py index 0095dd343f5..aa8b724ce98 100644 --- a/services/api-server/src/simcore_service_api_server/services/storage.py +++ b/services/api-server/src/simcore_service_api_server/services_http/storage.py @@ -24,7 +24,7 @@ _logger = logging.getLogger(__name__) -_exception_mapper = partial(service_exception_mapper, "Storage") +_exception_mapper = partial(service_exception_mapper, service_name="Storage") _FILE_ID_PATTERN = re.compile(r"^api\/(?P[\w-]+)\/(?P.+)$") AccessRight = Literal["read", "write"] @@ -54,7 +54,7 @@ class StorageApi(BaseServiceClientApi): # SIMCORE_S3_ID = 0 - @_exception_mapper({}) + @_exception_mapper(http_status_map={}) async def list_files( self, *, @@ -81,7 +81,7 @@ async def list_files( ) return files - @_exception_mapper({}) + @_exception_mapper(http_status_map={}) async def search_owned_files( self, *, @@ -119,7 +119,7 @@ async def search_owned_files( assert len(files) <= limit if limit else True # nosec return files - @_exception_mapper({}) + @_exception_mapper(http_status_map={}) async def get_download_link( self, *, user_id: int, file_id: UUID, file_name: str ) -> AnyUrl: @@ -138,7 +138,7 @@ async def get_download_link( link: AnyUrl = presigned_link.link return link - @_exception_mapper({}) + @_exception_mapper(http_status_map={}) async def delete_file(self, *, user_id: int, quoted_storage_file_id: str) -> None: response = await self.client.delete( f"/locations/{self.SIMCORE_S3_ID}/files/{quoted_storage_file_id}", @@ -146,7 +146,7 @@ async def delete_file(self, *, user_id: int, quoted_storage_file_id: str) -> Non ) response.raise_for_status() - @_exception_mapper({}) + @_exception_mapper(http_status_map={}) async def get_upload_links( self, *, user_id: int, file_id: UUID, file_name: str ) -> FileUploadSchema: @@ -183,7 +183,7 @@ async def create_abort_upload_link( url = url.include_query_params(**query) return url - @_exception_mapper({}) + @_exception_mapper(http_status_map={}) async def create_soft_link( self, *, user_id: int, target_s3_path: str, as_file_id: UUID ) -> File: diff --git a/services/api-server/src/simcore_service_api_server/services/study_job_models_converters.py b/services/api-server/src/simcore_service_api_server/services_http/study_job_models_converters.py similarity index 100% rename from services/api-server/src/simcore_service_api_server/services/study_job_models_converters.py rename to services/api-server/src/simcore_service_api_server/services_http/study_job_models_converters.py diff --git a/services/api-server/src/simcore_service_api_server/services/webserver.py b/services/api-server/src/simcore_service_api_server/services_http/webserver.py similarity index 92% rename from services/api-server/src/simcore_service_api_server/services/webserver.py rename to services/api-server/src/simcore_service_api_server/services_http/webserver.py index c7d5680eb37..a66bd7ff3d3 100644 --- a/services/api-server/src/simcore_service_api_server/services/webserver.py +++ b/services/api-server/src/simcore_service_api_server/services_http/webserver.py @@ -46,9 +46,18 @@ X_SIMCORE_PARENT_PROJECT_UUID, ) from settings_library.tracing import TracingSettings -from simcore_service_api_server.exceptions.backend_errors import ( +from tenacity import TryAgain +from tenacity.asyncio import AsyncRetrying +from tenacity.before_sleep import before_sleep_log +from tenacity.stop import stop_after_delay +from tenacity.wait import wait_fixed + +from ..core.settings import WebServerSettings +from ..exceptions.backend_errors import ( + ClusterNotFoundError, ConfigurationError, ForbiddenWalletError, + JobNotFoundError, ListJobsError, PaymentRequiredError, PricingPlanNotFoundError, @@ -61,15 +70,6 @@ SolverOutputNotFoundError, WalletNotFoundError, ) -from simcore_service_api_server.models.schemas.model_adapter import GetCreditPriceLegacy -from tenacity import TryAgain -from tenacity.asyncio import AsyncRetrying -from tenacity.before_sleep import before_sleep_log -from tenacity.stop import stop_after_delay -from tenacity.wait import wait_fixed - -from ..core.settings import WebServerSettings -from ..exceptions.backend_errors import ClusterNotFoundError, JobNotFoundError from ..exceptions.service_errors_utils import ( service_exception_handler, service_exception_mapper, @@ -78,6 +78,7 @@ from ..models.pagination import MAXIMUM_NUMBER_OF_ITEMS_PER_PAGE from ..models.schemas.jobs import MetaValueType from ..models.schemas.model_adapter import ( + GetCreditPriceLegacy, PricingUnitGetLegacy, WalletGetWithAvailableCreditsLegacy, ) @@ -88,7 +89,7 @@ _logger = logging.getLogger(__name__) -_exception_mapper = partial(service_exception_mapper, "Webserver") +_exception_mapper = partial(service_exception_mapper, service_name="Webserver") _JOB_STATUS_MAP = { status.HTTP_402_PAYMENT_REQUIRED: PaymentRequiredError, @@ -196,6 +197,7 @@ async def _page_projects( with service_exception_handler( service_name="Webserver", http_status_map={status.HTTP_404_NOT_FOUND: ListJobsError}, + rpc_exception_map={}, ): resp = await self.client.get( "/projects", @@ -243,7 +245,7 @@ async def _wait_for_long_running_task_results(self, lrt_response: httpx.Response # PROFILE -------------------------------------------------- - @_exception_mapper(_PROFILE_STATUS_MAP) + @_exception_mapper(http_status_map=_PROFILE_STATUS_MAP) async def get_me(self) -> Profile: response = await self.client.get("/me", cookies=self.session_cookies) response.raise_for_status() @@ -263,7 +265,7 @@ async def get_me(self) -> Profile: gravatar_id=got.gravatar_id, ) - @_exception_mapper(_PROFILE_STATUS_MAP) + @_exception_mapper(http_status_map=_PROFILE_STATUS_MAP) async def update_me(self, *, profile_update: ProfileUpdate) -> Profile: update = WebProfileUpdate.model_construct( @@ -283,7 +285,7 @@ async def update_me(self, *, profile_update: ProfileUpdate) -> Profile: # PROJECTS ------------------------------------------------- - @_exception_mapper({}) + @_exception_mapper(http_status_map={}) async def create_project( self, project: ProjectCreateNew, @@ -308,7 +310,7 @@ async def create_project( result = await self._wait_for_long_running_task_results(response) return ProjectGet.model_validate(result) - @_exception_mapper(_JOB_STATUS_MAP) + @_exception_mapper(http_status_map=_JOB_STATUS_MAP) async def clone_project( self, *, @@ -333,7 +335,7 @@ async def clone_project( result = await self._wait_for_long_running_task_results(response) return ProjectGet.model_validate(result) - @_exception_mapper(_JOB_STATUS_MAP) + @_exception_mapper(http_status_map=_JOB_STATUS_MAP) async def get_project(self, *, project_id: UUID) -> ProjectGet: response = await self.client.get( f"/projects/{project_id}", @@ -363,7 +365,7 @@ async def get_projects_page(self, *, limit: int, offset: int): show_hidden=False, ) - @_exception_mapper(_JOB_STATUS_MAP) + @_exception_mapper(http_status_map=_JOB_STATUS_MAP) async def delete_project(self, *, project_id: ProjectID) -> None: response = await self.client.delete( f"/projects/{project_id}", @@ -371,7 +373,9 @@ async def delete_project(self, *, project_id: ProjectID) -> None: ) response.raise_for_status() - @_exception_mapper({status.HTTP_404_NOT_FOUND: ProjectPortsNotFoundError}) + @_exception_mapper( + http_status_map={status.HTTP_404_NOT_FOUND: ProjectPortsNotFoundError} + ) async def get_project_metadata_ports( self, *, project_id: ProjectID ) -> list[StudyPort]: @@ -389,7 +393,9 @@ async def get_project_metadata_ports( assert isinstance(data, list) # nosec return data - @_exception_mapper({status.HTTP_404_NOT_FOUND: ProjectMetadataNotFoundError}) + @_exception_mapper( + http_status_map={status.HTTP_404_NOT_FOUND: ProjectMetadataNotFoundError} + ) async def get_project_metadata( self, *, project_id: ProjectID ) -> ProjectMetadataGet: @@ -402,7 +408,7 @@ async def get_project_metadata( assert data is not None # nosec return data - @_exception_mapper(_JOB_STATUS_MAP) + @_exception_mapper(http_status_map=_JOB_STATUS_MAP) async def patch_project(self, *, project_id: UUID, patch_params: ProjectPatch): response = await self.client.patch( f"/projects/{project_id}", @@ -411,7 +417,9 @@ async def patch_project(self, *, project_id: UUID, patch_params: ProjectPatch): ) response.raise_for_status() - @_exception_mapper({status.HTTP_404_NOT_FOUND: ProjectMetadataNotFoundError}) + @_exception_mapper( + http_status_map={status.HTTP_404_NOT_FOUND: ProjectMetadataNotFoundError} + ) async def update_project_metadata( self, *, project_id: ProjectID, metadata: dict[str, MetaValueType] ) -> ProjectMetadataGet: @@ -425,7 +433,9 @@ async def update_project_metadata( assert data is not None # nosec return data - @_exception_mapper({status.HTTP_404_NOT_FOUND: PricingUnitNotFoundError}) + @_exception_mapper( + http_status_map={status.HTTP_404_NOT_FOUND: PricingUnitNotFoundError} + ) async def get_project_node_pricing_unit( self, *, project_id: UUID, node_id: UUID ) -> PricingUnitGetLegacy: @@ -439,7 +449,9 @@ async def get_project_node_pricing_unit( assert data is not None # nosec return data - @_exception_mapper({status.HTTP_404_NOT_FOUND: PricingUnitNotFoundError}) + @_exception_mapper( + http_status_map={status.HTTP_404_NOT_FOUND: PricingUnitNotFoundError} + ) async def connect_pricing_unit_to_project_node( self, *, @@ -455,7 +467,7 @@ async def connect_pricing_unit_to_project_node( response.raise_for_status() @_exception_mapper( - _JOB_STATUS_MAP + http_status_map=_JOB_STATUS_MAP | { status.HTTP_409_CONFLICT: ProjectAlreadyStartedError, status.HTTP_406_NOT_ACCEPTABLE: ClusterNotFoundError, @@ -477,7 +489,7 @@ async def start_project( ) response.raise_for_status() - @_exception_mapper({}) + @_exception_mapper(http_status_map={}) async def update_project_inputs( self, *, @@ -498,7 +510,7 @@ async def update_project_inputs( assert data is not None # nosec return data - @_exception_mapper({}) + @_exception_mapper(http_status_map={}) async def get_project_inputs( self, *, project_id: ProjectID ) -> dict[NodeID, ProjectInputGet]: @@ -517,7 +529,9 @@ async def get_project_inputs( assert data is not None # nosec return data - @_exception_mapper({status.HTTP_404_NOT_FOUND: SolverOutputNotFoundError}) + @_exception_mapper( + http_status_map={status.HTTP_404_NOT_FOUND: SolverOutputNotFoundError} + ) async def get_project_outputs( self, *, project_id: ProjectID ) -> dict[NodeID, dict[str, Any]]: @@ -536,7 +550,7 @@ async def get_project_outputs( assert data is not None # nosec return data - @_exception_mapper({}) + @_exception_mapper(http_status_map={}) async def update_node_outputs( self, *, project_id: UUID, node_id: UUID, new_node_outputs: NodeOutputs ) -> None: @@ -549,7 +563,7 @@ async def update_node_outputs( # WALLETS ------------------------------------------------- - @_exception_mapper(_WALLET_STATUS_MAP) + @_exception_mapper(http_status_map=_WALLET_STATUS_MAP) async def get_default_wallet(self) -> WalletGetWithAvailableCreditsLegacy: response = await self.client.get( "/wallets/default", @@ -564,7 +578,7 @@ async def get_default_wallet(self) -> WalletGetWithAvailableCreditsLegacy: assert data is not None # nosec return data - @_exception_mapper(_WALLET_STATUS_MAP) + @_exception_mapper(http_status_map=_WALLET_STATUS_MAP) async def get_wallet( self, *, wallet_id: int ) -> WalletGetWithAvailableCreditsLegacy: @@ -581,7 +595,7 @@ async def get_wallet( assert data is not None # nosec return data - @_exception_mapper(_WALLET_STATUS_MAP) + @_exception_mapper(http_status_map=_WALLET_STATUS_MAP) async def get_project_wallet(self, *, project_id: ProjectID) -> WalletGet: response = await self.client.get( f"/projects/{project_id}/wallet", @@ -594,7 +608,9 @@ async def get_project_wallet(self, *, project_id: ProjectID) -> WalletGet: # PRODUCTS ------------------------------------------------- - @_exception_mapper({status.HTTP_404_NOT_FOUND: ProductPriceNotFoundError}) + @_exception_mapper( + http_status_map={status.HTTP_404_NOT_FOUND: ProductPriceNotFoundError} + ) async def get_product_price(self) -> GetCreditPriceLegacy: response = await self.client.get( "/credits-price", @@ -607,7 +623,9 @@ async def get_product_price(self) -> GetCreditPriceLegacy: # SERVICES ------------------------------------------------- - @_exception_mapper({status.HTTP_404_NOT_FOUND: PricingPlanNotFoundError}) + @_exception_mapper( + http_status_map={status.HTTP_404_NOT_FOUND: PricingPlanNotFoundError} + ) async def get_service_pricing_plan( self, *, solver_key: SolverKeyId, version: VersionStr ) -> ServicePricingPlanGet | None: diff --git a/services/api-server/src/simcore_service_api_server/services_rpc/__init__.py b/services/api-server/src/simcore_service_api_server/services_rpc/__init__.py new file mode 100644 index 00000000000..e69de29bb2d 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 new file mode 100644 index 00000000000..207712e3e47 --- /dev/null +++ b/services/api-server/src/simcore_service_api_server/services_rpc/wb_api_server.py @@ -0,0 +1,52 @@ +from dataclasses import dataclass +from functools import partial +from typing import cast + +from fastapi import FastAPI +from fastapi_pagination import Page, create_page +from servicelib.rabbitmq._client_rpc import RabbitMQRPCClient +from servicelib.rabbitmq.rpc_interfaces.webserver.licenses.licensed_items import ( + get_licensed_items as _get_licensed_items, +) + +from ..exceptions.service_errors_utils import service_exception_mapper +from ..models.pagination import PaginationParams +from ..models.schemas.model_adapter import LicensedItemGet + +_exception_mapper = partial(service_exception_mapper, service_name="WebApiServer") + + +@dataclass +class WbApiRpcClient: + _client: RabbitMQRPCClient + + @_exception_mapper(rpc_exception_map={}) + async def get_licensed_items( + self, product_name: str, page_params: PaginationParams + ) -> Page[LicensedItemGet]: + licensed_items_page = await _get_licensed_items( + rabbitmq_rpc_client=self._client, + product_name=product_name, + offset=page_params.offset, + limit=page_params.limit, + ) + page = create_page( + [ + LicensedItemGet( + licensed_item_id=elm.licensed_item_id, + name=elm.name, + licensed_resource_type=elm.licensed_resource_type, + pricing_plan_id=elm.pricing_plan_id, + created_at=elm.created_at, + modified_at=elm.modified_at, + ) + for elm in licensed_items_page.items + ], + total=licensed_items_page.total, + params=page_params, + ) + return cast(Page[LicensedItemGet], page) + + +def setup(app: FastAPI, rabbitmq_rmp_client: RabbitMQRPCClient): + app.state.wb_api_rpc_client = WbApiRpcClient(_client=rabbitmq_rmp_client) diff --git a/services/api-server/tests/unit/api_solvers/conftest.py b/services/api-server/tests/unit/api_solvers/conftest.py index ec8bf7d5630..4d008380aaf 100644 --- a/services/api-server/tests/unit/api_solvers/conftest.py +++ b/services/api-server/tests/unit/api_solvers/conftest.py @@ -16,7 +16,7 @@ from pytest_simcore.helpers import faker_catalog from respx import MockRouter from simcore_service_api_server.core.settings import ApplicationSettings -from simcore_service_api_server.services.director_v2 import ComputationTaskGet +from simcore_service_api_server.services_http.director_v2 import ComputationTaskGet @pytest.fixture diff --git a/services/api-server/tests/unit/api_solvers/test_api_routers_solvers_jobs.py b/services/api-server/tests/unit/api_solvers/test_api_routers_solvers_jobs.py index 865983537b0..e3f97def12d 100644 --- a/services/api-server/tests/unit/api_solvers/test_api_routers_solvers_jobs.py +++ b/services/api-server/tests/unit/api_solvers/test_api_routers_solvers_jobs.py @@ -22,7 +22,7 @@ from simcore_service_api_server._meta import API_VTAG from simcore_service_api_server.core.settings import ApplicationSettings from simcore_service_api_server.models.schemas.jobs import Job, JobInputs, JobStatus -from simcore_service_api_server.services.director_v2 import ComputationTaskGet +from simcore_service_api_server.services_http.director_v2 import ComputationTaskGet from starlette import status diff --git a/services/api-server/tests/unit/api_solvers/test_api_routers_solvers_jobs_logs.py b/services/api-server/tests/unit/api_solvers/test_api_routers_solvers_jobs_logs.py index eb821e46d01..80a7176ca85 100644 --- a/services/api-server/tests/unit/api_solvers/test_api_routers_solvers_jobs_logs.py +++ b/services/api-server/tests/unit/api_solvers/test_api_routers_solvers_jobs_logs.py @@ -79,7 +79,7 @@ def fake_project_for_streaming( fake_project = ProjectGet.model_validate(data) fake_project.workbench = {faker.uuid4(): faker.uuid4()} mocker.patch( - "simcore_service_api_server.api.dependencies.webserver.AuthSession.get_project", + "simcore_service_api_server.api.dependencies.webserver_http.AuthSession.get_project", return_value=fake_project, ) diff --git a/services/api-server/tests/unit/conftest.py b/services/api-server/tests/unit/conftest.py index a8ade97aee9..2b994b2f612 100644 --- a/services/api-server/tests/unit/conftest.py +++ b/services/api-server/tests/unit/conftest.py @@ -46,7 +46,7 @@ from simcore_service_api_server.core.application import init_app from simcore_service_api_server.core.settings import ApplicationSettings from simcore_service_api_server.db.repositories.api_keys import UserAndProductTuple -from simcore_service_api_server.services.solver_job_outputs import ResultsTypes +from simcore_service_api_server.services_http.solver_job_outputs import ResultsTypes @pytest.fixture 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 4ed52877cfa..0f0e91f126f 100644 --- a/services/api-server/tests/unit/test_api_solver_jobs.py +++ b/services/api-server/tests/unit/test_api_solver_jobs.py @@ -28,7 +28,7 @@ WalletGetWithAvailableCreditsLegacy, ) from simcore_service_api_server.models.schemas.solvers import Solver -from simcore_service_api_server.services.director_v2 import ComputationTaskGet +from simcore_service_api_server.services_http.director_v2 import ComputationTaskGet def _start_job_side_effect( diff --git a/services/api-server/tests/unit/test_exceptions.py b/services/api-server/tests/unit/test_exceptions.py index 6949e72d088..be81922bf4f 100644 --- a/services/api-server/tests/unit/test_exceptions.py +++ b/services/api-server/tests/unit/test_exceptions.py @@ -1,7 +1,7 @@ # pylint: disable=unused-variable # pylint: disable=unused-argument # pylint: disable=redefined-outer-name - +# pylint: disable=too-many-arguments from http import HTTPStatus from uuid import UUID @@ -25,8 +25,8 @@ async def test_backend_service_exception_mapper(): @service_exception_mapper( - "DummyService", - {status.HTTP_400_BAD_REQUEST: ProfileNotFoundError}, + service_name="DummyService", + http_status_map={status.HTTP_400_BAD_REQUEST: ProfileNotFoundError}, ) async def my_endpoint(status_code: int): raise HTTPStatusError( @@ -103,26 +103,26 @@ async def coro1(project_id): pass with pytest.raises(AssertionError): - _assert_correct_kwargs(func=coro1, status_map=status_map) + _assert_correct_kwargs(func=coro1, exception_types=set(status_map.values())) async def coro2(project_id=UUID("9c201eb7-ba04-4d9b-abe6-f16b406ca86d")): pass with pytest.raises(AssertionError) as exc: - _assert_correct_kwargs(func=coro2, status_map=status_map) + _assert_correct_kwargs(func=coro2, exception_types=set(status_map.values())) async def coro3(*, project_id): pass - _assert_correct_kwargs(func=coro3, status_map=status_map) + _assert_correct_kwargs(func=coro3, exception_types=set(status_map.values())) async def coro4(*, project_id=UUID("ce56af2e-e9e5-46a4-8067-662077de5528")): pass - _assert_correct_kwargs(func=coro4, status_map=status_map) + _assert_correct_kwargs(func=coro4, exception_types=set(status_map.values())) async def coro5(*, project_uuid): pass with pytest.raises(AssertionError): - _assert_correct_kwargs(func=coro5, status_map=status_map) + _assert_correct_kwargs(func=coro5, exception_types=set(status_map.values())) diff --git a/services/api-server/tests/unit/test_licensed_items.py b/services/api-server/tests/unit/test_licensed_items.py new file mode 100644 index 00000000000..e0db32dc3b7 --- /dev/null +++ b/services/api-server/tests/unit/test_licensed_items.py @@ -0,0 +1,90 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments +import asyncio + +import pytest +from fastapi import FastAPI, status +from httpx import AsyncClient, BasicAuth +from models_library.api_schemas_webserver.licensed_items import ( + LicensedItemGet as _LicensedItemGet, +) +from models_library.api_schemas_webserver.licensed_items import ( + LicensedItemGetPage as _LicensedItemGetPage, +) +from pydantic import TypeAdapter +from pytest_mock import MockerFixture +from servicelib.rabbitmq._client_rpc import RabbitMQRPCClient +from servicelib.rabbitmq._errors import RemoteMethodNotRegisteredError +from simcore_service_api_server._meta import API_VTAG +from simcore_service_api_server.api.dependencies.webserver_rpc import ( + get_wb_api_rpc_client, +) +from simcore_service_api_server.models.pagination import Page +from simcore_service_api_server.models.schemas.model_adapter import LicensedItemGet +from simcore_service_api_server.services_rpc.wb_api_server import WbApiRpcClient + + +@pytest.fixture +async def mock_wb_api_server_rcp( + app: FastAPI, mocker: MockerFixture, exception_to_raise: Exception | None +) -> MockerFixture: + async def _get_backend_licensed_items( + rabbitmq_rpc_client: RabbitMQRPCClient, + *, + product_name: str, + offset: int, + limit: int, + ) -> _LicensedItemGetPage: + if exception_to_raise is not None: + raise exception_to_raise + extra = _LicensedItemGet.model_config.get("json_schema_extra") + assert isinstance(extra, dict) + examples = extra.get("examples") + assert isinstance(examples, list) + return _LicensedItemGetPage( + items=[_LicensedItemGet.model_validate(ex) for ex in examples], + total=len(examples), + ) + + class DummyRpcClient: + pass + + app.dependency_overrides[get_wb_api_rpc_client] = lambda: WbApiRpcClient( + _client=DummyRpcClient() + ) + mocker.patch( + "simcore_service_api_server.services_rpc.wb_api_server._get_licensed_items", + _get_backend_licensed_items, + ) + + return mocker + + +@pytest.mark.parametrize("exception_to_raise", [None]) +async def test_get_licensed_items( + mock_wb_api_server_rcp: MockerFixture, client: AsyncClient, auth: BasicAuth +): + resp = await client.get(f"{API_VTAG}/licensed-items", auth=auth) + assert resp.status_code == status.HTTP_200_OK + TypeAdapter(Page[LicensedItemGet]).validate_json(resp.text) + + +@pytest.mark.parametrize("exception_to_raise", [asyncio.TimeoutError()]) +async def test_get_licensed_items_timeout( + mock_wb_api_server_rcp: MockerFixture, client: AsyncClient, auth: BasicAuth +): + resp = await client.get(f"{API_VTAG}/licensed-items", auth=auth) + assert resp.status_code == status.HTTP_504_GATEWAY_TIMEOUT + + +@pytest.mark.parametrize( + "exception_to_raise", + [asyncio.CancelledError(), RuntimeError(), RemoteMethodNotRegisteredError()], +) +async def test_get_licensed_items_502( + mock_wb_api_server_rcp: MockerFixture, client: AsyncClient, auth: BasicAuth +): + resp = await client.get(f"{API_VTAG}/licensed-items", auth=auth) + assert resp.status_code == status.HTTP_502_BAD_GATEWAY diff --git a/services/api-server/tests/unit/test_models_schemas_files.py b/services/api-server/tests/unit/test_models_schemas_files.py index 2ae9c4e5144..578216dd253 100644 --- a/services/api-server/tests/unit/test_models_schemas_files.py +++ b/services/api-server/tests/unit/test_models_schemas_files.py @@ -16,7 +16,7 @@ from models_library.projects_nodes_io import StorageFileID from pydantic import TypeAdapter, ValidationError from simcore_service_api_server.models.schemas.files import File -from simcore_service_api_server.services.storage import to_file_api_model +from simcore_service_api_server.services_http.storage import to_file_api_model FILE_CONTENT = "This is a test" diff --git a/services/api-server/tests/unit/test_services_directorv2.py b/services/api-server/tests/unit/test_services_directorv2.py index b88965fd755..bd5e6d9a7ae 100644 --- a/services/api-server/tests/unit/test_services_directorv2.py +++ b/services/api-server/tests/unit/test_services_directorv2.py @@ -12,7 +12,7 @@ from respx import MockRouter from settings_library.director_v2 import DirectorV2Settings from simcore_service_api_server.exceptions.backend_errors import JobNotFoundError -from simcore_service_api_server.services.director_v2 import DirectorV2Api +from simcore_service_api_server.services_http.director_v2 import DirectorV2Api @pytest.fixture diff --git a/services/api-server/tests/unit/test_services_rabbitmq.py b/services/api-server/tests/unit/test_services_rabbitmq.py index 03c0f758fca..aa644e81500 100644 --- a/services/api-server/tests/unit/test_services_rabbitmq.py +++ b/services/api-server/tests/unit/test_services_rabbitmq.py @@ -11,7 +11,7 @@ from collections.abc import AsyncIterable, Callable, Iterable from contextlib import asynccontextmanager from datetime import datetime, timedelta -from typing import Final, Literal +from typing import Final, Literal, cast from unittest.mock import AsyncMock from uuid import UUID @@ -38,11 +38,11 @@ from simcore_service_api_server.api.dependencies.rabbitmq import get_log_distributor from simcore_service_api_server.core.health_checker import get_health_checker from simcore_service_api_server.models.schemas.jobs import JobID, JobLog -from simcore_service_api_server.services.director_v2 import ( +from simcore_service_api_server.services_http.director_v2 import ( ComputationTaskGet, DirectorV2Api, ) -from simcore_service_api_server.services.log_streaming import ( +from simcore_service_api_server.services_http.log_streaming import ( LogDistributor, LogStreamer, LogStreamerRegistrationConflictError, @@ -167,7 +167,7 @@ async def _go( if log_message is None: log_message = LoggerRabbitMessage( user_id=user_id, - project_id=project_id_ or faker.uuid4(), + project_id=project_id_ or cast(UUID, faker.uuid4(cast_to=None)), node_id=node_id_, messages=messages_ or [faker.text() for _ in range(10)], log_level=level_ or logging.INFO, @@ -441,7 +441,7 @@ async def deregister(self, job_id: None): async def test_log_generator(mocker: MockFixture, faker: Faker): mocker.patch( - "simcore_service_api_server.services.log_streaming.LogStreamer._project_done", + "simcore_service_api_server.services_http.log_streaming.LogStreamer._project_done", return_value=True, ) log_streamer = LogStreamer(user_id=3, director2_api=None, job_id=None, log_distributor=_MockLogDistributor(), log_check_timeout=1) # type: ignore diff --git a/services/api-server/tests/unit/test_services_solver_job_models_converters.py b/services/api-server/tests/unit/test_services_solver_job_models_converters.py index 1016096dce5..40d60359477 100644 --- a/services/api-server/tests/unit/test_services_solver_job_models_converters.py +++ b/services/api-server/tests/unit/test_services_solver_job_models_converters.py @@ -10,7 +10,7 @@ from simcore_service_api_server.models.schemas.files import File from simcore_service_api_server.models.schemas.jobs import ArgumentTypes, Job, JobInputs from simcore_service_api_server.models.schemas.solvers import Solver -from simcore_service_api_server.services.solver_job_models_converters import ( +from simcore_service_api_server.services_http.solver_job_models_converters import ( create_job_from_project, create_job_inputs_from_node_inputs, create_jobstatus_from_task, @@ -220,7 +220,7 @@ def fake_url_for(*args, **kwargs): @pytest.mark.skip(reason="TODO: next PR") def test_create_jobstatus_from_task(): from simcore_service_api_server.models.schemas.jobs import JobStatus - from simcore_service_api_server.services.director_v2 import ComputationTaskGet + from simcore_service_api_server.services_http.director_v2 import ComputationTaskGet task = ComputationTaskGet.model_validate({}) # TODO: job_status: JobStatus = create_jobstatus_from_task(task) diff --git a/services/api-server/tests/unit/test_services_solver_job_outputs.py b/services/api-server/tests/unit/test_services_solver_job_outputs.py index b02022e5daa..01612e197e5 100644 --- a/services/api-server/tests/unit/test_services_solver_job_outputs.py +++ b/services/api-server/tests/unit/test_services_solver_job_outputs.py @@ -6,7 +6,7 @@ from typing import Union, get_args, get_origin from simcore_service_api_server.models.schemas.jobs import ArgumentTypes, File -from simcore_service_api_server.services.solver_job_outputs import ( +from simcore_service_api_server.services_http.solver_job_outputs import ( BaseFileLink, ResultsTypes, ) diff --git a/services/static-webserver/client/source/class/osparc/dashboard/DataBrowser.js b/services/static-webserver/client/source/class/osparc/dashboard/DataBrowser.js index 9d6683a832e..f8ffd3d73d0 100644 --- a/services/static-webserver/client/source/class/osparc/dashboard/DataBrowser.js +++ b/services/static-webserver/client/source/class/osparc/dashboard/DataBrowser.js @@ -75,7 +75,7 @@ qx.Class.define("osparc.dashboard.DataBrowser", { const reloadButton = treeFolderView.getChildControl("reload-button"); reloadButton.addListener("execute", () => this.__reloadTree(), this); - const selectedFileLayout = treeFolderView.getChildControl("selected-file-layout"); + const selectedFileLayout = treeFolderView.getChildControl("folder-viewer").getChildControl("selected-file-layout"); selectedFileLayout.addListener("fileDeleted", e => this.__fileDeleted(e.getData()), this); }, diff --git a/services/static-webserver/client/source/class/osparc/data/model/Study.js b/services/static-webserver/client/source/class/osparc/data/model/Study.js index e8929c93adf..af4b639cd44 100644 --- a/services/static-webserver/client/source/class/osparc/data/model/Study.js +++ b/services/static-webserver/client/source/class/osparc/data/model/Study.js @@ -635,6 +635,17 @@ qx.Class.define("osparc.data.model.Study", { return !this.getUi().getSlideshow().isEmpty(); }, + sendMessageToIframe: function(nodeId, msg) { + if (nodeId) { + const node = this.getWorkbench().getNode(nodeId); + if (node && node.getIFrame()) { + node.getIFrame().sendMessageToIframe(msg); + return true; + } + } + return false; + }, + patchStudy: function(studyChanges) { const matches = this.self().OwnPatch.filter(el => Object.keys(studyChanges).indexOf(el) !== -1); if (matches.length) { diff --git a/services/static-webserver/client/source/class/osparc/file/FileLabelWithActions.js b/services/static-webserver/client/source/class/osparc/file/FileLabelWithActions.js index 1343ce73a13..15eec413914 100644 --- a/services/static-webserver/client/source/class/osparc/file/FileLabelWithActions.js +++ b/services/static-webserver/client/source/class/osparc/file/FileLabelWithActions.js @@ -45,18 +45,29 @@ qx.Class.define("osparc.file.FileLabelWithActions", { this.getChildControl("selected-label"); const downloadBtn = this.getChildControl("download-button"); - downloadBtn.addListener("execute", () => this.__retrieveURLAndDownload(), this); + downloadBtn.addListener("execute", () => this.__retrieveURLAndDownloadSelected(), this); const deleteBtn = this.getChildControl("delete-button"); - deleteBtn.addListener("execute", () => this.__deleteFile(), this); + deleteBtn.addListener("execute", () => this.__deleteSelected(), this); }, events: { "fileDeleted": "qx.event.type.Data" }, + properties: { + multiSelect: { + check: "Boolean", + init: false, + nullable: false, + event: "changeMultiSelect", + apply: "__enableMultiSelection", + }, + }, + members: { __selection: null, + __multiSelection: null, _createChildControlImpl: function(id) { let control; @@ -88,26 +99,59 @@ qx.Class.define("osparc.file.FileLabelWithActions", { return control || this.base(arguments, id); }, + __enableMultiSelection: function() { + this.resetItemSelected(); + this.__multiSelection = []; + }, + setItemSelected: function(selectedItem) { - const isFile = osparc.file.FilesTree.isFile(selectedItem); - this.getChildControl("download-button").setEnabled(isFile); - this.getChildControl("delete-button").setEnabled(isFile); - const selectedLabel = this.getChildControl("selected-label"); - if (isFile) { - this.__selection = selectedItem; - selectedLabel.set({ - value: selectedItem.getLabel(), - toolTipText: selectedItem.getFileId() - }); + if (selectedItem) { + const isFile = osparc.file.FilesTree.isFile(selectedItem); + this.getChildControl("download-button").setEnabled(isFile); + this.getChildControl("delete-button").setEnabled(isFile); + const selectedLabel = this.getChildControl("selected-label"); + if (isFile) { + this.__selection = selectedItem; + selectedLabel.set({ + value: selectedItem.getLabel(), + toolTipText: selectedItem.getFileId() + }); + } else { + this.__selection = null; + selectedLabel.set({ + value: "", + toolTipText: "" + }); + } } else { - this.__selection = null; - selectedLabel.set({ - value: "", - toolTipText: "" - }); + this.resetItemSelected(); + } + }, + + setMultiItemSelected: function(multiSelectionData) { + this.__multiSelection = multiSelectionData; + if (multiSelectionData && multiSelectionData.length) { + if (multiSelectionData.length === 1) { + this.setItemSelected(multiSelectionData[0]); + } else { + const selectedLabel = this.getChildControl("selected-label"); + selectedLabel.set({ + value: multiSelectionData.length + " files" + }); + } + } else { + this.resetItemSelected(); } }, + resetItemSelected: function() { + this.__selection = null; + this.__multiSelection = []; + this.getChildControl("download-button").setEnabled(false); + this.getChildControl("delete-button").setEnabled(false); + this.getChildControl("selected-label").resetValue(); + }, + getItemSelected: function() { const selectedItem = this.__selection; if (selectedItem && osparc.file.FilesTree.isFile(selectedItem)) { @@ -116,40 +160,71 @@ qx.Class.define("osparc.file.FileLabelWithActions", { return null; }, - // Request to the server and download - __retrieveURLAndDownload: function() { - let selection = this.getItemSelected(); - if (selection) { - const fileId = selection.getFileId(); - const locationId = selection.getLocation(); - osparc.utils.Utils.retrieveURLAndDownload(locationId, fileId) - .then(data => { - if (data) { - osparc.DownloadLinkTracker.getInstance().downloadLinkUnattended(data.link, data.fileName); + __retrieveURLAndDownloadSelected: function() { + if (this.isMultiSelect()) { + this.__multiSelection.forEach(selection => { + this.__retrieveURLAndDownloadFile(selection); + }); + } else { + const selection = this.getItemSelected(); + if (selection) { + this.__retrieveURLAndDownloadFile(selection); + } + } + }, + + __deleteSelected: function() { + if (this.isMultiSelect()) { + const requests = []; + this.__multiSelection.forEach(selection => { + const request = this.__deleteFile(selection); + if (request) { + requests.push(request); + } + }); + Promise.all(requests) + .then(datas => { + if (datas.length) { + this.fireDataEvent("fileDeleted", datas[0]); + osparc.FlashMessenger.getInstance().logAs(this.tr("Files successfully deleted"), "ERROR"); } }); + requests + } else { + const selection = this.getItemSelected(); + if (selection) { + const request = this.__deleteFile(selection); + if (request) { + request + .then(data => { + this.fireDataEvent("fileDeleted", data); + osparc.FlashMessenger.getInstance().logAs(this.tr("File successfully deleted"), "ERROR"); + }); + } + } } }, - __deleteFile: function() { - let selection = this.getItemSelected(); - if (selection) { - console.log("Delete ", selection); - const fileId = selection.getFileId(); - const locationId = selection.getLocation(); - if (locationId !== 0 && locationId !== "0") { - osparc.FlashMessenger.getInstance().logAs(this.tr("Only files in simcore.s3 can be deleted")); - return false; - } - const dataStore = osparc.store.Data.getInstance(); - dataStore.addListenerOnce("deleteFile", e => { - if (e) { - this.fireDataEvent("fileDeleted", e.getData()); + __retrieveURLAndDownloadFile: function(file) { + const fileId = file.getFileId(); + const locationId = file.getLocation(); + osparc.utils.Utils.retrieveURLAndDownload(locationId, fileId) + .then(data => { + if (data) { + osparc.DownloadLinkTracker.getInstance().downloadLinkUnattended(data.link, data.fileName); } - }, this); - return dataStore.deleteFile(locationId, fileId); + }); + }, + + __deleteFile: function(file) { + const fileId = file.getFileId(); + const locationId = file.getLocation(); + if (locationId !== 0 && locationId !== "0") { + osparc.FlashMessenger.getInstance().logAs(this.tr("Only files in simcore.s3 can be deleted")); + return null; } - return false; - } + const dataStore = osparc.store.Data.getInstance(); + return dataStore.deleteFile(locationId, fileId); + }, } }); diff --git a/services/static-webserver/client/source/class/osparc/file/FilePicker.js b/services/static-webserver/client/source/class/osparc/file/FilePicker.js index 67d67c8455a..fdce6e4aec9 100644 --- a/services/static-webserver/client/source/class/osparc/file/FilePicker.js +++ b/services/static-webserver/client/source/class/osparc/file/FilePicker.js @@ -545,7 +545,8 @@ qx.Class.define("osparc.file.FilePicker", { flex: 1 }); treeFolderLayout.add(treeLayout, 0); - const folderViewer = new osparc.file.FolderViewer(); + const allowMultiselection = false; + const folderViewer = new osparc.file.FolderViewer(allowMultiselection); treeFolderLayout.add(folderViewer, 1); filesTree.addListener("selectionChanged", () => { @@ -562,7 +563,7 @@ qx.Class.define("osparc.file.FilePicker", { const selectionData = e.getData(); this.__selectionChanged(selectionData); }, this); - folderViewer.addListener("itemSelected", e => { + folderViewer.addListener("openItemSelected", e => { const selectionData = e.getData(); this.__selectionChanged(selectionData); if (osparc.file.FilesTree.isFile(selectionData)) { diff --git a/services/static-webserver/client/source/class/osparc/file/FileTreeItem.js b/services/static-webserver/client/source/class/osparc/file/FileTreeItem.js index 45fe07c10ce..5e7a4a02236 100644 --- a/services/static-webserver/client/source/class/osparc/file/FileTreeItem.js +++ b/services/static-webserver/client/source/class/osparc/file/FileTreeItem.js @@ -45,6 +45,11 @@ qx.Class.define("osparc.file.FileTreeItem", { construct: function() { this.base(arguments); + this.set({ + indent: 12, // defaults to 19, + decorator: "rounded", + }); + // create a date format like "Oct. 19, 2018 11:31 AM" this._dateFormat = new qx.util.format.DateFormat( qx.locale.Date.getDateFormat("medium") + " " + diff --git a/services/static-webserver/client/source/class/osparc/file/FolderContent.js b/services/static-webserver/client/source/class/osparc/file/FolderContent.js new file mode 100644 index 00000000000..ced22a52c1a --- /dev/null +++ b/services/static-webserver/client/source/class/osparc/file/FolderContent.js @@ -0,0 +1,299 @@ +/* ************************************************************************ + + osparc - the simcore frontend + + https://osparc.io + + Copyright: + 2024 IT'IS Foundation, https://itis.swiss + + License: + MIT: https://opensource.org/licenses/MIT + + Authors: + * Odei Maiz (odeimaiz) + +************************************************************************ */ + +qx.Class.define("osparc.file.FolderContent", { + extend: qx.ui.container.Stack, + + construct: function() { + this.base(arguments); + + this.getChildControl("icons-layout"); + this.getChildControl("table"); + }, + + properties: { + folder: { + check: "qx.core.Object", + init: null, + nullable: true, + event: "changeFolder", + apply: "__applyFolder" + }, + + mode: { + check: ["list", "icons"], + init: "icons", + nullable: false, + event: "changeMode", + apply: "__reloadFolderContent" + }, + + multiSelect: { + check: "Boolean", + init: false, + nullable: false, + event: "changeMultiSelect", + apply: "__reloadFolderContent" + }, + }, + + events: { + "selectionChanged": "qx.event.type.Data", // tap + "multiSelectionChanged": "qx.event.type.Data", // tap + "openItemSelected": "qx.event.type.Data", // dbltap + "requestDatasetFiles": "qx.event.type.Data", + }, + + statics: { + getItemButton: function() { + const item = new qx.ui.form.ToggleButton().set({ + iconPosition: "top", + width: 100, + height: 80, + padding: 2 + }); + item.getChildControl("label").set({ + font: "text-12", + rich: true, + textAlign: "center", + maxWidth: 100, + maxHeight: 33 // two lines + }); + osparc.utils.Utils.setIdToWidget(item, "FolderViewerItem"); + return item; + }, + + T_POS: { + TYPE: 0, + NAME: 1, + DATE: 2, + SIZE: 3, + ID: 4 + } + }, + + members: { + _createChildControlImpl: function(id) { + let control; + switch (id) { + case "table": { + const tableModel = new qx.ui.table.model.Simple(); + tableModel.setColumns([ + "", + this.tr("Name"), + this.tr("Date Modified"), + this.tr("Size"), + this.tr("Id") + ]); + control = new osparc.ui.table.Table(tableModel, { + // tableColumnModel: obj => new qx.ui.table.columnmodel.Resize(obj), + initiallyHiddenColumns: [this.self().T_POS.ID] + }); + control.getTableColumnModel().setDataCellRenderer(this.self().T_POS.TYPE, new qx.ui.table.cellrenderer.Image()); + control.setColumnWidth(this.self().T_POS.TYPE, 30); + control.setColumnWidth(this.self().T_POS.NAME, 360); + control.setColumnWidth(this.self().T_POS.DATE, 170); + control.setColumnWidth(this.self().T_POS.SIZE, 70); + this.bind("mode", control, "visibility", { + converter: mode => mode === "list" ? "visible" : "excluded" + }); + const scroll = new qx.ui.container.Scroll(); + scroll.add(control); + this.add(scroll); + break; + } + case "icons-layout": { + control = new qx.ui.container.Composite(new qx.ui.layout.Flow(5, 5)); + osparc.utils.Utils.setIdToWidget(control, "FolderViewerIconsContent"); + this.bind("mode", control, "visibility", { + converter: mode => mode === "icons" ? "visible" : "excluded" + }); + const scroll = new qx.ui.container.Scroll(); + scroll.add(control); + this.add(scroll); + break; + } + } + return control || this.base(arguments, id); + }, + + __convertEntries: function(content) { + const datas = []; + content.forEach(entry => { + const data = { + icon: entry.getIcon ? entry.getIcon() : this.__getIcon(entry), + label: entry.getLabel(), + lastModified: entry.getLastModified ? osparc.utils.Utils.formatDateAndTime(new Date(entry.getLastModified())) : "", + size: entry.getSize ? osparc.utils.Utils.bytesToSize(entry.getSize()) : "", + itemId: entry.getItemId ? entry.getItemId() : null, + entry: entry, + }; + datas.push(data); + }); + const items = []; + if (this.getMode() === "list") { + datas.forEach(data => { + const row = []; + row.push(data["icon"]); + row.push(data["label"]); + row.push(data["lastModified"]); + row.push(data["size"]); + if (data["itemId"]) { + row.push(data["itemId"]); + } + row.entry = data["entry"]; + items.push(row); + }); + } else if (this.getMode() === "icons") { + datas.forEach(data => { + let toolTip = data["label"]; + if (data["size"]) { + toolTip += "
" + data["size"]; + } + if (data["lastModified"]) { + toolTip += "
" + data["lastModified"]; + } + const gridItem = this.self().getItemButton().set({ + label: data["label"], + icon: data["icon"], + toolTipText: toolTip + }); + const icon = gridItem.getChildControl("icon", true); + if (icon.getSource() === "@FontAwesome5Solid/circle-notch/12") { + icon.setPadding(0); + icon.setMarginRight(4); + icon.getContentElement().addClass("rotate"); + } + if (data["itemId"]) { + gridItem.itemId = data["itemId"]; + } + gridItem.entry = data["entry"]; + this.__attachListenersToGridItem(gridItem); + items.push(gridItem); + }); + } + return items; + }, + + __getIcon: function(entry) { + return osparc.file.FilesTree.isDir(entry) ? "@MaterialIcons/folder" : "@MaterialIcons/insert_drive_file"; + }, + + __getEntries: function() { + if (this.getFolder()) { + const children = this.getFolder().getChildren().toArray(); + return this.__convertEntries(children); + } + return []; + }, + + __applyFolder: function(folder) { + if (folder) { + if (folder.getLoaded && !folder.getLoaded()) { + this.fireDataEvent("requestDatasetFiles", { + locationId: folder.getLocation(), + datasetId: folder.getPath() + }); + } + + folder.getChildren().addListener("change", () => { + this.__reloadFolderContent(); + }, this); + } + + this.__reloadFolderContent(); + }, + + __reloadFolderContent: function() { + const entries = this.__getEntries(); + if (this.getMode() === "list") { + const table = this.getChildControl("table"); + table.setData(entries); + this.__attachListenersToTableItem(table); + } else if (this.getMode() === "icons") { + const iconsLayout = this.getChildControl("icons-layout"); + iconsLayout.removeAll(); + const iconsGroup = new qx.ui.form.RadioGroup().set({ + allowEmptySelection: true + }); + entries.forEach(entry => { + if (!this.isMultiSelect()) { + iconsGroup.add(entry); + } + iconsLayout.add(entry); + }); + } + this.setSelection([this.getSelectables()[this.getMode() === "icons" ? 0 : 1]]); + }, + + __itemTapped: function(entry, buttonSelected) { + if (this.isMultiSelect()) { + this.fireDataEvent("multiSelectionChanged", entry); + } else if (buttonSelected === false) { + this.fireDataEvent("selectionChanged", null); + } else { + this.fireDataEvent("selectionChanged", entry); + } + }, + + __itemDblTapped: function(entry) { + this.fireDataEvent("openItemSelected", entry); + }, + + __attachListenersToGridItem: function(gridItem) { + gridItem.addListener("tap", () => { + if (this.isMultiSelect()) { + // pass all buttons that are selected + const selectedFiles = []; + const iconsLayout = this.getChildControl("icons-layout"); + iconsLayout.getChildren().forEach(btn => { + if (osparc.file.FilesTree.isFile(btn.entry) && btn.getValue()) { + selectedFiles.push(btn.entry); + } + }); + this.__itemTapped(selectedFiles, gridItem.getValue()); + } else { + this.__itemTapped(gridItem.entry, gridItem.getValue()); + } + // folders can't be selected + if (osparc.file.FilesTree.isDir(gridItem.entry)) { + gridItem.setValue(false); + } + }, this); + gridItem.addListener("dbltap", () => { + this.__itemDblTapped(gridItem.entry); + }, this); + }, + + __attachListenersToTableItem: function(table) { + table.addListener("cellTap", e => { + const selectedRow = e.getRow(); + const rowData = table.getTableModel().getRowData(selectedRow); + if ("entry" in rowData) { + this.__itemTapped(rowData.entry); + } + }, this); + table.addListener("cellDbltap", e => { + const selectedRow = e.getRow(); + const rowData = table.getTableModel().getRowData(selectedRow); + if ("entry" in rowData) { + this.__itemDblTapped(rowData.entry); + } + }, this); + } + } +}); diff --git a/services/static-webserver/client/source/class/osparc/file/FolderViewer.js b/services/static-webserver/client/source/class/osparc/file/FolderViewer.js index 26fb4433bf3..c44f48cbe27 100644 --- a/services/static-webserver/client/source/class/osparc/file/FolderViewer.js +++ b/services/static-webserver/client/source/class/osparc/file/FolderViewer.js @@ -22,7 +22,7 @@ qx.Class.define("osparc.file.FolderViewer", { extend: qx.ui.core.Widget, - construct: function() { + construct: function(allowMultiselection = true) { this.base(arguments); this._setLayout(new qx.ui.layout.VBox(10)); @@ -32,10 +32,14 @@ qx.Class.define("osparc.file.FolderViewer", { const folderUpBtn = this.getChildControl("folder-up"); folderUpBtn.addListener("execute", () => this.fireDataEvent("folderUp", this.getFolder()), this); this.getChildControl("folder-path"); - this.getChildControl("view-options-icons"); - this.getChildControl("view-options-list"); - this.getChildControl("icons-layout"); - this.getChildControl("table"); + let multiSelectButton = null; + if (allowMultiselection) { + multiSelectButton = this.getChildControl("multi-select-button"); + } + const gridViewButton = this.getChildControl("view-options-icons"); + const listViewButton = this.getChildControl("view-options-list"); + const folderContent = this.getChildControl("folder-content"); + const selectedFileLayout = this.getChildControl("selected-file-layout"); this.bind("folder", this.getChildControl("folder-up"), "enabled", { converter: folder => Boolean(folder && folder.getPathLabel && folder.getPathLabel().length > 1) @@ -44,6 +48,40 @@ qx.Class.define("osparc.file.FolderViewer", { this.bind("folder", this.getChildControl("folder-path"), "value", { converter: folder => folder ? folder.getPathLabel().join(" / ") : this.tr("Select folder") }); + + this.bind("folder", folderContent, "folder"); + + if (allowMultiselection) { + multiSelectButton.addListener("changeValue", e => { + folderContent.setMultiSelect(e.getData()); + selectedFileLayout.setMultiSelect(e.getData()); + }); + } + gridViewButton.addListener("execute", () => { + folderContent.setMode("icons"); + selectedFileLayout.resetItemSelected(); + }); + listViewButton.addListener("execute", () => { + folderContent.setMode("list"); + selectedFileLayout.resetItemSelected(); + }); + + folderContent.addListener("requestDatasetFiles", e => this.fireDataEvent("requestDatasetFiles", e.getData())); + folderContent.addListener("selectionChanged", e => { + const selectionData = e.getData(); + selectedFileLayout.setItemSelected(selectionData); + }, this); + folderContent.addListener("multiSelectionChanged", e => { + const multiSelectionData = e.getData(); + selectedFileLayout.setMultiItemSelected(multiSelectionData); + }, this); + folderContent.addListener("openItemSelected", e => { + const entry = e.getData(); + this.fireDataEvent("openItemSelected", entry); + if (osparc.file.FilesTree.isDir(entry)) { + this.setFolder(entry); + } + }); }, properties: { @@ -52,52 +90,16 @@ qx.Class.define("osparc.file.FolderViewer", { init: null, nullable: true, event: "changeFolder", - apply: "__applyFolder" + apply: "__applyFolder", }, - - mode: { - check: ["list", "icons"], - init: "icons", - nullable: false, - event: "changeMode", - apply: "__reloadFolderContent" - } }, events: { - "selectionChanged": "qx.event.type.Data", // tap - "itemSelected": "qx.event.type.Data", // dbltap + "openItemSelected": "qx.event.type.Data", // dbltap "folderUp": "qx.event.type.Data", "requestDatasetFiles": "qx.event.type.Data" }, - statics: { - getItemButton: function() { - const item = new qx.ui.form.ToggleButton().set({ - iconPosition: "top", - width: 100, - height: 80, - padding: 3 - }); - item.getChildControl("label").set({ - rich: true, - textAlign: "center", - maxWidth: 100, - maxHeight: 31 - }); - osparc.utils.Utils.setIdToWidget(item, "FolderViewerItem"); - return item; - }, - - T_POS: { - TYPE: 0, - NAME: 1, - DATE: 2, - SIZE: 3, - ID: 4 - } - }, - members: { _createChildControlImpl: function(id) { let control; @@ -128,6 +130,15 @@ qx.Class.define("osparc.file.FolderViewer", { }); break; } + case "multi-select-button": { + control = new qx.ui.form.ToggleButton(this.tr("Multiselect")).set({ + value: false, + marginRight: 10, + }); + const header = this.getChildControl("header"); + header.addAt(control, 2); + break; + } case "view-options-rgroup": control = new qx.ui.form.RadioGroup(); break; @@ -135,203 +146,37 @@ qx.Class.define("osparc.file.FolderViewer", { control = new qx.ui.form.ToggleButton(null, "@MaterialIcons/apps/18"); const group = this.getChildControl("view-options-rgroup"); group.add(control); - control.addListener("execute", () => { - this.setMode("icons"); - }); const header = this.getChildControl("header"); - header.addAt(control, 2); + header.addAt(control, 3); break; } case "view-options-list": { control = new qx.ui.form.ToggleButton(null, "@MaterialIcons/reorder/18"); const group = this.getChildControl("view-options-rgroup"); group.add(control); - control.addListener("execute", () => { - this.setMode("list"); - }); const header = this.getChildControl("header"); - header.addAt(control, 3); + header.addAt(control, 4); break; } - case "content-stack": { - control = new qx.ui.container.Stack(); + case "folder-content": { + control = new osparc.file.FolderContent(); this._add(control, { flex: 1 }); break; } - case "table": { - const tableModel = new qx.ui.table.model.Simple(); - tableModel.setColumns([ - "", - this.tr("Name"), - this.tr("Date Modified"), - this.tr("Size"), - this.tr("Id") - ]); - control = new osparc.ui.table.Table(tableModel, { - // tableColumnModel: obj => new qx.ui.table.columnmodel.Resize(obj), - initiallyHiddenColumns: [this.self().T_POS.ID] - }); - control.getTableColumnModel().setDataCellRenderer(this.self().T_POS.TYPE, new qx.ui.table.cellrenderer.Image()); - control.setColumnWidth(this.self().T_POS.TYPE, 30); - control.setColumnWidth(this.self().T_POS.NAME, 360); - control.setColumnWidth(this.self().T_POS.DATE, 170); - control.setColumnWidth(this.self().T_POS.SIZE, 70); - this.bind("mode", control, "visibility", { - converter: mode => mode === "list" ? "visible" : "excluded" - }); - const scroll = new qx.ui.container.Scroll(); - scroll.add(control); - this.getChildControl("content-stack").add(scroll); - break; - } - case "icons-layout": { - control = new qx.ui.container.Composite(new qx.ui.layout.Flow(5, 5)); - osparc.utils.Utils.setIdToWidget(control, "FolderViewerIconsContent"); - this.bind("mode", control, "visibility", { - converter: mode => mode === "icons" ? "visible" : "excluded" + case "selected-file-layout": + control = new osparc.file.FileLabelWithActions().set({ + alignY: "middle" }); - const scroll = new qx.ui.container.Scroll(); - scroll.add(control); - this.getChildControl("content-stack").add(scroll); + this._add(control); break; - } } return control || this.base(arguments, id); }, - __convertEntries: function(content) { - const items = []; - if (this.getMode() === "list") { - content.forEach(entry => { - const row = []; - row.push(entry.getIcon ? entry.getIcon() : this.__getIcon(entry)); - row.push(entry.getLabel()); - row.push(entry.getLastModified ? osparc.utils.Utils.formatDateAndTime(new Date(entry.getLastModified())) : ""); - row.push(entry.getSize ? osparc.utils.Utils.bytesToSize(entry.getSize()) : ""); - if (entry.getItemId) { - row.push(entry.getItemId()); - } - row.entry = entry; - items.push(row); - }); - } else if (this.getMode() === "icons") { - content.forEach(entry => { - let tt = entry.getLabel(); - if (entry.getSize) { - tt += "
" + osparc.utils.Utils.bytesToSize(entry.getSize()); - } - if (entry.getLastModified) { - tt += "
" + osparc.utils.Utils.formatDateAndTime(new Date(entry.getLastModified())); - } - const item = this.self().getItemButton().set({ - label: entry.getLabel(), - icon: entry.getIcon ? entry.getIcon() : this.__getIcon(entry), - toolTipText: tt - }); - const icon = item.getChildControl("icon", true); - if (icon.getSource() === "@FontAwesome5Solid/circle-notch/12") { - icon.setPadding(0); - icon.setMarginRight(4); - icon.getContentElement().addClass("rotate"); - } - - if (entry.getItemId) { - item.itemId = entry.getItemId(); - this.__attachListenersToItems(item, entry); - } - items.push(item); - }); - } - return items; - }, - - __getIcon: function(entry) { - return osparc.file.FilesTree.isDir(entry) ? "@MaterialIcons/folder" : "@MaterialIcons/insert_drive_file"; - }, - - __getEntries: function() { - if (this.getFolder()) { - const children = this.getFolder().getChildren().toArray(); - return this.__convertEntries(children); - } - return []; - }, - - __applyFolder: function(folder) { - if (folder) { - if (folder.getLoaded && !folder.getLoaded()) { - this.fireDataEvent("requestDatasetFiles", { - locationId: folder.getLocation(), - datasetId: folder.getPath() - }); - } - - folder.getChildren().addListener("change", () => { - this.__reloadFolderContent(); - }, this); - } - - this.__reloadFolderContent(); - }, - - __reloadFolderContent: function() { - const entries = this.__getEntries(); - if (this.getMode() === "list") { - const table = this.getChildControl("table"); - table.setData(entries); - this.__attachListenersTotable(table); - } else if (this.getMode() === "icons") { - const iconsLayout = this.getChildControl("icons-layout"); - iconsLayout.removeAll(); - const iconsGroup = new qx.ui.form.RadioGroup().set({ - allowEmptySelection: true - }); - entries.forEach(entry => { - iconsGroup.add(entry); - iconsLayout.add(entry); - }); - } - const stack = this.getChildControl("content-stack"); - stack.setSelection([stack.getSelectables()[this.getMode() === "icons" ? 0 : 1]]); - }, - - __itemTapped: function(item) { - this.fireDataEvent("selectionChanged", item); - }, - - __itemDblTapped: function(item) { - this.fireDataEvent("itemSelected", item); - if (osparc.file.FilesTree.isDir(item)) { - this.setFolder(item); - } - }, - - __attachListenersToItems: function(btn, entry) { - btn.addListener("tap", () => { - this.__itemTapped(entry); - }, this); - btn.addListener("dbltap", () => { - this.__itemDblTapped(entry); - }, this); - }, - - __attachListenersTotable: function(table) { - table.addListener("cellTap", e => { - const selectedRow = e.getRow(); - const rowData = table.getTableModel().getRowData(selectedRow); - if ("entry" in rowData) { - this.__itemTapped(rowData.entry); - } - }, this); - table.addListener("cellDbltap", e => { - const selectedRow = e.getRow(); - const rowData = table.getTableModel().getRowData(selectedRow); - if ("entry" in rowData) { - this.__itemDblTapped(rowData.entry); - } - }, this); + __applyFolder: function() { + this.getChildControl("selected-file-layout").resetItemSelected(); } } }); diff --git a/services/static-webserver/client/source/class/osparc/file/TreeFolderView.js b/services/static-webserver/client/source/class/osparc/file/TreeFolderView.js index 9e88b2d688e..d9349713218 100644 --- a/services/static-webserver/client/source/class/osparc/file/TreeFolderView.js +++ b/services/static-webserver/client/source/class/osparc/file/TreeFolderView.js @@ -75,12 +75,6 @@ qx.Class.define("osparc.file.TreeFolderView", { treeFolderLayout.add(control, 1); break; } - case "selected-file-layout": - control = new osparc.file.FileLabelWithActions().set({ - alignY: "middle" - }); - this._add(control); - break; } return control || this.base(arguments, id); }, @@ -89,27 +83,16 @@ qx.Class.define("osparc.file.TreeFolderView", { this.getChildControl("reload-button"); const folderTree = this.getChildControl("folder-tree"); const folderViewer = this.getChildControl("folder-viewer"); - const selectedFileLayout = this.getChildControl("selected-file-layout"); // Connect elements folderTree.addListener("selectionChanged", () => { - const selectionData = folderTree.getSelectedItem(); - if (selectionData) { - selectedFileLayout.setItemSelected(selectionData); - if (osparc.file.FilesTree.isDir(selectionData) || (selectionData.getChildren && selectionData.getChildren().length)) { - folderViewer.setFolder(selectionData); - } - } - }, this); - - folderViewer.addListener("selectionChanged", e => { - const selectionData = e.getData(); - if (selectionData) { - selectedFileLayout.setItemSelected(selectionData); + const selectedFolder = folderTree.getSelectedItem(); + if (selectedFolder && (osparc.file.FilesTree.isDir(selectedFolder) || (selectedFolder.getChildren && selectedFolder.getChildren().length))) { + folderViewer.setFolder(selectedFolder); } }, this); - folderViewer.addListener("itemSelected", e => { + folderViewer.addListener("openItemSelected", e => { const data = e.getData(); folderTree.openNodeAndParents(data); folderTree.setSelection(new qx.data.Array([data])); diff --git a/services/static-webserver/client/source/class/osparc/store/Data.js b/services/static-webserver/client/source/class/osparc/store/Data.js index b514b7b3144..322dc1313bd 100644 --- a/services/static-webserver/client/source/class/osparc/store/Data.js +++ b/services/static-webserver/client/source/class/osparc/store/Data.js @@ -33,7 +33,6 @@ qx.Class.define("osparc.store.Data", { events: { "fileCopied": "qx.event.type.Data", - "deleteFile": "qx.event.type.Data" }, members: { @@ -286,7 +285,7 @@ qx.Class.define("osparc.store.Data", { deleteFile: function(locationId, fileUuid) { if (!osparc.data.Permissions.getInstance().canDo("study.node.data.delete", true)) { - return false; + return null; } // Deletes File @@ -296,22 +295,19 @@ qx.Class.define("osparc.store.Data", { fileUuid: encodeURIComponent(fileUuid) } }; - osparc.data.Resources.fetch("storageFiles", "delete", params) + return osparc.data.Resources.fetch("storageFiles", "delete", params) .then(files => { const data = { data: files, locationId: locationId, fileUuid: fileUuid }; - this.fireDataEvent("deleteFile", data); + return data; }) .catch(err => { console.error(err); osparc.FlashMessenger.getInstance().logAs(this.tr("Failed deleting file"), "ERROR"); - this.fireDataEvent("deleteFile", null); }); - - return true; } } }); diff --git a/services/static-webserver/client/source/class/osparc/vipMarket/Market.js b/services/static-webserver/client/source/class/osparc/vipMarket/Market.js index 8bd65242eb2..dbffeefed8e 100644 --- a/services/static-webserver/client/source/class/osparc/vipMarket/Market.js +++ b/services/static-webserver/client/source/class/osparc/vipMarket/Market.js @@ -18,7 +18,7 @@ qx.Class.define("osparc.vipMarket.Market", { extend: osparc.ui.window.TabbedView, - construct: function() { + construct: function(category) { this.base(arguments); const miniWallet = osparc.desktop.credits.BillingCenter.createMiniWalletView().set({ @@ -51,9 +51,17 @@ qx.Class.define("osparc.vipMarket.Market", { }].forEach(marketInfo => { this.__buildViPMarketPage(marketInfo); }); + + if (category) { + this.openCategory(category); + } }); }, + events: { + "importMessageSent": "qx.event.type.Data", + }, + properties: { openBy: { check: "String", @@ -70,6 +78,7 @@ qx.Class.define("osparc.vipMarket.Market", { metadataUrl: marketInfo["url"], }); this.bind("openBy", vipMarketView, "openBy"); + vipMarketView.addListener("importMessageSent", () => this.fireEvent("importMessageSent")); const page = this.addTab(marketInfo["label"], marketInfo["icon"], vipMarketView); page.category = marketInfo["category"]; return page; @@ -83,5 +92,17 @@ qx.Class.define("osparc.vipMarket.Market", { } return false; }, + + sendCloseMessage: function() { + const store = osparc.store.Store.getInstance(); + const currentStudy = store.getCurrentStudy(); + const nodeId = this.getOpenBy(); + if (currentStudy && nodeId) { + const msg = { + "type": "closeMarket", + }; + currentStudy.sendMessageToIframe(nodeId, msg); + } + }, } }); diff --git a/services/static-webserver/client/source/class/osparc/vipMarket/MarketWindow.js b/services/static-webserver/client/source/class/osparc/vipMarket/MarketWindow.js index c610abf36b3..c238f5618b8 100644 --- a/services/static-webserver/client/source/class/osparc/vipMarket/MarketWindow.js +++ b/services/static-webserver/client/source/class/osparc/vipMarket/MarketWindow.js @@ -30,20 +30,22 @@ qx.Class.define("osparc.vipMarket.MarketWindow", { height }); - const vipMarket = this.__vipMarket = new osparc.vipMarket.Market().set({ + const vipMarket = this.__vipMarket = new osparc.vipMarket.Market(category).set({ openBy: nodeId ? nodeId : null, }); this._setTabbedView(vipMarket); - - if (category) { - vipMarket.openCategory(category); - } }, statics: { openWindow: function(nodeId, category) { if (osparc.product.Utils.showS4LStore()) { const storeWindow = new osparc.vipMarket.MarketWindow(nodeId, category); + storeWindow.getVipMarket().addListener("importMessageSent", () => storeWindow.close()); + storeWindow.addListenerOnce("close", () => { + if (storeWindow.getVipMarket()) { + storeWindow.getVipMarket().sendCloseMessage(); + } + }); storeWindow.center(); storeWindow.open(); return storeWindow; @@ -51,4 +53,12 @@ qx.Class.define("osparc.vipMarket.MarketWindow", { return null; } }, + + members: { + __vipMarket: null, + + getVipMarket: function() { + return this.__vipMarket; + }, + }, }); diff --git a/services/static-webserver/client/source/class/osparc/vipMarket/VipMarket.js b/services/static-webserver/client/source/class/osparc/vipMarket/VipMarket.js index 1385264933e..f7a0125f3c0 100644 --- a/services/static-webserver/client/source/class/osparc/vipMarket/VipMarket.js +++ b/services/static-webserver/client/source/class/osparc/vipMarket/VipMarket.js @@ -26,6 +26,10 @@ qx.Class.define("osparc.vipMarket.VipMarket", { this.__buildLayout(); }, + events: { + "importMessageSent": "qx.event.type.Data" + }, + properties: { openBy: { check: "String", @@ -71,7 +75,6 @@ qx.Class.define("osparc.vipMarket.VipMarket", { members: { __anatomicalModels: null, - __purchasesItems: null, __anatomicalModelsModel: null, _createChildControlImpl: function(id) { @@ -214,7 +217,6 @@ qx.Class.define("osparc.vipMarket.VipMarket", { .then(values => { const licensedItems = values[0]; const purchasesItems = values[1]; - this.__purchasesItems = purchasesItems; this.__anatomicalModels = []; allAnatomicalModels.forEach(model => { @@ -359,22 +361,18 @@ qx.Class.define("osparc.vipMarket.VipMarket", { }, __sendImportModelMessage: function(modelId) { + const store = osparc.store.Store.getInstance(); + const currentStudy = store.getCurrentStudy(); const nodeId = this.getOpenBy(); - if (nodeId) { - const store = osparc.store.Store.getInstance(); - const currentStudy = store.getCurrentStudy(); - if (!currentStudy) { - return; - } - const node = currentStudy.getWorkbench().getNode(nodeId); - if (node && node.getIFrame()) { - const msg = { - "type": "importModel", - "message": { - "modelId": modelId, - }, - }; - node.getIFrame().sendMessageToIframe(msg); + if (currentStudy && nodeId) { + const msg = { + "type": "importModel", + "message": { + "modelId": modelId, + }, + }; + if (currentStudy.sendMessageToIframe(nodeId, msg)) { + this.fireEvent("importMessageSent"); } } }, diff --git a/services/static-webserver/client/source/class/osparc/widget/NodeDataManager.js b/services/static-webserver/client/source/class/osparc/widget/NodeDataManager.js index 641e98c58a0..2785f658c6c 100644 --- a/services/static-webserver/client/source/class/osparc/widget/NodeDataManager.js +++ b/services/static-webserver/client/source/class/osparc/widget/NodeDataManager.js @@ -94,7 +94,7 @@ qx.Class.define("osparc.widget.NodeDataManager", { const reloadButton = treeFolderView.getChildControl("reload-button"); reloadButton.addListener("execute", () => this.__reloadTree(), this); - const selectedFileLayout = treeFolderView.getChildControl("selected-file-layout"); + const selectedFileLayout = treeFolderView.getChildControl("folder-viewer").getChildControl("selected-file-layout"); selectedFileLayout.addListener("fileDeleted", e => this.__fileDeleted(e.getData()), this); }, diff --git a/services/web/server/src/simcore_service_webserver/folders/_folders_db.py b/services/web/server/src/simcore_service_webserver/folders/_folders_db.py index 6c78855995e..38ad97f44ed 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_folders_db.py +++ b/services/web/server/src/simcore_service_webserver/folders/_folders_db.py @@ -323,7 +323,7 @@ async def update( """ Batch/single patch of folder/s """ - # NOTE: exclude unset can also be done using a pydantic model and dict(exclude_unset=True) + # NOTE: exclude unset can also be done using a pydantic model and model_dump(exclude_unset=True) updated = as_dict_exclude_unset( name=name, parent_folder_id=parent_folder_id, diff --git a/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_db.py b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_db.py index e468c10f55d..415dec7149d 100644 --- a/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_db.py +++ b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_db.py @@ -145,7 +145,7 @@ async def update( ) -> LicensedItemDB: # NOTE: at least 'touch' if updated_values is empty _updates = { - **updates.dict(exclude_unset=True), + **updates.model_dump(exclude_unset=True), "modified": func.now(), } diff --git a/services/web/server/src/simcore_service_webserver/projects/_folders_db.py b/services/web/server/src/simcore_service_webserver/projects/_folders_db.py index 2e5001343d4..d8c965be26b 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_folders_db.py +++ b/services/web/server/src/simcore_service_webserver/projects/_folders_db.py @@ -129,7 +129,7 @@ async def update_project_to_folder( """ Batch/single patch of project to folders """ - # NOTE: exclude unset can also be done using a pydantic model and dict(exclude_unset=True) + # NOTE: exclude unset can also be done using a pydantic model and model_dump(exclude_unset=True) updated = as_dict_exclude_unset( user_id=user_id, ) diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_db.py b/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_db.py index 5264a112419..b88bdd918ae 100644 --- a/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_db.py +++ b/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_db.py @@ -217,7 +217,7 @@ async def update_workspace( ) -> WorkspaceDB: # NOTE: at least 'touch' if updated_values is empty _updates = { - **updates.dict(exclude_unset=True), + **updates.model_dump(exclude_unset=True), "modified": func.now(), } diff --git a/services/web/server/tests/unit/with_dbs/03/test_trash.py b/services/web/server/tests/unit/with_dbs/03/test_trash.py index 6ab597e3972..f582747ef95 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_trash.py +++ b/services/web/server/tests/unit/with_dbs/03/test_trash.py @@ -409,7 +409,7 @@ async def workspace( # CREATE a workspace resp = await client.post("/v0/workspaces", json={"name": "My first workspace"}) data, _ = await assert_status(resp, status.HTTP_201_CREATED) - workspace = WorkspaceGet.parse_obj(data) + workspace = WorkspaceGet.model_validate(data) yield workspace @@ -433,7 +433,7 @@ async def test_trash_empty_workspace( resp = await client.get("/v0/workspaces") await assert_status(resp, status.HTTP_200_OK) - page = Page[WorkspaceGet].parse_obj(await resp.json()) + page = Page[WorkspaceGet].model_validate(await resp.json()) assert page.meta.total == 1 assert page.data[0] == workspace @@ -441,7 +441,7 @@ async def test_trash_empty_workspace( resp = await client.get("/v0/workspaces", params={"filters": '{"trashed": true}'}) await assert_status(resp, status.HTTP_200_OK) - page = Page[WorkspaceGet].parse_obj(await resp.json()) + page = Page[WorkspaceGet].model_validate(await resp.json()) assert page.meta.total == 0 # ------------- @@ -457,16 +457,16 @@ async def test_trash_empty_workspace( resp = await client.get("/v0/workspaces") await assert_status(resp, status.HTTP_200_OK) - page = Page[WorkspaceGet].parse_obj(await resp.json()) + page = Page[WorkspaceGet].model_validate(await resp.json()) assert page.meta.total == 0 # LIST trashed resp = await client.get("/v0/workspaces", params={"filters": '{"trashed": true}'}) await assert_status(resp, status.HTTP_200_OK) - page = Page[WorkspaceGet].parse_obj(await resp.json()) + page = Page[WorkspaceGet].model_validate(await resp.json()) assert page.meta.total == 1 - assert page.data[0].dict(exclude=_exclude_attrs) == workspace.dict( + assert page.data[0].model_dump(exclude=_exclude_attrs) == workspace.model_dump( exclude=_exclude_attrs ) assert page.data[0].trashed_at is not None @@ -483,9 +483,9 @@ async def test_trash_empty_workspace( resp = await client.get("/v0/workspaces") await assert_status(resp, status.HTTP_200_OK) - page = Page[WorkspaceGet].parse_obj(await resp.json()) + page = Page[WorkspaceGet].model_validate(await resp.json()) assert page.meta.total == 1 - assert page.data[0].dict(exclude=_exclude_attrs) == workspace.dict( + assert page.data[0].model_dump(exclude=_exclude_attrs) == workspace.model_dump( exclude=_exclude_attrs ) @@ -496,5 +496,5 @@ async def test_trash_empty_workspace( resp = await client.get("/v0/workspaces", params={"filters": '{"trashed": true}'}) await assert_status(resp, status.HTTP_200_OK) - page = Page[WorkspaceGet].parse_obj(await resp.json()) + page = Page[WorkspaceGet].model_validate(await resp.json()) assert page.meta.total == 0 diff --git a/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces.py b/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces.py index 362eca1d82b..7e45b93400a 100644 --- a/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces.py +++ b/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces.py @@ -93,7 +93,7 @@ async def test_workspaces_workflow( resp, status.HTTP_200_OK, include_meta=True, include_links=True ) assert len(data) == 1 - assert WorkspaceGet.parse_obj(data[0]) == added_workspace + assert WorkspaceGet.model_validate(data[0]) == added_workspace assert meta["count"] == 1 assert links @@ -126,7 +126,7 @@ async def test_workspaces_workflow( resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) assert len(data) == 1 - assert WorkspaceGet.parse_obj(data[0]) == replaced_workspace + assert WorkspaceGet.model_validate(data[0]) == replaced_workspace # DELETE a workspace url = client.app.router["delete_workspace"].url_for( diff --git a/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__folders_and_projects_crud.py b/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__folders_and_projects_crud.py index 477413d274d..3d5c2d7991a 100644 --- a/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__folders_and_projects_crud.py +++ b/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces__folders_and_projects_crud.py @@ -296,7 +296,7 @@ async def test_workspaces_delete_folders( }, ) data, _ = await assert_status(resp, status.HTTP_201_CREATED) - added_workspace = WorkspaceGet.parse_obj(data) + added_workspace = WorkspaceGet.model_validate(data) # Create project in workspace project_data = deepcopy(fake_project) diff --git a/tests/e2e-playwright/tests/conftest.py b/tests/e2e-playwright/tests/conftest.py index 20c40c3a291..e815ff6c522 100644 --- a/tests/e2e-playwright/tests/conftest.py +++ b/tests/e2e-playwright/tests/conftest.py @@ -417,7 +417,7 @@ def log_in_and_out( def _open_with_resources(page: Page, *, click_it: bool): study_title_field = page.get_by_test_id("studyTitleField") # wait until the title is automatically filled up - expect(study_title_field).not_to_have_value("", timeout=5000) + expect(study_title_field).not_to_have_value("") open_with_resources_button = page.get_by_test_id("openWithResources") if click_it: