diff --git a/packages/models-library/src/models_library/wallets.py b/packages/models-library/src/models_library/wallets.py index f0663842664..c0e5314423c 100644 --- a/packages/models-library/src/models_library/wallets.py +++ b/packages/models-library/src/models_library/wallets.py @@ -1,4 +1,5 @@ from datetime import datetime +from decimal import Decimal from enum import auto from typing import Any, ClassVar, TypeAlias @@ -24,6 +25,9 @@ class Config: } +ZERO_CREDITS = Decimal(0) + + ### DB diff --git a/services/static-webserver/client/source/class/osparc/data/model/Node.js b/services/static-webserver/client/source/class/osparc/data/model/Node.js index 3c4c2e3e5d5..da59fd9d5fa 100644 --- a/services/static-webserver/client/source/class/osparc/data/model/Node.js +++ b/services/static-webserver/client/source/class/osparc/data/model/Node.js @@ -967,7 +967,7 @@ qx.Class.define("osparc.data.model.Node", { osparc.data.Resources.fetch("studies", "startNode", params) .then(() => this.startDynamicService()) .catch(err => { - if ("status" in err && err.status === 409) { + if ("status" in err && (err.status === 409 || err.status === 402)) { osparc.FlashMessenger.getInstance().logAs(err.message, "WARNING"); } else { console.error(err); diff --git a/services/static-webserver/client/source/class/osparc/desktop/StudyEditor.js b/services/static-webserver/client/source/class/osparc/desktop/StudyEditor.js index 0bb3d98bd35..14fdba55639 100644 --- a/services/static-webserver/client/source/class/osparc/desktop/StudyEditor.js +++ b/services/static-webserver/client/source/class/osparc/desktop/StudyEditor.js @@ -340,7 +340,7 @@ qx.Class.define("osparc.desktop.StudyEditor", { this.getStudyLogger().error(null, "Error submitting pipeline"); this.getStudy().setPipelineRunning(false); }, this); - req.addListener("fail", e => { + req.addListener("fail", async e => { if (e.getTarget().getStatus() == "403") { this.getStudyLogger().error(null, "Pipeline is already running"); } else if (e.getTarget().getStatus() == "422") { @@ -357,6 +357,9 @@ qx.Class.define("osparc.desktop.StudyEditor", { this.__requestStartPipeline(studyId, partialPipeline, true); } }, this); + } else if (e.getTarget().getStatus() == "402") { + const msg = await e.getTarget().getResponse().error.errors[0].message; + osparc.FlashMessenger.getInstance().logAs(msg, "WARNING"); } else { this.getStudyLogger().error(null, "Failed submitting pipeline"); } diff --git a/services/web/server/src/simcore_service_webserver/director_v2/_handlers.py b/services/web/server/src/simcore_service_webserver/director_v2/_handlers.py index 3fe3d9db29b..942bda6c608 100644 --- a/services/web/server/src/simcore_service_webserver/director_v2/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/director_v2/_handlers.py @@ -6,7 +6,7 @@ from models_library.clusters import ClusterID from models_library.projects import ProjectID from models_library.users import UserID -from models_library.wallets import WalletID, WalletInfo +from models_library.wallets import ZERO_CREDITS, WalletID, WalletInfo from pydantic import BaseModel, Field, ValidationError, parse_obj_as from pydantic.types import NonNegativeInt from servicelib.aiohttp.rest_responses import create_error_response, get_http_error @@ -20,20 +20,21 @@ from simcore_postgres_database.utils_groups_extra_properties import ( GroupExtraPropertiesRepo, ) -from simcore_service_webserver.db.plugin import get_database_engine -from simcore_service_webserver.users.exceptions import UserDefaultWalletNotFoundError from .._constants import RQ_PRODUCT_KEY from .._meta import API_VTAG as VTAG from ..application_settings import get_settings +from ..db.plugin import get_database_engine from ..login.decorators import login_required from ..products import api as products_api from ..projects import api as projects_api from ..security.decorators import permission_required from ..users import preferences_api as user_preferences_api +from ..users.exceptions import UserDefaultWalletNotFoundError from ..utils_aiohttp import envelope_json_response from ..version_control.models import CommitID from ..wallets import api as wallets_api +from ..wallets.errors import WalletNotEnoughCreditsError from ._abc import get_project_run_policy from ._core_computations import ComputationsApi from .exceptions import DirectorServiceError @@ -69,83 +70,100 @@ class _ComputationStarted(BaseModel): @permission_required("services.pipeline.*") @permission_required("project.read") async def start_computation(request: web.Request) -> web.Response: - req_ctx = RequestContext.parse_obj(request) - computations = ComputationsApi(request.app) + # pylint: disable=too-many-statements + try: + req_ctx = RequestContext.parse_obj(request) + computations = ComputationsApi(request.app) - run_policy = get_project_run_policy(request.app) - assert run_policy # nosec + run_policy = get_project_run_policy(request.app) + assert run_policy # nosec - project_id = ProjectID(request.match_info["project_id"]) + project_id = ProjectID(request.match_info["project_id"]) - subgraph: set[str] = set() - force_restart: bool = False # NOTE: deprecate this entry - cluster_id: NonNegativeInt = 0 + subgraph: set[str] = set() + force_restart: bool = False # NOTE: deprecate this entry + cluster_id: NonNegativeInt = 0 - if request.can_read_body: - body = await request.json() - assert parse_obj_as(_ComputationStart, body) is not None # nosec + if request.can_read_body: + body = await request.json() + assert parse_obj_as(_ComputationStart, body) is not None # nosec - subgraph = body.get("subgraph", []) - force_restart = bool(body.get("force_restart", force_restart)) - cluster_id = body.get("cluster_id") + subgraph = body.get("subgraph", []) + force_restart = bool(body.get("force_restart", force_restart)) + cluster_id = body.get("cluster_id") - simcore_user_agent = request.headers.get( - X_SIMCORE_USER_AGENT, UNDEFINED_DEFAULT_SIMCORE_USER_AGENT_VALUE - ) - async with get_database_engine(request.app).acquire() as conn: - group_properties = ( - await GroupExtraPropertiesRepo.get_aggregated_properties_for_user( - conn, user_id=req_ctx.user_id, product_name=req_ctx.product_name - ) + simcore_user_agent = request.headers.get( + X_SIMCORE_USER_AGENT, UNDEFINED_DEFAULT_SIMCORE_USER_AGENT_VALUE ) + async with get_database_engine(request.app).acquire() as conn: + group_properties = ( + await GroupExtraPropertiesRepo.get_aggregated_properties_for_user( + conn, user_id=req_ctx.user_id, product_name=req_ctx.product_name + ) + ) - # Get wallet information - wallet_info = None - product = products_api.get_current_product(request) - app_settings = get_settings(request.app) - if product.is_payment_enabled and app_settings.WEBSERVER_CREDIT_COMPUTATION_ENABLED: - project_wallet = await projects_api.get_project_wallet( - request.app, project_id=project_id - ) - if project_wallet is None: - user_default_wallet_preference = await user_preferences_api.get_user_preference( - request.app, - user_id=req_ctx.user_id, - product_name=req_ctx.product_name, - preference_class=user_preferences_api.PreferredWalletIdFrontendUserPreference, + # Get wallet information + wallet_info = None + product = products_api.get_current_product(request) + app_settings = get_settings(request.app) + if ( + product.is_payment_enabled + and app_settings.WEBSERVER_CREDIT_COMPUTATION_ENABLED + ): + project_wallet = await projects_api.get_project_wallet( + request.app, project_id=project_id ) - if user_default_wallet_preference is None: - raise UserDefaultWalletNotFoundError(uid=req_ctx.user_id) - project_wallet_id = parse_obj_as( - WalletID, user_default_wallet_preference.value + if project_wallet is None: + user_default_wallet_preference = await user_preferences_api.get_user_preference( + request.app, + user_id=req_ctx.user_id, + product_name=req_ctx.product_name, + preference_class=user_preferences_api.PreferredWalletIdFrontendUserPreference, + ) + if user_default_wallet_preference is None: + raise UserDefaultWalletNotFoundError(uid=req_ctx.user_id) + project_wallet_id = parse_obj_as( + WalletID, user_default_wallet_preference.value + ) + await projects_api.connect_wallet_to_project( + request.app, + product_name=req_ctx.product_name, + project_id=project_id, + user_id=req_ctx.user_id, + wallet_id=project_wallet_id, + ) + else: + project_wallet_id = project_wallet.wallet_id + + # Check whether user has access to the wallet + wallet = ( + await wallets_api.get_wallet_with_available_credits_by_user_and_wallet( + request.app, + req_ctx.user_id, + project_wallet_id, + req_ctx.product_name, + ) ) - await projects_api.connect_wallet_to_project( - request.app, - product_name=req_ctx.product_name, - project_id=project_id, - user_id=req_ctx.user_id, - wallet_id=project_wallet_id, + if wallet.available_credits <= ZERO_CREDITS: + raise WalletNotEnoughCreditsError( + reason=f"Wallet {wallet.wallet_id} credit balance {wallet.available_credits}" + ) + wallet_info = WalletInfo( + wallet_id=project_wallet_id, wallet_name=wallet.name ) - else: - project_wallet_id = project_wallet.wallet_id - # Check whether user has access to the wallet - wallet = await wallets_api.get_wallet_by_user( - request.app, req_ctx.user_id, project_wallet_id, req_ctx.product_name - ) - wallet_info = WalletInfo(wallet_id=project_wallet_id, wallet_name=wallet.name) - - options = { - "start_pipeline": True, - "subgraph": list(subgraph), # sets are not natively json serializable - "force_restart": force_restart, - "cluster_id": None if group_properties.use_on_demand_clusters else cluster_id, - "simcore_user_agent": simcore_user_agent, - "use_on_demand_clusters": group_properties.use_on_demand_clusters, - "wallet_info": wallet_info, - } + options = { + "start_pipeline": True, + "subgraph": list(subgraph), # sets are not natively json serializable + "force_restart": force_restart, + "cluster_id": None + if group_properties.use_on_demand_clusters + else cluster_id, + "simcore_user_agent": simcore_user_agent, + "use_on_demand_clusters": group_properties.use_on_demand_clusters, + "wallet_info": wallet_info, + } - try: running_project_ids: list[ProjectID] project_vc_commits: list[CommitID] @@ -199,6 +217,8 @@ async def start_computation(request: web.Request) -> web.Response: ) except UserDefaultWalletNotFoundError as exc: return create_error_response(exc, http_error_cls=web.HTTPNotFound) + except WalletNotEnoughCreditsError as exc: + return create_error_response(exc, http_error_cls=web.HTTPPaymentRequired) @routes.post(f"/{VTAG}/computations/{{project_id}}:stop", name="stop_computation") diff --git a/services/web/server/src/simcore_service_webserver/projects/_nodes_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_nodes_handlers.py index 27b59c32576..f9ccafb6b87 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_nodes_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_nodes_handlers.py @@ -54,6 +54,7 @@ from ..users.api import get_user_role from ..users.exceptions import UserDefaultWalletNotFoundError from ..utils_aiohttp import envelope_json_response +from ..wallets.errors import WalletNotEnoughCreditsError from . import projects_api from ._common_models import ProjectPathParams, RequestContext from ._nodes_api import NodeScreenshot, get_node_screenshots @@ -83,6 +84,8 @@ async def wrapper(request: web.Request) -> web.StreamResponse: DefaultPricingUnitNotFoundError, ) as exc: raise web.HTTPNotFound(reason=f"{exc}") from exc + except (WalletNotEnoughCreditsError) as exc: + raise web.HTTPPaymentRequired(reason=f"{exc}") from exc return wrapper diff --git a/services/web/server/src/simcore_service_webserver/projects/_states_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_states_handlers.py index f9290df62da..661559b6351 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_states_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_states_handlers.py @@ -22,8 +22,6 @@ from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON from simcore_postgres_database.models.users import UserRole from simcore_postgres_database.webserver_models import ProjectType -from simcore_service_webserver.users.exceptions import UserDefaultWalletNotFoundError -from simcore_service_webserver.utils_aiohttp import envelope_json_response from .._meta import API_VTAG as VTAG from ..director_v2.exceptions import DirectorServiceError @@ -32,6 +30,9 @@ from ..products.api import Product, get_current_product from ..security.decorators import permission_required from ..users import api +from ..users.exceptions import UserDefaultWalletNotFoundError +from ..utils_aiohttp import envelope_json_response +from ..wallets.errors import WalletNotEnoughCreditsError from . import projects_api from ._common_models import ProjectPathParams, RequestContext from .exceptions import ( @@ -64,6 +65,9 @@ async def _wrapper(request: web.Request) -> web.StreamResponse: except ProjectTooManyProjectOpenedError as exc: raise web.HTTPConflict(reason=f"{exc}") from exc + except WalletNotEnoughCreditsError as exc: + raise web.HTTPPaymentRequired(reason=f"{exc}") from exc + return _wrapper diff --git a/services/web/server/src/simcore_service_webserver/projects/projects_api.py b/services/web/server/src/simcore_service_webserver/projects/projects_api.py index c91be59679c..74115ecefdf 100644 --- a/services/web/server/src/simcore_service_webserver/projects/projects_api.py +++ b/services/web/server/src/simcore_service_webserver/projects/projects_api.py @@ -40,7 +40,7 @@ from models_library.services_resources import ServiceResourcesDict from models_library.users import UserID from models_library.utils.fastapi_encoders import jsonable_encoder -from models_library.wallets import WalletID, WalletInfo +from models_library.wallets import ZERO_CREDITS, WalletID, WalletInfo from pydantic import parse_obj_as from servicelib.aiohttp.application_keys import APP_FIRE_AND_FORGET_TASKS_KEY from servicelib.common_headers import ( @@ -86,6 +86,7 @@ get_user_preference, ) from ..wallets import api as wallets_api +from ..wallets.errors import WalletNotEnoughCreditsError from . import _crud_api_delete, _nodes_api from ._nodes_utils import set_reservation_same_as_limit, validate_new_service_resources from ._wallets_api import connect_wallet_to_project, get_project_wallet @@ -337,9 +338,15 @@ async def _start_dynamic_service( else: project_wallet_id = project_wallet.wallet_id # Check whether user has access to the wallet - wallet = await wallets_api.get_wallet_by_user( - request.app, user_id, project_wallet_id, product_name + wallet = ( + await wallets_api.get_wallet_with_available_credits_by_user_and_wallet( + request.app, user_id, project_wallet_id, product_name + ) ) + if wallet.available_credits <= ZERO_CREDITS: + raise WalletNotEnoughCreditsError( + reason=f"Wallet {wallet.wallet_id} credit balance {wallet.available_credits}" + ) wallet_info = WalletInfo( wallet_id=project_wallet_id, wallet_name=wallet.name ) diff --git a/services/web/server/src/simcore_service_webserver/wallets/api.py b/services/web/server/src/simcore_service_webserver/wallets/api.py index c2e4e0eb38c..8df130d4905 100644 --- a/services/web/server/src/simcore_service_webserver/wallets/api.py +++ b/services/web/server/src/simcore_service_webserver/wallets/api.py @@ -1,5 +1,6 @@ from ._api import ( get_wallet_by_user, + get_wallet_with_available_credits_by_user_and_wallet, get_wallet_with_permissions_by_user, list_wallets_for_user, ) @@ -8,6 +9,7 @@ __all__: tuple[str, ...] = ( "get_wallet_by_user", "get_wallet_with_permissions_by_user", + "get_wallet_with_available_credits_by_user_and_wallet", "list_wallets_for_user", "list_wallet_groups_with_read_access_by_wallet", ) diff --git a/services/web/server/src/simcore_service_webserver/wallets/errors.py b/services/web/server/src/simcore_service_webserver/wallets/errors.py index 45bd1ae3855..33e3816390a 100644 --- a/services/web/server/src/simcore_service_webserver/wallets/errors.py +++ b/services/web/server/src/simcore_service_webserver/wallets/errors.py @@ -18,6 +18,10 @@ class WalletAccessForbiddenError(WalletsValueError): msg_template = "Wallet access forbidden. {reason}" +class WalletNotEnoughCreditsError(WalletsValueError): + msg_template = "Wallet does not have enough credits. {reason}" + + # Wallet groups