From 8c81f526ac384eee656b0e6a89c90fddf23a7cc2 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Mon, 13 Jan 2025 17:34:16 +0100 Subject: [PATCH 01/17] daily work --- .../src/models_library/resource_tracker.py | 18 +++ .../credit_transactions.py | 68 ++++++++ .../services/background_tasks.py | 9 +- .../api/rpc/_credit_transactions.py | 50 ++++++ ..._resource_tracker.py => _pricing_plans.py} | 99 +----------- .../api/rpc/_service_runs.py | 108 +++++++++++++ .../api/rpc/routes.py | 12 +- .../models/credit_transactions.py | 5 + .../services/credit_transactions.py | 148 ++++++++++++++++-- .../modules/db/credit_transactions_db.py | 56 ++++++- .../services/modules/db/service_runs_db.py | 75 ++++++--- .../process_message_running_service.py | 23 ++- .../services/service_runs.py | 63 +++----- .../services/utils.py | 21 +-- .../projects/_wallets_api.py | 118 +++++++++++++- .../projects/_wallets_handlers.py | 69 +++++++- 16 files changed, 734 insertions(+), 208 deletions(-) create mode 100644 packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/credit_transactions.py create mode 100644 services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_credit_transactions.py rename services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/{_resource_tracker.py => _pricing_plans.py} (56%) create mode 100644 services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_service_runs.py diff --git a/packages/models-library/src/models_library/resource_tracker.py b/packages/models-library/src/models_library/resource_tracker.py index 629633aa8c8..3e56664d77e 100644 --- a/packages/models-library/src/models_library/resource_tracker.py +++ b/packages/models-library/src/models_library/resource_tracker.py @@ -38,16 +38,34 @@ class ServiceRunStatus(StrAutoEnum): class CreditTransactionStatus(StrAutoEnum): + # Represents the possible statuses of a credit transaction. + PENDING = auto() + # The transaction is pending and has not yet been finalized. + # Example: During the running of a service, the transaction remains in the Pending state until the service is stopped. + BILLED = auto() + # The transaction has been successfully billed. + + IN_DEBT = auto() + # The transaction is marked as in debt. + # Example: This occurs when a computational job continues to run even though the user does not have sufficient credits in their wallet. + NOT_BILLED = auto() + # The transaction will not be billed. + # Example: This status is used when there is an issue on our side, and we decide not to bill the user. + REQUIRES_MANUAL_REVIEW = auto() + # The transaction requires manual review due to potential issues. + # NOTE: This status is currently not in use. class CreditClassification(StrAutoEnum): ADD_WALLET_TOP_UP = auto() # user top up credits DEDUCT_SERVICE_RUN = auto() # computational/dynamic service run costs) DEDUCT_LICENSE_PURCHASE = auto() + ADD_WALLET_EXCHANGE = auto() + DEDUCT_WALLET_EXCHANGE = auto() class PricingPlanClassification(StrAutoEnum): diff --git a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/credit_transactions.py b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/credit_transactions.py new file mode 100644 index 00000000000..1af232a7c32 --- /dev/null +++ b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/credit_transactions.py @@ -0,0 +1,68 @@ +import logging +from typing import Final + +from models_library.api_schemas_resource_usage_tracker import ( + RESOURCE_USAGE_TRACKER_RPC_NAMESPACE, +) +from models_library.api_schemas_resource_usage_tracker.credit_transactions import ( + CreditTransactionCreateBody, + WalletTotalCredits, +) +from models_library.products import ProductName +from models_library.projects import ProjectID +from models_library.rabbitmq_basic_types import RPCMethodName +from models_library.resource_tracker import CreditTransactionStatus +from models_library.wallets import WalletID +from pydantic import NonNegativeInt, TypeAdapter + +from ....logging_utils import log_decorator +from ....rabbitmq import RabbitMQRPCClient + +_logger = logging.getLogger(__name__) + + +_DEFAULT_TIMEOUT_S: Final[NonNegativeInt] = 20 + +_RPC_METHOD_NAME_ADAPTER: TypeAdapter[RPCMethodName] = TypeAdapter(RPCMethodName) + + +@log_decorator(_logger, level=logging.DEBUG) +async def get_wallet_total_credits( + rabbitmq_rpc_client: RabbitMQRPCClient, + *, + product_name: ProductName, + wallet_id: WalletID, + transaction_status: CreditTransactionStatus | None = None, + project_id: ProjectID | None = None, +) -> WalletTotalCredits: + result = await rabbitmq_rpc_client.request( + RESOURCE_USAGE_TRACKER_RPC_NAMESPACE, + _RPC_METHOD_NAME_ADAPTER.validate_python("get_wallet_total_credits"), + product_name=product_name, + wallet_id=wallet_id, + transaction_status=transaction_status, + project_id=project_id, + timeout_s=_DEFAULT_TIMEOUT_S, + ) + assert isinstance(result, WalletTotalCredits) # nosec + return result + + +@log_decorator(_logger, level=logging.DEBUG) +async def pay_project_debt( + rabbitmq_rpc_client: RabbitMQRPCClient, + *, + project_id: ProjectID, + current_wallet_transaction: CreditTransactionCreateBody, + new_wallet_transaction: CreditTransactionCreateBody, +) -> None: + result = await rabbitmq_rpc_client.request( + RESOURCE_USAGE_TRACKER_RPC_NAMESPACE, + _RPC_METHOD_NAME_ADAPTER.validate_python("pay_project_debt"), + project_id=project_id, + current_wallet_transaction=current_wallet_transaction, + new_wallet_transaction=new_wallet_transaction, + timeout_s=_DEFAULT_TIMEOUT_S, + ) + assert isinstance(result, None) # nosec + return result diff --git a/services/efs-guardian/src/simcore_service_efs_guardian/services/background_tasks.py b/services/efs-guardian/src/simcore_service_efs_guardian/services/background_tasks.py index 8ac475a4742..e766d1302e2 100644 --- a/services/efs-guardian/src/simcore_service_efs_guardian/services/background_tasks.py +++ b/services/efs-guardian/src/simcore_service_efs_guardian/services/background_tasks.py @@ -45,11 +45,12 @@ async def removal_policy_task(app: FastAPI) -> None: _project_last_change_date = ( await projects_repo.get_project_last_change_date(project_id) ) - except DBProjectNotFoundError as exc: - _logger.warning( - "Project %s not found, this should not happen, please investigate (contact MD)", - exc.msg_template, + except DBProjectNotFoundError: + _logger.info( + "Project %s not found. Removing EFS data for project {project_id} started", + project_id, ) + await efs_manager.remove_project_efs_data(project_id) if ( _project_last_change_date < base_start_timestamp diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_credit_transactions.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_credit_transactions.py new file mode 100644 index 00000000000..6304a388fbc --- /dev/null +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_credit_transactions.py @@ -0,0 +1,50 @@ +from fastapi import FastAPI +from models_library.api_schemas_resource_usage_tracker.credit_transactions import ( + CreditTransactionCreateBody, + WalletTotalCredits, +) +from models_library.products import ProductName +from models_library.projects import ProjectID +from models_library.resource_tracker import CreditTransactionStatus +from models_library.wallets import WalletID +from servicelib.rabbitmq import RPCRouter + +from ...services import credit_transactions + +router = RPCRouter() + + +@router.expose(reraise_if_error_type=()) +async def get_wallet_total_credits( + app: FastAPI, + *, + product_name: ProductName, + wallet_id: WalletID, + # internal filters + transaction_status: CreditTransactionStatus | None = None, + project_id: ProjectID | None = None, +) -> WalletTotalCredits: + return await credit_transactions.sum_credit_transactions_by_product_and_wallet( + db_engine=app.state.engine, + product_name=product_name, + wallet_id=wallet_id, + transaction_status=transaction_status, + project_id=project_id, + ) + + +@router.expose(reraise_if_error_type=()) +async def pay_project_debt( + app: FastAPI, + *, + project_id: ProjectID, + current_wallet_transaction: CreditTransactionCreateBody, + new_wallet_transaction: CreditTransactionCreateBody, +) -> None: + return await credit_transactions.pay_project_debt( + db_engine=app.state.engine, + rabbitmq_client=app.state.rabbitmq_client, + project_id=project_id, + current_wallet_transaction=current_wallet_transaction, + new_wallet_transaction=new_wallet_transaction, + ) diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_resource_tracker.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_pricing_plans.py similarity index 56% rename from services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_resource_tracker.py rename to services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_pricing_plans.py index 5a382782f9d..eb5fea480a7 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_resource_tracker.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_pricing_plans.py @@ -4,10 +4,6 @@ PricingPlanToServiceGet, PricingUnitGet, ) -from models_library.api_schemas_resource_usage_tracker.service_runs import ( - OsparcCreditsAggregatedUsagesPage, - ServiceRunPage, -) from models_library.products import ProductName from models_library.resource_tracker import ( PricingPlanCreate, @@ -16,108 +12,15 @@ PricingUnitId, PricingUnitWithCostCreate, PricingUnitWithCostUpdate, - ServiceResourceUsagesFilters, - ServicesAggregatedUsagesTimePeriod, - ServicesAggregatedUsagesType, ) -from models_library.rest_ordering import OrderBy from models_library.services import ServiceKey, ServiceVersion -from models_library.users import UserID -from models_library.wallets import WalletID -from pydantic import AnyUrl from servicelib.rabbitmq import RPCRouter -from ...core.settings import ApplicationSettings -from ...services import pricing_plans, pricing_units, service_runs -from ...services.modules.s3 import get_s3_client +from ...services import pricing_plans, pricing_units router = RPCRouter() -## Service runs - - -@router.expose(reraise_if_error_type=()) -async def get_service_run_page( - app: FastAPI, - *, - user_id: UserID, - product_name: ProductName, - limit: int = 20, - offset: int = 0, - wallet_id: WalletID | None = None, - access_all_wallet_usage: bool = False, - order_by: OrderBy | None = None, - filters: ServiceResourceUsagesFilters | None = None, -) -> ServiceRunPage: - return await service_runs.list_service_runs( - user_id=user_id, - product_name=product_name, - db_engine=app.state.engine, - limit=limit, - offset=offset, - wallet_id=wallet_id, - access_all_wallet_usage=access_all_wallet_usage, - order_by=order_by, - filters=filters, - ) - - -@router.expose(reraise_if_error_type=()) -async def export_service_runs( - app: FastAPI, - *, - user_id: UserID, - product_name: ProductName, - wallet_id: WalletID | None = None, - access_all_wallet_usage: bool = False, - order_by: OrderBy | None = None, - filters: ServiceResourceUsagesFilters | None = None, -) -> AnyUrl: - app_settings: ApplicationSettings = app.state.settings - s3_settings = app_settings.RESOURCE_USAGE_TRACKER_S3 - assert s3_settings # nosec - - return await service_runs.export_service_runs( - s3_client=get_s3_client(app), - bucket_name=f"{s3_settings.S3_BUCKET_NAME}", - s3_region=s3_settings.S3_REGION, - user_id=user_id, - product_name=product_name, - db_engine=app.state.engine, - wallet_id=wallet_id, - access_all_wallet_usage=access_all_wallet_usage, - order_by=order_by, - filters=filters, - ) - - -@router.expose(reraise_if_error_type=()) -async def get_osparc_credits_aggregated_usages_page( - app: FastAPI, - *, - user_id: UserID, - product_name: ProductName, - aggregated_by: ServicesAggregatedUsagesType, - time_period: ServicesAggregatedUsagesTimePeriod, - limit: int = 20, - offset: int = 0, - wallet_id: WalletID, - access_all_wallet_usage: bool = False, -) -> OsparcCreditsAggregatedUsagesPage: - return await service_runs.get_osparc_credits_aggregated_usages_page( - user_id=user_id, - product_name=product_name, - db_engine=app.state.engine, - aggregated_by=aggregated_by, - time_period=time_period, - limit=limit, - offset=offset, - wallet_id=wallet_id, - access_all_wallet_usage=access_all_wallet_usage, - ) - - ## Pricing plans diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_service_runs.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_service_runs.py new file mode 100644 index 00000000000..ffb38284397 --- /dev/null +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_service_runs.py @@ -0,0 +1,108 @@ +from fastapi import FastAPI +from models_library.api_schemas_resource_usage_tracker.service_runs import ( + OsparcCreditsAggregatedUsagesPage, + ServiceRunPage, +) +from models_library.products import ProductName +from models_library.resource_tracker import ( + ServiceResourceUsagesFilters, + ServicesAggregatedUsagesTimePeriod, + ServicesAggregatedUsagesType, +) +from models_library.rest_ordering import OrderBy +from models_library.users import UserID +from models_library.wallets import WalletID +from pydantic import AnyUrl +from servicelib.rabbitmq import RPCRouter + +from ...core.settings import ApplicationSettings +from ...services import service_runs +from ...services.modules.s3 import get_s3_client + +router = RPCRouter() + + +## Service runs + + +@router.expose(reraise_if_error_type=()) +async def get_service_run_page( + app: FastAPI, + *, + user_id: UserID, + product_name: ProductName, + wallet_id: WalletID | None = None, + access_all_wallet_usage: bool = False, + filters: ServiceResourceUsagesFilters | None = None, + # pagination + limit: int = 20, + offset: int = 0, + # ordering + order_by: OrderBy | None = None, +) -> ServiceRunPage: + return await service_runs.list_service_runs( + db_engine=app.state.engine, + user_id=user_id, + product_name=product_name, + wallet_id=wallet_id, + access_all_wallet_usage=access_all_wallet_usage, + filters=filters, + limit=limit, + offset=offset, + order_by=order_by, + ) + + +@router.expose(reraise_if_error_type=()) +async def export_service_runs( + app: FastAPI, + *, + user_id: UserID, + product_name: ProductName, + wallet_id: WalletID | None = None, + access_all_wallet_usage: bool = False, + order_by: OrderBy | None = None, + filters: ServiceResourceUsagesFilters | None = None, +) -> AnyUrl: + app_settings: ApplicationSettings = app.state.settings + s3_settings = app_settings.RESOURCE_USAGE_TRACKER_S3 + assert s3_settings # nosec + + return await service_runs.export_service_runs( + s3_client=get_s3_client(app), + bucket_name=f"{s3_settings.S3_BUCKET_NAME}", + s3_region=s3_settings.S3_REGION, + user_id=user_id, + product_name=product_name, + db_engine=app.state.engine, + wallet_id=wallet_id, + access_all_wallet_usage=access_all_wallet_usage, + order_by=order_by, + filters=filters, + ) + + +@router.expose(reraise_if_error_type=()) +async def get_osparc_credits_aggregated_usages_page( + app: FastAPI, + *, + user_id: UserID, + product_name: ProductName, + aggregated_by: ServicesAggregatedUsagesType, + time_period: ServicesAggregatedUsagesTimePeriod, + limit: int = 20, + offset: int = 0, + wallet_id: WalletID, + access_all_wallet_usage: bool = False, +) -> OsparcCreditsAggregatedUsagesPage: + return await service_runs.get_osparc_credits_aggregated_usages_page( + user_id=user_id, + product_name=product_name, + db_engine=app.state.engine, + aggregated_by=aggregated_by, + time_period=time_period, + limit=limit, + offset=offset, + wallet_id=wallet_id, + access_all_wallet_usage=access_all_wallet_usage, + ) diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/routes.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/routes.py index e5da8f44411..42767b19525 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/routes.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/routes.py @@ -8,13 +8,21 @@ from servicelib.rabbitmq import RPCRouter from ...services.modules.rabbitmq import get_rabbitmq_rpc_server -from . import _licensed_items_checkouts, _licensed_items_purchases, _resource_tracker +from . import ( + _credit_transactions, + _licensed_items_checkouts, + _licensed_items_purchases, + _pricing_plans, + _service_runs, +) _logger = logging.getLogger(__name__) ROUTERS: list[RPCRouter] = [ - _resource_tracker.router, + _credit_transactions.router, + _service_runs.router, + _pricing_plans.router, _licensed_items_purchases.router, _licensed_items_checkouts.router, ] diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/models/credit_transactions.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/models/credit_transactions.py index ac461b37c8c..564b12ce1d4 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/models/credit_transactions.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/models/credit_transactions.py @@ -50,6 +50,11 @@ class CreditTransactionCreditsAndStatusUpdate(BaseModel): transaction_status: CreditTransactionStatus +class CreditTransactionStatusUpdate(BaseModel): + service_run_id: ServiceRunID + transaction_status: CreditTransactionStatus + + class CreditTransactionDB(BaseModel): transaction_id: CreditTransactionId product_name: ProductName diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/credit_transactions.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/credit_transactions.py index fa314ee2550..f2eb899c447 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/credit_transactions.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/credit_transactions.py @@ -6,6 +6,7 @@ WalletTotalCredits, ) from models_library.products import ProductName +from models_library.projects import ProjectID from models_library.resource_tracker import ( CreditClassification, CreditTransactionId, @@ -13,21 +14,22 @@ ) from models_library.wallets import WalletID from servicelib.rabbitmq import RabbitMQClient +from simcore_postgres_database.utils_repos import transaction_context from sqlalchemy.ext.asyncio import AsyncEngine from ..api.rest.dependencies import get_resource_tracker_db_engine from ..models.credit_transactions import CreditTransactionCreate from .modules.db import credit_transactions_db from .modules.rabbitmq import get_rabbitmq_client_from_request -from .utils import sum_credit_transactions_and_publish_to_rabbitmq +from .utils import make_negative, sum_credit_transactions_and_publish_to_rabbitmq async def create_credit_transaction( - credit_transaction_create_body: CreditTransactionCreateBody, db_engine: Annotated[AsyncEngine, Depends(get_resource_tracker_db_engine)], rabbitmq_client: Annotated[ RabbitMQClient, Depends(get_rabbitmq_client_from_request) ], + credit_transaction_create_body: CreditTransactionCreateBody, ) -> CreditTransactionId: transaction_create = CreditTransactionCreate( product_name=credit_transaction_create_body.product_name, @@ -47,25 +49,143 @@ async def create_credit_transaction( created_at=credit_transaction_create_body.created_at, last_heartbeat_at=credit_transaction_create_body.created_at, ) - transaction_id = await credit_transactions_db.create_credit_transaction( - db_engine, data=transaction_create - ) + async with transaction_context(db_engine) as conn: + transaction_id = await credit_transactions_db.create_credit_transaction( + db_engine, connection=conn, data=transaction_create + ) - await sum_credit_transactions_and_publish_to_rabbitmq( - db_engine, - rabbitmq_client, - credit_transaction_create_body.product_name, - credit_transaction_create_body.wallet_id, - ) + wallet_total_credits = await sum_credit_transactions_and_publish_to_rabbitmq( + db_engine, + rabbitmq_client, + credit_transaction_create_body.product_name, + credit_transaction_create_body.wallet_id, + ) + if wallet_total_credits.available_osparc_credits >= 0: + # Change status from `IN_DEBT` to `BILLED` + await credit_transactions_db.batch_update_credit_transaction_status_for_in_debt_transactions( + db_engine, + connection=conn, + project_id=None, + wallet_id=credit_transaction_create_body.wallet_id, + transaction_status=CreditTransactionStatus.BILLED, + ) - return transaction_id + return transaction_id async def sum_credit_transactions_by_product_and_wallet( + db_engine: Annotated[AsyncEngine, Depends(get_resource_tracker_db_engine)], + *, product_name: ProductName, wallet_id: WalletID, - db_engine: Annotated[AsyncEngine, Depends(get_resource_tracker_db_engine)], + # attribute filters + transaction_status: CreditTransactionStatus | None = None, + project_id: ProjectID | None = None, ) -> WalletTotalCredits: return await credit_transactions_db.sum_credit_transactions_by_product_and_wallet( - db_engine, product_name=product_name, wallet_id=wallet_id + db_engine, + product_name=product_name, + wallet_id=wallet_id, + transaction_status=transaction_status, + project_id=project_id, + ) + + +async def pay_project_debt( + db_engine: Annotated[AsyncEngine, Depends(get_resource_tracker_db_engine)], + rabbitmq_client: Annotated[ + RabbitMQClient, Depends(get_rabbitmq_client_from_request) + ], + project_id: ProjectID, + current_wallet_transaction: CreditTransactionCreateBody, + new_wallet_transaction: CreditTransactionCreateBody, +): + # `current_wallet_transaction` is Wallet in DEBT + + total_project_debt_amount = ( + await credit_transactions_db.sum_credit_transactions_by_product_and_wallet( + db_engine, + product_name=current_wallet_transaction.product_name, + wallet_id=current_wallet_transaction.wallet_id, + transaction_status=CreditTransactionStatus.IN_DEBT, + project_id=project_id, + ) + ) + + if ( + total_project_debt_amount.available_osparc_credits + != new_wallet_transaction.osparc_credits + ): + raise ValueError("wrong") + if ( + make_negative(total_project_debt_amount.available_osparc_credits) + != current_wallet_transaction.osparc_credits + ): + raise ValueError("wrong") + + new_wallet_transaction_create = CreditTransactionCreate( + product_name=new_wallet_transaction.product_name, + wallet_id=new_wallet_transaction.wallet_id, + wallet_name=new_wallet_transaction.wallet_name, + pricing_plan_id=None, + pricing_unit_id=None, + pricing_unit_cost_id=None, + user_id=new_wallet_transaction.user_id, + user_email=new_wallet_transaction.user_email, + osparc_credits=new_wallet_transaction.osparc_credits, + transaction_status=CreditTransactionStatus.BILLED, + transaction_classification=CreditClassification.DEDUCT_WALLET_EXCHANGE, + service_run_id=None, + payment_transaction_id=new_wallet_transaction.payment_transaction_id, + licensed_item_purchase_id=None, + created_at=new_wallet_transaction.created_at, + last_heartbeat_at=new_wallet_transaction.created_at, + ) + + current_wallet_transaction_create = CreditTransactionCreate( + product_name=current_wallet_transaction.product_name, + wallet_id=current_wallet_transaction.wallet_id, + wallet_name=current_wallet_transaction.wallet_name, + pricing_plan_id=None, + pricing_unit_id=None, + pricing_unit_cost_id=None, + user_id=current_wallet_transaction.user_id, + user_email=current_wallet_transaction.user_email, + osparc_credits=current_wallet_transaction.osparc_credits, + transaction_status=CreditTransactionStatus.BILLED, + transaction_classification=CreditClassification.ADD_WALLET_EXCHANGE, + service_run_id=None, + payment_transaction_id=current_wallet_transaction.payment_transaction_id, + licensed_item_purchase_id=None, + created_at=current_wallet_transaction.created_at, + last_heartbeat_at=current_wallet_transaction.created_at, + ) + + async with transaction_context(db_engine) as conn: + await credit_transactions_db.create_credit_transaction( + db_engine, connection=conn, data=new_wallet_transaction_create + ) + await credit_transactions_db.create_credit_transaction( + db_engine, connection=conn, data=current_wallet_transaction_create + ) + # Change status from `IN_DEBT` to `BILLED` + await credit_transactions_db.batch_update_credit_transaction_status_for_in_debt_transactions( + db_engine, + connection=conn, + project_id=project_id, + wallet_id=current_wallet_transaction_create.wallet_id, + transaction_status=CreditTransactionStatus.BILLED, + ) + + await sum_credit_transactions_and_publish_to_rabbitmq( + db_engine, + rabbitmq_client, + new_wallet_transaction_create.product_name, + new_wallet_transaction_create.wallet_id, + ) + await sum_credit_transactions_and_publish_to_rabbitmq( + db_engine, + rabbitmq_client, + current_wallet_transaction_create.product_name, + current_wallet_transaction_create.wallet_id, ) diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/credit_transactions_db.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/credit_transactions_db.py index 254a36a9732..ec3f5510182 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/credit_transactions_db.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/credit_transactions_db.py @@ -7,6 +7,7 @@ WalletTotalCredits, ) from models_library.products import ProductName +from models_library.projects import ProjectID from models_library.resource_tracker import CreditTransactionId, CreditTransactionStatus from models_library.wallets import WalletID from simcore_postgres_database.models.resource_tracker_credit_transactions import ( @@ -29,7 +30,7 @@ async def create_credit_transaction( engine: AsyncEngine, connection: AsyncConnection | None = None, *, - data: CreditTransactionCreate + data: CreditTransactionCreate, ) -> CreditTransactionId: async with transaction_context(engine, connection) as conn: insert_stmt = ( @@ -66,7 +67,7 @@ async def update_credit_transaction_credits( engine: AsyncEngine, connection: AsyncConnection | None = None, *, - data: CreditTransactionCreditsUpdate + data: CreditTransactionCreditsUpdate, ) -> CreditTransactionId | None: async with transaction_context(engine, connection) as conn: update_stmt = ( @@ -103,7 +104,7 @@ async def update_credit_transaction_credits_and_status( engine: AsyncEngine, connection: AsyncConnection | None = None, *, - data: CreditTransactionCreditsAndStatusUpdate + data: CreditTransactionCreditsAndStatusUpdate, ) -> CreditTransactionId | None: async with transaction_context(engine, connection) as conn: update_stmt = ( @@ -132,12 +133,47 @@ async def update_credit_transaction_credits_and_status( return cast(CreditTransactionId | None, row[0]) +async def batch_update_credit_transaction_status_for_in_debt_transactions( + engine: AsyncEngine, + connection: AsyncConnection | None = None, + *, + project_id: ProjectID | None = None, + wallet_id: WalletID, + transaction_status: CreditTransactionStatus, +) -> None: + async with transaction_context(engine, connection) as conn: + update_stmt = ( + resource_tracker_credit_transactions.update() + .values( + modified=sa.func.now(), + transaction_status=transaction_status, + ) + .where( + (resource_tracker_credit_transactions.c.wallet_id == f"{wallet_id}") + & ( + resource_tracker_credit_transactions.c.transaction_status + == CreditTransactionStatus.IN_DEBT + ) + ) + ) + + if project_id: + update_stmt = update_stmt.where( + resource_tracker_credit_transactions.c.project_id == f"{project_id}" + ) + + await conn.execute(update_stmt) + + async def sum_credit_transactions_by_product_and_wallet( engine: AsyncEngine, connection: AsyncConnection | None = None, *, product_name: ProductName, - wallet_id: WalletID + wallet_id: WalletID, + # attribute filters + transaction_status: CreditTransactionStatus | None = None, + project_id: ProjectID | None = None, ) -> WalletTotalCredits: async with transaction_context(engine, connection) as conn: sum_stmt = sa.select( @@ -150,10 +186,22 @@ async def sum_credit_transactions_by_product_and_wallet( [ CreditTransactionStatus.BILLED, CreditTransactionStatus.PENDING, + CreditTransactionStatus.IN_DEBT, ] ) ) ) + + if project_id: + sum_stmt = sum_stmt.where( + resource_tracker_credit_transactions.c.project_id == f"{project_id}" + ) + if transaction_status: + sum_stmt = sum_stmt.where( + resource_tracker_credit_transactions.c.transaction_status + == transaction_status + ) + result = await conn.execute(sum_stmt) row = result.first() if row is None or row[0] is None: diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/service_runs_db.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/service_runs_db.py index c1bf23df530..d20b7673ddd 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/service_runs_db.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/service_runs_db.py @@ -24,7 +24,10 @@ resource_tracker_service_runs, ) from simcore_postgres_database.models.tags import tags -from simcore_postgres_database.utils_repos import transaction_context +from simcore_postgres_database.utils_repos import ( + pass_or_acquire_connection, + transaction_context, +) from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine from ....exceptions.errors import ServiceRunNotCreatedDBError @@ -162,7 +165,7 @@ async def get_service_run_by_id( *, service_run_id: ServiceRunID, ) -> ServiceRunDB | None: - async with transaction_context(engine, connection) as conn: + async with pass_or_acquire_connection(engine, connection) as conn: stmt = sa.select(resource_tracker_service_runs).where( resource_tracker_service_runs.c.service_run_id == service_run_id ) @@ -190,15 +193,18 @@ async def list_service_runs_by_product_and_user_and_wallet( product_name: ProductName, user_id: UserID | None, wallet_id: WalletID | None, - offset: int, - limit: int, + # attribute filtering service_run_status: ServiceRunStatus | None = None, started_from: datetime | None = None, started_until: datetime | None = None, + # pagination + offset: int, + limit: int, + # ordering order_by: OrderBy | None = None, -) -> list[ServiceRunWithCreditsDB]: - async with transaction_context(engine, connection) as conn: - query = ( +) -> tuple[int, list[ServiceRunWithCreditsDB]]: + async with pass_or_acquire_connection(engine, connection) as conn: + base_query = ( sa.select( resource_tracker_service_runs.c.product_name, resource_tracker_service_runs.c.service_run_id, @@ -257,41 +263,56 @@ async def list_service_runs_by_product_and_user_and_wallet( ) ) .where(resource_tracker_service_runs.c.product_name == product_name) - .offset(offset) - .limit(limit) ) if user_id: - query = query.where(resource_tracker_service_runs.c.user_id == user_id) + base_query = base_query.where( + resource_tracker_service_runs.c.user_id == user_id + ) if wallet_id: - query = query.where(resource_tracker_service_runs.c.wallet_id == wallet_id) + base_query = base_query.where( + resource_tracker_service_runs.c.wallet_id == wallet_id + ) if service_run_status: - query = query.where( + base_query = base_query.where( resource_tracker_service_runs.c.service_run_status == service_run_status ) if started_from: - query = query.where( + base_query = base_query.where( sa.func.DATE(resource_tracker_service_runs.c.started_at) >= started_from.date() ) if started_until: - query = query.where( + base_query = base_query.where( sa.func.DATE(resource_tracker_service_runs.c.started_at) <= started_until.date() ) + # Select total count from base_query + subquery = base_query.subquery() + count_query = sa.select(sa.func.count()).select_from(subquery) + if order_by: if order_by.direction == OrderDirection.ASC: - query = query.order_by(sa.asc(order_by.field)) + list_query = base_query.order_by(sa.asc(order_by.field)) else: - query = query.order_by(sa.desc(order_by.field)) + list_query = base_query.order_by(sa.desc(order_by.field)) else: # Default ordering - query = query.order_by(resource_tracker_service_runs.c.started_at.desc()) + list_query = base_query.order_by( + resource_tracker_service_runs.c.started_at.desc() + ) - result = await conn.execute(query) + total_count = await conn.scalar(count_query) + if total_count is None: + total_count = 0 - return [ServiceRunWithCreditsDB.model_validate(row) for row in result.fetchall()] + result = await conn.stream(list_query.offset(offset).limit(limit)) + items: list[ServiceRunWithCreditsDB] = [ + ServiceRunWithCreditsDB.model_validate(row) async for row in result + ] + + return cast(int, total_count), items async def get_osparc_credits_aggregated_by_service( @@ -306,7 +327,7 @@ async def get_osparc_credits_aggregated_by_service( started_from: datetime | None = None, started_until: datetime | None = None, ) -> tuple[int, list[OsparcCreditsAggregatedByServiceKeyDB]]: - async with transaction_context(engine, connection) as conn: + async with pass_or_acquire_connection(engine, connection) as conn: base_query = ( sa.select( resource_tracker_service_runs.c.service_key, @@ -347,8 +368,12 @@ async def get_osparc_credits_aggregated_by_service( .where( (resource_tracker_service_runs.c.product_name == product_name) & ( - resource_tracker_credit_transactions.c.transaction_status - == CreditTransactionStatus.BILLED + resource_tracker_credit_transactions.c.transaction_status.in_( + [ + CreditTransactionStatus.BILLED, + CreditTransactionStatus.IN_DEBT, + ] + ) ) & ( resource_tracker_credit_transactions.c.transaction_classification @@ -506,7 +531,7 @@ async def total_service_runs_by_product_and_user_and_wallet( started_from: datetime | None = None, started_until: datetime | None = None, ) -> PositiveInt: - async with transaction_context(engine, connection) as conn: + async with pass_or_acquire_connection(engine, connection) as conn: query = ( sa.select(sa.func.count()) .select_from(resource_tracker_service_runs) @@ -547,7 +572,7 @@ async def list_service_runs_with_running_status_across_all_products( offset: int, limit: int, ) -> list[ServiceRunForCheckDB]: - async with transaction_context(engine, connection) as conn: + async with pass_or_acquire_connection(engine, connection) as conn: query = ( sa.select( resource_tracker_service_runs.c.service_run_id, @@ -571,7 +596,7 @@ async def list_service_runs_with_running_status_across_all_products( async def total_service_runs_with_running_status_across_all_products( engine: AsyncEngine, connection: AsyncConnection | None = None ) -> PositiveInt: - async with transaction_context(engine, connection) as conn: + async with pass_or_acquire_connection(engine, connection) as conn: query = ( sa.select(sa.func.count()) .select_from(resource_tracker_service_runs) diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/process_message_running_service.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/process_message_running_service.py index 88553f51705..5c94657123b 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/process_message_running_service.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/process_message_running_service.py @@ -292,15 +292,28 @@ async def _process_stop_event( msg.created_at, running_service.pricing_unit_cost, ) + + wallet_total_credits = ( + await credit_transactions_db.sum_credit_transactions_by_product_and_wallet( + db_engine, + product_name=running_service.product_name, + wallet_id=running_service.wallet_id, + ) + ) + _transaction_status = ( + CreditTransactionStatus.BILLED + if wallet_total_credits.available_osparc_credits - computed_credits > 0 + else CreditTransactionStatus.IN_DEBT + ) + # Adjust the status if the platform status is not OK + if msg.simcore_platform_status != SimcorePlatformStatus.OK: + _transaction_status = CreditTransactionStatus.NOT_BILLED + # Update credits in the transaction table and close the transaction update_credit_transaction = CreditTransactionCreditsAndStatusUpdate( service_run_id=msg.service_run_id, osparc_credits=make_negative(computed_credits), - transaction_status=( - CreditTransactionStatus.BILLED - if msg.simcore_platform_status == SimcorePlatformStatus.OK - else CreditTransactionStatus.NOT_BILLED - ), + transaction_status=_transaction_status, ) await credit_transactions_db.update_credit_transaction_credits_and_status( db_engine, data=update_credit_transaction diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/service_runs.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/service_runs.py index b4d9127733e..8f441be78a1 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/service_runs.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/service_runs.py @@ -18,25 +18,25 @@ from models_library.rest_ordering import OrderBy from models_library.users import UserID from models_library.wallets import WalletID -from pydantic import AnyUrl, PositiveInt, TypeAdapter +from pydantic import AnyUrl, TypeAdapter from sqlalchemy.ext.asyncio import AsyncEngine -from ..models.service_runs import ServiceRunWithCreditsDB from .modules.db import service_runs_db _PRESIGNED_LINK_EXPIRATION_SEC = 7200 async def list_service_runs( + db_engine: AsyncEngine, + *, user_id: UserID, product_name: ProductName, - db_engine: AsyncEngine, - limit: int = 20, - offset: int = 0, wallet_id: WalletID | None = None, access_all_wallet_usage: bool = False, - order_by: OrderBy | None = None, filters: ServiceResourceUsagesFilters | None = None, + limit: int = 20, + offset: int = 0, + order_by: OrderBy | None = None, ) -> ServiceRunPage: started_from = None started_until = None @@ -46,73 +46,50 @@ async def list_service_runs( # Situation when we want to see all usage of a specific user (ex. for Non billable product) if wallet_id is None and access_all_wallet_usage is False: - total_service_runs: PositiveInt = ( - await service_runs_db.total_service_runs_by_product_and_user_and_wallet( - db_engine, - product_name=product_name, - user_id=user_id, - wallet_id=None, - started_from=started_from, - started_until=started_until, - ) - ) - service_runs_db_model: list[ - ServiceRunWithCreditsDB - ] = await service_runs_db.list_service_runs_by_product_and_user_and_wallet( + ( + total_service_runs, + service_runs_db_model, + ) = await service_runs_db.list_service_runs_by_product_and_user_and_wallet( db_engine, product_name=product_name, user_id=user_id, wallet_id=None, - offset=offset, - limit=limit, started_from=started_from, started_until=started_until, + offset=offset, + limit=limit, order_by=order_by, ) # Situation when accountant user can see all users usage of the wallet elif wallet_id and access_all_wallet_usage is True: - total_service_runs: PositiveInt = await service_runs_db.total_service_runs_by_product_and_user_and_wallet( # type: ignore[no-redef] + ( + total_service_runs, + service_runs_db_model, + ) = await service_runs_db.list_service_runs_by_product_and_user_and_wallet( db_engine, product_name=product_name, user_id=None, wallet_id=wallet_id, started_from=started_from, started_until=started_until, - ) - service_runs_db_model: list[ # type: ignore[no-redef] - ServiceRunWithCreditsDB - ] = await service_runs_db.list_service_runs_by_product_and_user_and_wallet( - db_engine, - product_name=product_name, - user_id=None, - wallet_id=wallet_id, offset=offset, limit=limit, - started_from=started_from, - started_until=started_until, order_by=order_by, ) # Situation when regular user can see only his usage of the wallet elif wallet_id and access_all_wallet_usage is False: - total_service_runs: PositiveInt = await service_runs_db.total_service_runs_by_product_and_user_and_wallet( # type: ignore[no-redef] + ( + total_service_runs, + service_runs_db_model, + ) = await service_runs_db.list_service_runs_by_product_and_user_and_wallet( db_engine, product_name=product_name, user_id=user_id, wallet_id=wallet_id, started_from=started_from, started_until=started_until, - ) - service_runs_db_model: list[ # type: ignore[no-redef] - ServiceRunWithCreditsDB - ] = await service_runs_db.list_service_runs_by_product_and_user_and_wallet( - db_engine, - product_name=product_name, - user_id=user_id, - wallet_id=wallet_id, offset=offset, limit=limit, - started_from=started_from, - started_until=started_until, order_by=order_by, ) else: diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/utils.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/utils.py index 2556322000e..4ccd956a16f 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/utils.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/utils.py @@ -99,16 +99,17 @@ async def publish_to_rabbitmq_wallet_credits_limit_reached( ) for offset in range(0, total_count, _BATCH_SIZE): - batch_services = ( - await service_runs_db.list_service_runs_by_product_and_user_and_wallet( - db_engine, - product_name=product_name, - user_id=None, - wallet_id=wallet_id, - offset=offset, - limit=_BATCH_SIZE, - service_run_status=ServiceRunStatus.RUNNING, - ) + ( + _, + batch_services, + ) = await service_runs_db.list_service_runs_by_product_and_user_and_wallet( + db_engine, + product_name=product_name, + user_id=None, + wallet_id=wallet_id, + offset=offset, + limit=_BATCH_SIZE, + service_run_status=ServiceRunStatus.RUNNING, ) await asyncio.gather( diff --git a/services/web/server/src/simcore_service_webserver/projects/_wallets_api.py b/services/web/server/src/simcore_service_webserver/projects/_wallets_api.py index 85bc9cf43a3..b7f26bf3fcb 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_wallets_api.py +++ b/services/web/server/src/simcore_service_webserver/projects/_wallets_api.py @@ -1,10 +1,22 @@ +from datetime import UTC, datetime +from decimal import Decimal + +from models_library.api_schemas_resource_usage_tracker.credit_transactions import ( + CreditTransactionCreateBody, +) from models_library.api_schemas_webserver.wallets import WalletGet from models_library.products import ProductName from models_library.projects import ProjectID +from models_library.resource_tracker import CreditTransactionStatus from models_library.users import UserID from models_library.wallets import WalletDB, WalletID +from servicelib.rabbitmq.rpc_interfaces.resource_usage_tracker import ( + credit_transactions, +) -from ..wallets import _api as wallet_api +from ..rabbitmq import get_rabbitmq_rpc_client +from ..users import api as users_api +from ..wallets import _api as wallets_api from .db import ProjectDBAPI @@ -28,13 +40,115 @@ async def connect_wallet_to_project( ) -> WalletGet: db: ProjectDBAPI = ProjectDBAPI.get_from_app_context(app) + project_wallet = await db.get_project_wallet(project_uuid=project_id) + + if project_wallet: + # NOTE: Do not allow to change wallet if the project is in DEBT! + rpc_client = get_rabbitmq_rpc_client(app) + project_wallet_credits = await credit_transactions.get_wallet_total_credits( + rpc_client, + product_name=product_name, + wallet_id=project_wallet.wallet_id, + project_id=project_id, + transaction_status=CreditTransactionStatus.IN_DEBT, + ) + if project_wallet_credits.available_osparc_credits > 0: + msg = f"Current Project Wallet {project_wallet.wallet_id} is in DEBT" + raise ValueError(msg) + # ensure the wallet can be used by the user - wallet: WalletGet = await wallet_api.get_wallet_by_user( + wallet: WalletGet = await wallets_api.get_wallet_by_user( app, user_id=user_id, wallet_id=wallet_id, product_name=product_name, ) + # Allow changing the wallet only if there are no pending transactions within the project. + # TODO: MATUS: check pending transactions + await db.connect_wallet_to_project(project_uuid=project_id, wallet_id=wallet_id) return wallet + + +async def pay_debt_with_different_wallet( + app, + *, + product_name: ProductName, + project_id: ProjectID, + user_id: UserID, + current_wallet_id: WalletID, + new_wallet_id: WalletID, + debt_amount: Decimal, +) -> None: + """ + Handles the repayment of a project's debt using a different wallet. + + Example scenario: + - A project has a debt of -100 credits. + - Wallet A is the current wallet connected to the project and has -200 credits. + - The user wants to pay the project's debt using Wallet B, which has 500 credits. + + Parameters: + - current_wallet_id: ID of Wallet A (the wallet currently linked to the project). + - new_wallet_id: ID of Wallet B (the wallet the user wants to use to pay the debt). + - debt_amount: The amount to be transferred (e.g., 100 credits). + + Process: + 1. Transfer the specified debt amount (100 credits) from Wallet B to Wallet A. + 2. Update the project's debt status (e.g., unblock the project). + + Outcome: + The project's debt is paid, Wallet A is credited, and Wallet B is debited. + """ + + assert current_wallet_id != new_wallet_id # nosec + + # ensure the wallets can be used by the user + new_wallet: WalletGet = await wallets_api.get_wallet_by_user( + app, + user_id=user_id, + wallet_id=new_wallet_id, + product_name=product_name, + ) + current_wallet: WalletGet = await wallets_api.get_wallet_by_user( + app, + user_id=user_id, + wallet_id=current_wallet_id, + product_name=product_name, + ) + + user = await users_api.get_user(app, user_id=user_id) + + # Transfer credits from the source wallet to the connected wallet + rpc_client = get_rabbitmq_rpc_client(app) + _created_at = datetime.now(tz=UTC) + + new_wallet_transaction = CreditTransactionCreateBody( + product_name=product_name, + wallet_id=new_wallet_id, + wallet_name=new_wallet.name, + user_id=user_id, + user_email=user["email"], + osparc_credits=-debt_amount, # <-- Negative number + payment_transaction_id=f"Payment transaction from wallet {current_wallet_id} to wallet {new_wallet_id}", + created_at=_created_at, + ) + + current_wallet_transaction = CreditTransactionCreateBody( + product_name=product_name, + wallet_id=current_wallet_id, + wallet_name=current_wallet.name, + user_id=user_id, + user_email=user["email"], + osparc_credits=debt_amount, # <-- Positive number + payment_transaction_id=f"Payment transaction from wallet {new_wallet_id} to wallet {current_wallet_id}", + created_at=_created_at, + ) + + await credit_transactions.pay_project_debt( + rpc_client, + project_id=project_id, + current_wallet_transaction=current_wallet_transaction, + new_wallet_transaction=new_wallet_transaction, + ) diff --git a/services/web/server/src/simcore_service_webserver/projects/_wallets_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_wallets_handlers.py index 56e7136d299..d1777cde453 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_wallets_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_wallets_handlers.py @@ -4,13 +4,18 @@ import functools import logging +from decimal import Decimal from aiohttp import web from models_library.api_schemas_webserver.wallets import WalletGet from models_library.projects import ProjectID from models_library.wallets import WalletID from pydantic import BaseModel, ConfigDict -from servicelib.aiohttp.requests_validation import parse_request_path_parameters_as +from servicelib.aiohttp import status +from servicelib.aiohttp.requests_validation import ( + parse_request_body_as, + parse_request_path_parameters_as, +) from servicelib.aiohttp.typing_extension import Handler from simcore_service_webserver.utils_aiohttp import envelope_json_response @@ -90,6 +95,7 @@ async def connect_wallet_to_project(request: web.Request): user_id=req_ctx.user_id, include_state=False, ) + wallet: WalletGet = await wallets_api.connect_wallet_to_project( request.app, product_name=req_ctx.product_name, @@ -99,3 +105,64 @@ async def connect_wallet_to_project(request: web.Request): ) return envelope_json_response(wallet) + + +class _PayProjectDebtBody(BaseModel): + amount: Decimal + model_config = ConfigDict(extra="forbid") + + +@routes.post( + f"/{API_VTAG}/projects/{{project_id}}/wallet/{{wallet_id}}:pay-debt", + name="pay_project_debt", +) +@login_required +@permission_required("project.wallet.*") +@_handle_project_wallet_exceptions +async def pay_project_debt(request: web.Request): + req_ctx = RequestContext.model_validate(request) + path_params = parse_request_path_parameters_as(_ProjectWalletPathParams, request) + body_params = await parse_request_body_as(_PayProjectDebtBody, request) + + # Ensure the project exists + await projects_api.get_project_for_user( + request.app, + project_uuid=f"{path_params.project_id}", + user_id=req_ctx.user_id, + include_state=False, + ) + + # Ensure the wallet is associated with the project + wallet: WalletGet | None = await wallets_api.get_project_wallet( + request.app, path_params.project_id + ) + if not wallet: + raise web.HTTPNotFound(reason="Wallet not associated with the project") + + if wallet.wallet_id == path_params.wallet_id: + # NOTE: Currently, this option is not supported. The only way a user can + # access their project with the same wallet is by topping it up to achieve + # a positive balance. (This could potentially be improved in the future; + # for example, we might allow users to top up credits specifically for the + # debt of a particular project, which would unblock access to that project.) + # At present, once the wallet balance becomes positive, RUT updates all + # projects connected to that wallet from IN_DEBT to BILLED. + + web.json_response(status=status.HTTP_501_NOT_IMPLEMENTED) + else: + # The debt is being paid using a different wallet than the one currently connected to the project. + # Steps: + # 1. Transfer the required credits from the specified wallet to the connected wallet. + # 2. Mark the transaction as billed (This will allow to ) + + await wallets_api.pay_debt_with_different_wallet( + app=request.app, + product_name=req_ctx.product_name, + project_id=path_params.project_id, + user_id=req_ctx.user_id, + current_wallet_id=wallet.wallet_id, + new_wallet_id=path_params.wallet_id, + debt_amount=body_params.amount, + ) + + return envelope_json_response(payment_result) From e18149e824a24e6e64d07cedd7ab644c0932650e Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Tue, 14 Jan 2025 14:01:23 +0100 Subject: [PATCH 02/17] openapi specs --- api/specs/web-server/_projects_wallet.py | 21 +++++++- .../credit_transactions.py | 4 +- services/resource-usage-tracker/openapi.json | 44 ++++++++++++++++ .../api/rpc/_credit_transactions.py | 1 + .../core/application.py | 2 + .../services/credit_transactions.py | 38 ++++++++------ .../services/fire_and_forget_setup.py | 41 +++++++++++++++ .../api/v0/openapi.yaml | 33 ++++++++++++ .../projects/_wallets_handlers.py | 50 ++++++++++--------- 9 files changed, 192 insertions(+), 42 deletions(-) create mode 100644 services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/fire_and_forget_setup.py diff --git a/api/specs/web-server/_projects_wallet.py b/api/specs/web-server/_projects_wallet.py index c9502393b97..0f22d25f097 100644 --- a/api/specs/web-server/_projects_wallet.py +++ b/api/specs/web-server/_projects_wallet.py @@ -8,15 +8,20 @@ # pylint: disable=unused-variable # pylint: disable=too-many-arguments +from typing import Annotated from _common import assert_handler_signature_against_model -from fastapi import APIRouter +from fastapi import APIRouter, Depends, status from models_library.api_schemas_webserver.wallets import WalletGet from models_library.generics import Envelope from models_library.projects import ProjectID from models_library.wallets import WalletID from simcore_service_webserver._meta import API_VTAG from simcore_service_webserver.projects._common.models import ProjectPathParams +from simcore_service_webserver.projects._wallets_handlers import ( + _PayProjectDebtBody, + _ProjectWalletPathParams, +) router = APIRouter( prefix=f"/{API_VTAG}", @@ -51,3 +56,17 @@ async def connect_wallet_to_project( assert_handler_signature_against_model(connect_wallet_to_project, ProjectPathParams) + + +@router.post( + "/projects/{project_id}/wallet/{wallet_id}:pay-debt", + status_code=status.HTTP_204_NO_CONTENT, +) +async def pay_project_debt( + _path: Annotated[_ProjectWalletPathParams, Depends()], + _body: Annotated[_PayProjectDebtBody, Depends()], +): + ... + + +assert_handler_signature_against_model(connect_wallet_to_project, ProjectPathParams) diff --git a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/credit_transactions.py b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/credit_transactions.py index 1af232a7c32..10561651177 100644 --- a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/credit_transactions.py +++ b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/credit_transactions.py @@ -56,7 +56,7 @@ async def pay_project_debt( current_wallet_transaction: CreditTransactionCreateBody, new_wallet_transaction: CreditTransactionCreateBody, ) -> None: - result = await rabbitmq_rpc_client.request( + await rabbitmq_rpc_client.request( RESOURCE_USAGE_TRACKER_RPC_NAMESPACE, _RPC_METHOD_NAME_ADAPTER.validate_python("pay_project_debt"), project_id=project_id, @@ -64,5 +64,3 @@ async def pay_project_debt( new_wallet_transaction=new_wallet_transaction, timeout_s=_DEFAULT_TIMEOUT_S, ) - assert isinstance(result, None) # nosec - return result diff --git a/services/resource-usage-tracker/openapi.json b/services/resource-usage-tracker/openapi.json index b267c3f0a9e..af7e065a385 100644 --- a/services/resource-usage-tracker/openapi.json +++ b/services/resource-usage-tracker/openapi.json @@ -72,6 +72,39 @@ "title": "Wallet Id", "minimum": 0 } + }, + { + "name": "transaction_status", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/CreditTransactionStatus" + }, + { + "type": "null" + } + ], + "title": "Transaction Status" + } + }, + { + "name": "project_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Project Id" + } } ], "responses": { @@ -346,6 +379,17 @@ "title": "CreditTransactionCreated", "description": "Response Create Credit Transaction V1 Credit Transactions Post" }, + "CreditTransactionStatus": { + "type": "string", + "enum": [ + "PENDING", + "BILLED", + "IN_DEBT", + "NOT_BILLED", + "REQUIRES_MANUAL_REVIEW" + ], + "title": "CreditTransactionStatus" + }, "HTTPValidationError": { "properties": { "detail": { diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_credit_transactions.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_credit_transactions.py index 6304a388fbc..57c2b87912e 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_credit_transactions.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_credit_transactions.py @@ -44,6 +44,7 @@ async def pay_project_debt( return await credit_transactions.pay_project_debt( db_engine=app.state.engine, rabbitmq_client=app.state.rabbitmq_client, + rut_fire_and_forget_tasks=app.state.rut_fire_and_forget_tasks, project_id=project_id, current_wallet_transaction=current_wallet_transaction, new_wallet_transaction=new_wallet_transaction, diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/core/application.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/core/application.py index 143079f9bba..7299703a6bd 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/core/application.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/core/application.py @@ -18,6 +18,7 @@ from ..services.background_task_periodic_heartbeat_check_setup import ( setup as setup_background_task_periodic_heartbeat_check, ) +from ..services.fire_and_forget_setup import setup as fire_and_forget_setup from ..services.modules.db import setup as setup_db from ..services.modules.rabbitmq import setup as setup_rabbitmq from ..services.modules.redis import setup as setup_redis @@ -50,6 +51,7 @@ def create_app(settings: ApplicationSettings) -> FastAPI: # PLUGINS SETUP setup_api_routes(app) + fire_and_forget_setup(app) if settings.RESOURCE_USAGE_TRACKER_POSTGRES: setup_db(app) diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/credit_transactions.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/credit_transactions.py index f2eb899c447..ebaf4708859 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/credit_transactions.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/credit_transactions.py @@ -14,6 +14,7 @@ ) from models_library.wallets import WalletID from servicelib.rabbitmq import RabbitMQClient +from servicelib.utils import fire_and_forget_task from simcore_postgres_database.utils_repos import transaction_context from sqlalchemy.ext.asyncio import AsyncEngine @@ -92,15 +93,14 @@ async def sum_credit_transactions_by_product_and_wallet( async def pay_project_debt( - db_engine: Annotated[AsyncEngine, Depends(get_resource_tracker_db_engine)], - rabbitmq_client: Annotated[ - RabbitMQClient, Depends(get_rabbitmq_client_from_request) - ], + db_engine: AsyncEngine, + rabbitmq_client: RabbitMQClient, + rut_fire_and_forget_tasks: set, project_id: ProjectID, current_wallet_transaction: CreditTransactionCreateBody, new_wallet_transaction: CreditTransactionCreateBody, ): - # `current_wallet_transaction` is Wallet in DEBT + # NOTE: `current_wallet_transaction` is the Wallet in DEBT total_project_debt_amount = ( await credit_transactions_db.sum_credit_transactions_by_product_and_wallet( @@ -177,15 +177,23 @@ async def pay_project_debt( transaction_status=CreditTransactionStatus.BILLED, ) - await sum_credit_transactions_and_publish_to_rabbitmq( - db_engine, - rabbitmq_client, - new_wallet_transaction_create.product_name, - new_wallet_transaction_create.wallet_id, + fire_and_forget_task( + sum_credit_transactions_and_publish_to_rabbitmq( + db_engine, + rabbitmq_client, + new_wallet_transaction_create.product_name, + new_wallet_transaction_create.wallet_id, + ), + task_suffix_name=f"sum_and_publish_credits_wallet_id{new_wallet_transaction_create.wallet_id}", + fire_and_forget_tasks_collection=rut_fire_and_forget_tasks, ) - await sum_credit_transactions_and_publish_to_rabbitmq( - db_engine, - rabbitmq_client, - current_wallet_transaction_create.product_name, - current_wallet_transaction_create.wallet_id, + fire_and_forget_task( + sum_credit_transactions_and_publish_to_rabbitmq( + db_engine, + rabbitmq_client, + current_wallet_transaction_create.product_name, + current_wallet_transaction_create.wallet_id, + ), + task_suffix_name=f"sum_and_publish_credits_wallet_id{current_wallet_transaction_create.wallet_id}", + fire_and_forget_tasks_collection=rut_fire_and_forget_tasks, ) diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/fire_and_forget_setup.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/fire_and_forget_setup.py new file mode 100644 index 00000000000..bb2d7ae4d37 --- /dev/null +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/fire_and_forget_setup.py @@ -0,0 +1,41 @@ +import logging +from collections.abc import Awaitable, Callable + +from fastapi import FastAPI +from servicelib.logging_utils import log_catch, log_context + +_logger = logging.getLogger(__name__) + + +def _on_app_startup(_app: FastAPI) -> Callable[[], Awaitable[None]]: + async def _startup() -> None: + with log_context( + _logger, + logging.INFO, + msg="Resource Usage Tracker setup fire and forget tasks..", + ), log_catch(_logger, reraise=False): + _app.state.rut_fire_and_forget_tasks = set() + + return _startup + + +def _on_app_shutdown( + _app: FastAPI, +) -> Callable[[], Awaitable[None]]: + async def _stop() -> None: + with log_context( + _logger, + logging.INFO, + msg="Resource Usage Tracker fire and forget tasks shutdown..", + ), log_catch(_logger, reraise=False): + assert _app # nosec + if _app.state.rut_fire_and_forget_tasks: + for task in _app.state.rut_fire_and_forget_tasks: + task.cancel() + + return _stop + + +def setup(app: FastAPI) -> None: + app.add_event_handler("startup", _on_app_startup(app)) + app.add_event_handler("shutdown", _on_app_shutdown(app)) diff --git a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml index 70e29c830d5..c7a33c6fa02 100644 --- a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml +++ b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml @@ -5153,6 +5153,39 @@ paths: application/json: schema: $ref: '#/components/schemas/Envelope_WalletGet_' + /v0/projects/{project_id}/wallet/{wallet_id}:pay-debt: + post: + tags: + - projects + summary: Pay Project Debt + operationId: pay_project_debt + parameters: + - name: project_id + in: path + required: true + schema: + type: string + format: uuid + title: Project Id + - name: wallet_id + in: path + required: true + schema: + type: integer + exclusiveMinimum: true + title: Wallet Id + minimum: 0 + - name: amount + in: query + required: true + schema: + anyOf: + - type: number + - type: string + title: Amount + responses: + '204': + description: Successful Response /v0/projects/{project_id}/workspaces/{workspace_id}:move: post: tags: diff --git a/services/web/server/src/simcore_service_webserver/projects/_wallets_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_wallets_handlers.py index 9b080bcba40..e3f3a6bb58d 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_wallets_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_wallets_handlers.py @@ -22,7 +22,7 @@ from .._meta import API_VTAG from ..login.decorators import login_required from ..security.decorators import permission_required -from ..wallets.errors import WalletAccessForbiddenError +from ..wallets.errors import WalletAccessForbiddenError, WalletNotFoundError from . import _wallets_api as wallets_api from . import projects_api from ._common.models import ProjectPathParams, RequestContext @@ -40,6 +40,9 @@ async def wrapper(request: web.Request) -> web.StreamResponse: except ProjectNotFoundError as exc: raise web.HTTPNotFound(reason=f"{exc}") from exc + except WalletNotFoundError as exc: + raise web.HTTPNotFound(reason=f"{exc}") from exc + except (WalletAccessForbiddenError, ProjectInvalidRightsError) as exc: raise web.HTTPForbidden(reason=f"{exc}") from exc @@ -132,14 +135,17 @@ async def pay_project_debt(request: web.Request): include_state=False, ) - # Ensure the wallet is associated with the project - wallet: WalletGet | None = await wallets_api.get_project_wallet( + # Get curently associated wallet with the project + current_wallet: WalletGet | None = await wallets_api.get_project_wallet( request.app, path_params.project_id ) - if not wallet: - raise web.HTTPNotFound(reason="Wallet not associated with the project") + if not current_wallet: + _logger.warning("This should not happen?") + raise web.HTTPNotFound( + reason="Project doesn't have any wallet associated to the project" + ) - if wallet.wallet_id == path_params.wallet_id: + if current_wallet.wallet_id == path_params.wallet_id: # NOTE: Currently, this option is not supported. The only way a user can # access their project with the same wallet is by topping it up to achieve # a positive balance. (This could potentially be improved in the future; @@ -148,21 +154,19 @@ async def pay_project_debt(request: web.Request): # At present, once the wallet balance becomes positive, RUT updates all # projects connected to that wallet from IN_DEBT to BILLED. - web.json_response(status=status.HTTP_501_NOT_IMPLEMENTED) - else: - # The debt is being paid using a different wallet than the one currently connected to the project. - # Steps: - # 1. Transfer the required credits from the specified wallet to the connected wallet. - # 2. Mark the transaction as billed (This will allow to ) - - await wallets_api.pay_debt_with_different_wallet( - app=request.app, - product_name=req_ctx.product_name, - project_id=path_params.project_id, - user_id=req_ctx.user_id, - current_wallet_id=wallet.wallet_id, - new_wallet_id=path_params.wallet_id, - debt_amount=body_params.amount, - ) + return web.json_response(status=status.HTTP_501_NOT_IMPLEMENTED) - return envelope_json_response(payment_result) + # The debt is being paid using a different wallet than the one currently connected to the project. + # Steps: + # 1. Transfer the required credits from the specified wallet to the connected wallet. + # 2. Mark the project transactions as billed + await wallets_api.pay_debt_with_different_wallet( + app=request.app, + product_name=req_ctx.product_name, + project_id=path_params.project_id, + user_id=req_ctx.user_id, + current_wallet_id=path_params.wallet_id, + new_wallet_id=path_params.wallet_id, + debt_amount=body_params.amount, + ) + return envelope_json_response(web.json_response(status=status.HTTP_204_NO_CONTENT)) From 53a31cba91a2bd750d46c9dd13b60bd61b710871 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Tue, 14 Jan 2025 14:43:22 +0100 Subject: [PATCH 03/17] improbemtns --- .../src/models_library/resource_tracker.py | 20 +++++++- .../resource_tracker_credit_transactions.py | 3 ++ .../resource_usage_tracker/service_runs.py | 20 +++++--- .../api/rpc/_service_runs.py | 10 +++- .../services/credit_transactions.py | 6 ++- .../services/modules/db/service_runs_db.py | 12 +++++ .../services/service_runs.py | 12 ++++- .../projects/_wallets_api.py | 48 ++++++++++++------- .../projects/_wallets_handlers.py | 1 - 9 files changed, 102 insertions(+), 30 deletions(-) diff --git a/packages/models-library/src/models_library/resource_tracker.py b/packages/models-library/src/models_library/resource_tracker.py index 3e56664d77e..953cb9b722d 100644 --- a/packages/models-library/src/models_library/resource_tracker.py +++ b/packages/models-library/src/models_library/resource_tracker.py @@ -61,11 +61,27 @@ class CreditTransactionStatus(StrAutoEnum): class CreditClassification(StrAutoEnum): - ADD_WALLET_TOP_UP = auto() # user top up credits - DEDUCT_SERVICE_RUN = auto() # computational/dynamic service run costs) + # Represents the different types of credit classifications. + + ADD_WALLET_TOP_UP = auto() + # Indicates that credits have been added to the user's wallet through a top-up. + # Example: The user adds funds to their wallet to increase their available credits. + + DEDUCT_SERVICE_RUN = auto() + # Represents a deduction from the user's wallet due to the costs of running a computational or dynamic service. + # Example: Credits are deducted when the user runs a simulation. + DEDUCT_LICENSE_PURCHASE = auto() + # Represents a deduction from the user's wallet for purchasing a license. + # Example: The user purchases a license to access premium features such as VIP models. + ADD_WALLET_EXCHANGE = auto() + # Represents the addition of credits to the user's wallet through an exchange. + # Example: Credits are added due to credit exchange between wallets. + DEDUCT_WALLET_EXCHANGE = auto() + # Represents a deduction of credits from the user's wallet through an exchange. + # Example: Credits are deducted due to credit exchange between wallets. class PricingPlanClassification(StrAutoEnum): diff --git a/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_credit_transactions.py b/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_credit_transactions.py index ca4cc470b5f..70a3e1ed1ac 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_credit_transactions.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_credit_transactions.py @@ -18,6 +18,7 @@ class CreditTransactionStatus(str, enum.Enum): PENDING = "PENDING" BILLED = "BILLED" + IN_DEBT = "IN_DEBT" NOT_BILLED = "NOT_BILLED" REQUIRES_MANUAL_REVIEW = "REQUIRES_MANUAL_REVIEW" @@ -28,6 +29,8 @@ class CreditTransactionClassification(str, enum.Enum): "DEDUCT_SERVICE_RUN" # computational/dynamic service run costs) ) DEDUCT_LICENSE_PURCHASE = "DEDUCT_LICENSE_PURCHASE" + ADD_WALLET_EXCHANGE = "ADD_WALLET_EXCHANGE" + DEDUCT_WALLET_EXCHANGE = "DEDUCT_WALLET_EXCHANGE" resource_tracker_credit_transactions = sa.Table( diff --git a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/service_runs.py b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/service_runs.py index 9d4bd57204c..97c8ed37e8b 100644 --- a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/service_runs.py +++ b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/service_runs.py @@ -9,8 +9,10 @@ ServiceRunPage, ) from models_library.products import ProductName +from models_library.projects import ProjectID from models_library.rabbitmq_basic_types import RPCMethodName from models_library.resource_tracker import ( + CreditTransactionStatus, ServiceResourceUsagesFilters, ServicesAggregatedUsagesTimePeriod, ServicesAggregatedUsagesType, @@ -37,24 +39,30 @@ async def get_service_run_page( *, user_id: UserID, product_name: ProductName, - limit: int = 20, - offset: int = 0, wallet_id: WalletID | None = None, access_all_wallet_usage: bool = False, - order_by: OrderBy | None = None, filters: ServiceResourceUsagesFilters | None = None, + transaction_status: CreditTransactionStatus | None = None, + project_id: ProjectID | None = None, + # pagination + offset: int = 0, + limit: int = 20, + # ordering + order_by: OrderBy | None = None, ) -> ServiceRunPage: result = await rabbitmq_rpc_client.request( RESOURCE_USAGE_TRACKER_RPC_NAMESPACE, _RPC_METHOD_NAME_ADAPTER.validate_python("get_service_run_page"), user_id=user_id, product_name=product_name, - limit=limit, - offset=offset, wallet_id=wallet_id, access_all_wallet_usage=access_all_wallet_usage, - order_by=order_by, filters=filters, + transaction_status=transaction_status, + project_id=project_id, + offset=offset, + limit=limit, + order_by=order_by, timeout_s=_DEFAULT_TIMEOUT_S, ) assert isinstance(result, ServiceRunPage) # nosec diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_service_runs.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_service_runs.py index ffb38284397..725de2f9deb 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_service_runs.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_service_runs.py @@ -4,7 +4,9 @@ ServiceRunPage, ) from models_library.products import ProductName +from models_library.projects import ProjectID from models_library.resource_tracker import ( + CreditTransactionStatus, ServiceResourceUsagesFilters, ServicesAggregatedUsagesTimePeriod, ServicesAggregatedUsagesType, @@ -34,9 +36,11 @@ async def get_service_run_page( wallet_id: WalletID | None = None, access_all_wallet_usage: bool = False, filters: ServiceResourceUsagesFilters | None = None, + transaction_status: CreditTransactionStatus | None = None, + project_id: ProjectID | None = None, # pagination - limit: int = 20, offset: int = 0, + limit: int = 20, # ordering order_by: OrderBy | None = None, ) -> ServiceRunPage: @@ -47,8 +51,10 @@ async def get_service_run_page( wallet_id=wallet_id, access_all_wallet_usage=access_all_wallet_usage, filters=filters, - limit=limit, + transaction_status=transaction_status, + project_id=project_id, offset=offset, + limit=limit, order_by=order_by, ) diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/credit_transactions.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/credit_transactions.py index ebaf4708859..0c887eeb49d 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/credit_transactions.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/credit_transactions.py @@ -116,12 +116,14 @@ async def pay_project_debt( total_project_debt_amount.available_osparc_credits != new_wallet_transaction.osparc_credits ): - raise ValueError("wrong") + msg = f"Project DEBT of {total_project_debt_amount.available_osparc_credits} does not equal to payment: new_wallet {new_wallet_transaction.osparc_credits}, current wallet {current_wallet_transaction.osparc_credits}" + raise ValueError(msg) if ( make_negative(total_project_debt_amount.available_osparc_credits) != current_wallet_transaction.osparc_credits ): - raise ValueError("wrong") + msg = f"Project DEBT of {total_project_debt_amount.available_osparc_credits} does not equal to payment: new_wallet {new_wallet_transaction.osparc_credits}, current wallet {current_wallet_transaction.osparc_credits}" + raise ValueError(msg) new_wallet_transaction_create = CreditTransactionCreate( product_name=new_wallet_transaction.product_name, diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/service_runs_db.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/service_runs_db.py index d20b7673ddd..3fe7c4b4288 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/service_runs_db.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/service_runs_db.py @@ -6,6 +6,7 @@ import sqlalchemy as sa from models_library.api_schemas_storage import S3BucketName from models_library.products import ProductName +from models_library.projects import ProjectID from models_library.resource_tracker import ( CreditClassification, CreditTransactionStatus, @@ -197,6 +198,8 @@ async def list_service_runs_by_product_and_user_and_wallet( service_run_status: ServiceRunStatus | None = None, started_from: datetime | None = None, started_until: datetime | None = None, + transaction_status: CreditTransactionStatus | None = None, + project_id: ProjectID | None = None, # pagination offset: int, limit: int, @@ -287,6 +290,15 @@ async def list_service_runs_by_product_and_user_and_wallet( sa.func.DATE(resource_tracker_service_runs.c.started_at) <= started_until.date() ) + if project_id: + base_query = base_query.where( + resource_tracker_service_runs.c.project_id == project_id + ) + if transaction_status: + base_query = base_query.where( + resource_tracker_credit_transactions.c.transaction_status + == transaction_status + ) # Select total count from base_query subquery = base_query.subquery() diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/service_runs.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/service_runs.py index 8f441be78a1..fab7c43de52 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/service_runs.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/service_runs.py @@ -10,7 +10,9 @@ ) from models_library.api_schemas_storage import S3BucketName from models_library.products import ProductName +from models_library.projects import ProjectID from models_library.resource_tracker import ( + CreditTransactionStatus, ServiceResourceUsagesFilters, ServicesAggregatedUsagesTimePeriod, ServicesAggregatedUsagesType, @@ -34,8 +36,10 @@ async def list_service_runs( wallet_id: WalletID | None = None, access_all_wallet_usage: bool = False, filters: ServiceResourceUsagesFilters | None = None, - limit: int = 20, + transaction_status: CreditTransactionStatus | None = None, + project_id: ProjectID | None = None, offset: int = 0, + limit: int = 20, order_by: OrderBy | None = None, ) -> ServiceRunPage: started_from = None @@ -56,6 +60,8 @@ async def list_service_runs( wallet_id=None, started_from=started_from, started_until=started_until, + transaction_status=transaction_status, + project_id=project_id, offset=offset, limit=limit, order_by=order_by, @@ -72,6 +78,8 @@ async def list_service_runs( wallet_id=wallet_id, started_from=started_from, started_until=started_until, + transaction_status=transaction_status, + project_id=project_id, offset=offset, limit=limit, order_by=order_by, @@ -88,6 +96,8 @@ async def list_service_runs( wallet_id=wallet_id, started_from=started_from, started_until=started_until, + transaction_status=transaction_status, + project_id=project_id, offset=offset, limit=limit, order_by=order_by, diff --git a/services/web/server/src/simcore_service_webserver/projects/_wallets_api.py b/services/web/server/src/simcore_service_webserver/projects/_wallets_api.py index b7f26bf3fcb..d8366f62c32 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_wallets_api.py +++ b/services/web/server/src/simcore_service_webserver/projects/_wallets_api.py @@ -12,6 +12,7 @@ from models_library.wallets import WalletDB, WalletID from servicelib.rabbitmq.rpc_interfaces.resource_usage_tracker import ( credit_transactions, + service_runs, ) from ..rabbitmq import get_rabbitmq_rpc_client @@ -40,32 +41,47 @@ async def connect_wallet_to_project( ) -> WalletGet: db: ProjectDBAPI = ProjectDBAPI.get_from_app_context(app) - project_wallet = await db.get_project_wallet(project_uuid=project_id) + # ensure the wallet can be used by the user + wallet: WalletGet = await wallets_api.get_wallet_by_user( + app, + user_id=user_id, + wallet_id=wallet_id, + product_name=product_name, + ) + + current_project_wallet = await db.get_project_wallet(project_uuid=project_id) + rpc_client = get_rabbitmq_rpc_client(app) - if project_wallet: - # NOTE: Do not allow to change wallet if the project is in DEBT! - rpc_client = get_rabbitmq_rpc_client(app) + if current_project_wallet: + # Do not allow to change wallet if the project connected wallet is in DEBT! project_wallet_credits = await credit_transactions.get_wallet_total_credits( rpc_client, product_name=product_name, - wallet_id=project_wallet.wallet_id, + wallet_id=current_project_wallet.wallet_id, project_id=project_id, transaction_status=CreditTransactionStatus.IN_DEBT, ) if project_wallet_credits.available_osparc_credits > 0: - msg = f"Current Project Wallet {project_wallet.wallet_id} is in DEBT" + msg = ( + f"Current Project Wallet {current_project_wallet.wallet_id} is in DEBT" + ) raise ValueError(msg) - # ensure the wallet can be used by the user - wallet: WalletGet = await wallets_api.get_wallet_by_user( - app, - user_id=user_id, - wallet_id=wallet_id, - product_name=product_name, - ) - - # Allow changing the wallet only if there are no pending transactions within the project. - # TODO: MATUS: check pending transactions + # Do not allow to change wallet if the project has transaction in PENDING! + project_service_runs_in_progress = await service_runs.get_service_run_page( + rpc_client, + user_id=user_id, + product_name=product_name, + wallet_id=wallet_id, + access_all_wallet_usage=True, + transaction_status=CreditTransactionStatus.PENDING, + project_id=project_id, + offset=0, + limit=1, + ) + if project_service_runs_in_progress.total > 0: + msg = "Can not change the wallet, as project has currently pending transaction" + raise ValueError(msg) await db.connect_wallet_to_project(project_uuid=project_id, wallet_id=wallet_id) return wallet diff --git a/services/web/server/src/simcore_service_webserver/projects/_wallets_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_wallets_handlers.py index e3f3a6bb58d..f5e4faab7a4 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_wallets_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_wallets_handlers.py @@ -140,7 +140,6 @@ async def pay_project_debt(request: web.Request): request.app, path_params.project_id ) if not current_wallet: - _logger.warning("This should not happen?") raise web.HTTPNotFound( reason="Project doesn't have any wallet associated to the project" ) From bec976b77307f64822abea5a875fc88a96218908 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Tue, 14 Jan 2025 15:37:13 +0100 Subject: [PATCH 04/17] DB migration --- ..._add_credit_transaction_classification_.py | 35 +++++++++++++++++ .../modules/db/credit_transactions_db.py | 38 +++++++++---------- 2 files changed, 54 insertions(+), 19 deletions(-) create mode 100644 packages/postgres-database/src/simcore_postgres_database/migration/versions/a3a58471b0f1_add_credit_transaction_classification_.py diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/a3a58471b0f1_add_credit_transaction_classification_.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/a3a58471b0f1_add_credit_transaction_classification_.py new file mode 100644 index 00000000000..9bdd1c6ee2d --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/a3a58471b0f1_add_credit_transaction_classification_.py @@ -0,0 +1,35 @@ +"""add credit transaction classification enums + +Revision ID: a3a58471b0f1 +Revises: 307017ee1a49 +Create Date: 2025-01-14 13:44:05.025647+00:00 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "a3a58471b0f1" +down_revision = "307017ee1a49" +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute(sa.DDL("ALTER TYPE credittransactionstatus ADD VALUE 'IN_DEBT'")) + op.execute( + sa.DDL( + "ALTER TYPE credittransactionclassification ADD VALUE 'ADD_WALLET_EXCHANGE'" + ) + ) + op.execute( + sa.DDL( + "ALTER TYPE credittransactionclassification ADD VALUE 'DEDUCT_WALLET_EXCHANGE'" + ) + ) + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/credit_transactions_db.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/credit_transactions_db.py index ec3f5510182..3c52c2f72da 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/credit_transactions_db.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/credit_transactions_db.py @@ -141,28 +141,28 @@ async def batch_update_credit_transaction_status_for_in_debt_transactions( wallet_id: WalletID, transaction_status: CreditTransactionStatus, ) -> None: - async with transaction_context(engine, connection) as conn: - update_stmt = ( - resource_tracker_credit_transactions.update() - .values( - modified=sa.func.now(), - transaction_status=transaction_status, - ) - .where( - (resource_tracker_credit_transactions.c.wallet_id == f"{wallet_id}") - & ( - resource_tracker_credit_transactions.c.transaction_status - == CreditTransactionStatus.IN_DEBT - ) - ) + update_stmt = ( + resource_tracker_credit_transactions.update() + .values( + modified=sa.func.now(), + transaction_status=transaction_status, ) - - if project_id: - update_stmt = update_stmt.where( - resource_tracker_credit_transactions.c.project_id == f"{project_id}" + .where( + (resource_tracker_credit_transactions.c.wallet_id == wallet_id) + & ( + resource_tracker_credit_transactions.c.transaction_status + == CreditTransactionStatus.IN_DEBT ) + ) + ) - await conn.execute(update_stmt) + if project_id: + update_stmt = update_stmt.where( + resource_tracker_credit_transactions.c.project_id == f"{project_id}" + ) + async with transaction_context(engine, connection) as conn: + result = await conn.execute(update_stmt) + print(result) async def sum_credit_transactions_by_product_and_wallet( From 948785641c600dda2a97d1ef2e8b8bec8436c7e6 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Tue, 14 Jan 2025 15:51:20 +0100 Subject: [PATCH 05/17] fix test --- .../services/process_message_running_service.py | 2 +- .../unit/with_dbs/test_process_rabbitmq_message_with_billing.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/process_message_running_service.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/process_message_running_service.py index 5c94657123b..be6145ca191 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/process_message_running_service.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/process_message_running_service.py @@ -302,7 +302,7 @@ async def _process_stop_event( ) _transaction_status = ( CreditTransactionStatus.BILLED - if wallet_total_credits.available_osparc_credits - computed_credits > 0 + if wallet_total_credits.available_osparc_credits - computed_credits >= 0 else CreditTransactionStatus.IN_DEBT ) # Adjust the status if the platform status is not OK diff --git a/services/resource-usage-tracker/tests/unit/with_dbs/test_process_rabbitmq_message_with_billing.py b/services/resource-usage-tracker/tests/unit/with_dbs/test_process_rabbitmq_message_with_billing.py index b29863f0b57..2ea005af907 100644 --- a/services/resource-usage-tracker/tests/unit/with_dbs/test_process_rabbitmq_message_with_billing.py +++ b/services/resource-usage-tracker/tests/unit/with_dbs/test_process_rabbitmq_message_with_billing.py @@ -240,7 +240,7 @@ async def test_process_event_functions( postgres_db, msg.service_run_id, modified_at ) assert output.osparc_credits < first_credits_used - assert output.transaction_status == "BILLED" + assert output.transaction_status == "IN_DEBT" async for attempt in AsyncRetrying( wait=wait_fixed(0.1), From 32a63e979e510bee76d5067db1c0993f92c1dfc2 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Fri, 17 Jan 2025 14:27:47 +0100 Subject: [PATCH 06/17] improvements and tests --- ..._add_credit_transaction_classification_.py | 4 +- .../credit_transactions.py | 24 +- .../api/rest/_resource_tracker.py | 2 +- .../api/rpc/_credit_transactions.py | 25 +- .../services/credit_transactions.py | 49 ++- .../modules/db/credit_transactions_db.py | 20 +- .../services/modules/db/service_runs_db.py | 71 +++- .../process_message_running_service.py | 10 +- .../services/service_runs.py | 21 + .../services/utils.py | 10 +- .../tests/unit/conftest.py | 2 + .../tests/unit/with_dbs/conftest.py | 4 +- .../with_dbs/test_api_credit_transactions.py | 361 +++++++++++++++++- .../projects/_nodes_handlers.py | 3 +- .../projects/_states_handlers.py | 7 + .../projects/_wallets_api.py | 69 +++- .../projects/_wallets_handlers.py | 21 +- .../simcore_service_webserver/projects/api.py | 7 +- .../projects/exceptions.py | 13 + 19 files changed, 632 insertions(+), 91 deletions(-) diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/a3a58471b0f1_add_credit_transaction_classification_.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/a3a58471b0f1_add_credit_transaction_classification_.py index 9bdd1c6ee2d..cef7f00e6bc 100644 --- a/packages/postgres-database/src/simcore_postgres_database/migration/versions/a3a58471b0f1_add_credit_transaction_classification_.py +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/a3a58471b0f1_add_credit_transaction_classification_.py @@ -1,7 +1,7 @@ """add credit transaction classification enums Revision ID: a3a58471b0f1 -Revises: 307017ee1a49 +Revises: f19905923355 Create Date: 2025-01-14 13:44:05.025647+00:00 """ @@ -10,7 +10,7 @@ # revision identifiers, used by Alembic. revision = "a3a58471b0f1" -down_revision = "307017ee1a49" +down_revision = "f19905923355" branch_labels = None depends_on = None diff --git a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/credit_transactions.py b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/credit_transactions.py index 10561651177..855f6c055dc 100644 --- a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/credit_transactions.py +++ b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/credit_transactions.py @@ -32,16 +32,34 @@ async def get_wallet_total_credits( *, product_name: ProductName, wallet_id: WalletID, - transaction_status: CreditTransactionStatus | None = None, - project_id: ProjectID | None = None, ) -> WalletTotalCredits: result = await rabbitmq_rpc_client.request( RESOURCE_USAGE_TRACKER_RPC_NAMESPACE, _RPC_METHOD_NAME_ADAPTER.validate_python("get_wallet_total_credits"), product_name=product_name, wallet_id=wallet_id, - transaction_status=transaction_status, + timeout_s=_DEFAULT_TIMEOUT_S, + ) + assert isinstance(result, WalletTotalCredits) # nosec + return result + + +@log_decorator(_logger, level=logging.DEBUG) +async def get_project_wallet_total_credits( + rabbitmq_rpc_client: RabbitMQRPCClient, + *, + product_name: ProductName, + wallet_id: WalletID, + project_id: ProjectID, + transaction_status: CreditTransactionStatus | None = None, +) -> WalletTotalCredits: + result = await rabbitmq_rpc_client.request( + RESOURCE_USAGE_TRACKER_RPC_NAMESPACE, + _RPC_METHOD_NAME_ADAPTER.validate_python("get_project_wallet_total_credits"), + product_name=product_name, + wallet_id=wallet_id, project_id=project_id, + transaction_status=transaction_status, timeout_s=_DEFAULT_TIMEOUT_S, ) assert isinstance(result, WalletTotalCredits) # nosec diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rest/_resource_tracker.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rest/_resource_tracker.py index fc20977e3aa..749e47f7938 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rest/_resource_tracker.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rest/_resource_tracker.py @@ -34,7 +34,7 @@ async def get_credit_transactions_sum( wallet_total_credits: Annotated[ WalletTotalCredits, - Depends(credit_transactions.sum_credit_transactions_by_product_and_wallet), + Depends(credit_transactions.sum_wallet_credits), ], ): return wallet_total_credits diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_credit_transactions.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_credit_transactions.py index 57c2b87912e..d06d07cfad7 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_credit_transactions.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_credit_transactions.py @@ -9,7 +9,7 @@ from models_library.wallets import WalletID from servicelib.rabbitmq import RPCRouter -from ...services import credit_transactions +from ...services import credit_transactions, service_runs router = RPCRouter() @@ -20,20 +20,33 @@ async def get_wallet_total_credits( *, product_name: ProductName, wallet_id: WalletID, - # internal filters +) -> WalletTotalCredits: + return await credit_transactions.sum_wallet_credits( + db_engine=app.state.engine, + product_name=product_name, + wallet_id=wallet_id, + ) + + +@router.expose(reraise_if_error_type=()) +async def get_project_wallet_total_credits( + app: FastAPI, + *, + product_name: ProductName, + wallet_id: WalletID, + project_id: ProjectID, transaction_status: CreditTransactionStatus | None = None, - project_id: ProjectID | None = None, ) -> WalletTotalCredits: - return await credit_transactions.sum_credit_transactions_by_product_and_wallet( + return await service_runs.sum_project_wallet_total_credits( db_engine=app.state.engine, product_name=product_name, wallet_id=wallet_id, - transaction_status=transaction_status, project_id=project_id, + transaction_status=transaction_status, ) -@router.expose(reraise_if_error_type=()) +@router.expose(reraise_if_error_type=(ValueError,)) async def pay_project_debt( app: FastAPI, *, diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/credit_transactions.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/credit_transactions.py index 0c887eeb49d..9bcf201ddae 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/credit_transactions.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/credit_transactions.py @@ -20,9 +20,10 @@ from ..api.rest.dependencies import get_resource_tracker_db_engine from ..models.credit_transactions import CreditTransactionCreate +from ..services.modules.db import service_runs_db from .modules.db import credit_transactions_db from .modules.rabbitmq import get_rabbitmq_client_from_request -from .utils import make_negative, sum_credit_transactions_and_publish_to_rabbitmq +from .utils import sum_credit_transactions_and_publish_to_rabbitmq async def create_credit_transaction( @@ -74,21 +75,16 @@ async def create_credit_transaction( return transaction_id -async def sum_credit_transactions_by_product_and_wallet( +async def sum_wallet_credits( db_engine: Annotated[AsyncEngine, Depends(get_resource_tracker_db_engine)], *, product_name: ProductName, wallet_id: WalletID, - # attribute filters - transaction_status: CreditTransactionStatus | None = None, - project_id: ProjectID | None = None, ) -> WalletTotalCredits: - return await credit_transactions_db.sum_credit_transactions_by_product_and_wallet( + return await credit_transactions_db.sum_wallet_credits( db_engine, product_name=product_name, wallet_id=wallet_id, - transaction_status=transaction_status, - project_id=project_id, ) @@ -102,27 +98,42 @@ async def pay_project_debt( ): # NOTE: `current_wallet_transaction` is the Wallet in DEBT - total_project_debt_amount = ( - await credit_transactions_db.sum_credit_transactions_by_product_and_wallet( - db_engine, - product_name=current_wallet_transaction.product_name, - wallet_id=current_wallet_transaction.wallet_id, - transaction_status=CreditTransactionStatus.IN_DEBT, - project_id=project_id, - ) + total_project_debt_amount = await service_runs_db.sum_project_wallet_total_credits( + db_engine, + product_name=current_wallet_transaction.product_name, + wallet_id=current_wallet_transaction.wallet_id, + project_id=project_id, + transaction_status=CreditTransactionStatus.IN_DEBT, ) if ( total_project_debt_amount.available_osparc_credits != new_wallet_transaction.osparc_credits ): - msg = f"Project DEBT of {total_project_debt_amount.available_osparc_credits} does not equal to payment: new_wallet {new_wallet_transaction.osparc_credits}, current wallet {current_wallet_transaction.osparc_credits}" + msg = f"Project DEBT of {total_project_debt_amount.available_osparc_credits} does not equal to payment: new_wallet {new_wallet_transaction.wallet_id} credits {new_wallet_transaction.osparc_credits}, current wallet {current_wallet_transaction.wallet_id} credits {current_wallet_transaction.osparc_credits}" raise ValueError(msg) if ( - make_negative(total_project_debt_amount.available_osparc_credits) + -total_project_debt_amount.available_osparc_credits != current_wallet_transaction.osparc_credits ): - msg = f"Project DEBT of {total_project_debt_amount.available_osparc_credits} does not equal to payment: new_wallet {new_wallet_transaction.osparc_credits}, current wallet {current_wallet_transaction.osparc_credits}" + msg = f"Project DEBT of {total_project_debt_amount.available_osparc_credits} does not equal to payment: new_wallet {new_wallet_transaction.wallet_id} credits {new_wallet_transaction.osparc_credits}, current wallet {current_wallet_transaction.wallet_id} credits {current_wallet_transaction.osparc_credits}" + raise ValueError(msg) + if current_wallet_transaction.product_name != new_wallet_transaction.product_name: + msg = f"Currently we do not support credit exchange between different products. New wallet {new_wallet_transaction.wallet_id}, current wallet {current_wallet_transaction.wallet_id}" + raise ValueError(msg) + + # Does the new wallet has enough credits to pay the debt? + new_wallet_total_credit_amount = await credit_transactions_db.sum_wallet_credits( + db_engine, + product_name=new_wallet_transaction.product_name, + wallet_id=new_wallet_transaction.wallet_id, + ) + if ( + new_wallet_total_credit_amount.available_osparc_credits + + total_project_debt_amount.available_osparc_credits + < 0 + ): + msg = f"New wallet {new_wallet_transaction.wallet_id} doesn't have enough credits {new_wallet_total_credit_amount.available_osparc_credits} to pay the debt {total_project_debt_amount.available_osparc_credits} of current wallet {current_wallet_transaction.wallet_id}" raise ValueError(msg) new_wallet_transaction_create = CreditTransactionCreate( diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/credit_transactions_db.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/credit_transactions_db.py index 3c52c2f72da..5a6574ce7ac 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/credit_transactions_db.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/credit_transactions_db.py @@ -13,6 +13,9 @@ from simcore_postgres_database.models.resource_tracker_credit_transactions import ( resource_tracker_credit_transactions, ) +from simcore_postgres_database.models.resource_tracker_service_runs import ( + resource_tracker_service_runs, +) from simcore_postgres_database.utils_repos import transaction_context from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine @@ -158,22 +161,19 @@ async def batch_update_credit_transaction_status_for_in_debt_transactions( if project_id: update_stmt = update_stmt.where( - resource_tracker_credit_transactions.c.project_id == f"{project_id}" + resource_tracker_service_runs.c.project_id == f"{project_id}" ) async with transaction_context(engine, connection) as conn: result = await conn.execute(update_stmt) print(result) -async def sum_credit_transactions_by_product_and_wallet( +async def sum_wallet_credits( engine: AsyncEngine, connection: AsyncConnection | None = None, *, product_name: ProductName, wallet_id: WalletID, - # attribute filters - transaction_status: CreditTransactionStatus | None = None, - project_id: ProjectID | None = None, ) -> WalletTotalCredits: async with transaction_context(engine, connection) as conn: sum_stmt = sa.select( @@ -192,16 +192,6 @@ async def sum_credit_transactions_by_product_and_wallet( ) ) - if project_id: - sum_stmt = sum_stmt.where( - resource_tracker_credit_transactions.c.project_id == f"{project_id}" - ) - if transaction_status: - sum_stmt = sum_stmt.where( - resource_tracker_credit_transactions.c.transaction_status - == transaction_status - ) - result = await conn.execute(sum_stmt) row = result.first() if row is None or row[0] is None: diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/service_runs_db.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/service_runs_db.py index 3fe7c4b4288..3e411f434f6 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/service_runs_db.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/service_runs_db.py @@ -1,9 +1,14 @@ -# pylint: disable=too-many-arguments import logging from datetime import datetime + +# pylint: disable=too-many-arguments +from decimal import Decimal from typing import cast import sqlalchemy as sa +from models_library.api_schemas_resource_usage_tracker.credit_transactions import ( + WalletTotalCredits, +) from models_library.api_schemas_storage import S3BucketName from models_library.products import ProductName from models_library.projects import ProjectID @@ -434,6 +439,70 @@ async def get_osparc_credits_aggregated_by_service( ) +async def sum_project_wallet_total_credits( + engine: AsyncEngine, + connection: AsyncConnection | None = None, + *, + product_name: ProductName, + wallet_id: WalletID, + project_id: ProjectID, + transaction_status: CreditTransactionStatus | None = None, +) -> WalletTotalCredits: + async with pass_or_acquire_connection(engine, connection) as conn: + sum_stmt = ( + sa.select( + sa.func.SUM(resource_tracker_credit_transactions.c.osparc_credits), + ) + .select_from( + resource_tracker_service_runs.join( + resource_tracker_credit_transactions, + ( + resource_tracker_service_runs.c.product_name + == resource_tracker_credit_transactions.c.product_name + ) + & ( + resource_tracker_service_runs.c.service_run_id + == resource_tracker_credit_transactions.c.service_run_id + ), + isouter=True, + ) + ) + .where( + (resource_tracker_service_runs.c.product_name == product_name) + & (resource_tracker_service_runs.c.project_id == f"{project_id}") + & ( + resource_tracker_credit_transactions.c.transaction_classification + == CreditClassification.DEDUCT_SERVICE_RUN + ) + & (resource_tracker_credit_transactions.c.wallet_id == wallet_id) + ) + ) + + if transaction_status: + sum_stmt = sum_stmt.where( + resource_tracker_credit_transactions.c.transaction_status + == transaction_status + ) + else: + sum_stmt = sum_stmt.where( + resource_tracker_credit_transactions.c.transaction_status.in_( + [ + CreditTransactionStatus.BILLED, + CreditTransactionStatus.PENDING, + CreditTransactionStatus.IN_DEBT, + ] + ) + ) + + result = await conn.execute(sum_stmt) + row = result.first() + if row is None or row[0] is None: + return WalletTotalCredits( + wallet_id=wallet_id, available_osparc_credits=Decimal(0) + ) + return WalletTotalCredits(wallet_id=wallet_id, available_osparc_credits=row[0]) + + async def export_service_runs_table_to_s3( engine: AsyncEngine, connection: AsyncConnection | None = None, diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/process_message_running_service.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/process_message_running_service.py index be6145ca191..87772933074 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/process_message_running_service.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/process_message_running_service.py @@ -293,12 +293,10 @@ async def _process_stop_event( running_service.pricing_unit_cost, ) - wallet_total_credits = ( - await credit_transactions_db.sum_credit_transactions_by_product_and_wallet( - db_engine, - product_name=running_service.product_name, - wallet_id=running_service.wallet_id, - ) + wallet_total_credits = await credit_transactions_db.sum_wallet_credits( + db_engine, + product_name=running_service.product_name, + wallet_id=running_service.wallet_id, ) _transaction_status = ( CreditTransactionStatus.BILLED diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/service_runs.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/service_runs.py index fab7c43de52..db9b8096f32 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/service_runs.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/service_runs.py @@ -1,7 +1,11 @@ +# pylint: disable=too-many-arguments from datetime import UTC, datetime, timedelta import shortuuid from aws_library.s3 import SimcoreS3API +from models_library.api_schemas_resource_usage_tracker.credit_transactions import ( + WalletTotalCredits, +) from models_library.api_schemas_resource_usage_tracker.service_runs import ( OsparcCreditsAggregatedByServiceGet, OsparcCreditsAggregatedUsagesPage, @@ -182,6 +186,23 @@ async def export_service_runs( ) +async def sum_project_wallet_total_credits( + db_engine: AsyncEngine, + *, + product_name: ProductName, + wallet_id: WalletID, + project_id: ProjectID, + transaction_status: CreditTransactionStatus | None = None, +) -> WalletTotalCredits: + return await service_runs_db.sum_project_wallet_total_credits( + db_engine, + product_name=product_name, + wallet_id=wallet_id, + project_id=project_id, + transaction_status=transaction_status, + ) + + async def get_osparc_credits_aggregated_usages_page( user_id: UserID, product_name: ProductName, diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/utils.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/utils.py index 4ccd956a16f..d8f9463d7fd 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/utils.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/utils.py @@ -37,12 +37,10 @@ async def sum_credit_transactions_and_publish_to_rabbitmq( product_name: ProductName, wallet_id: WalletID, ) -> WalletTotalCredits: - wallet_total_credits = ( - await credit_transactions_db.sum_credit_transactions_by_product_and_wallet( - db_engine, - product_name=product_name, - wallet_id=wallet_id, - ) + wallet_total_credits = await credit_transactions_db.sum_wallet_credits( + db_engine, + product_name=product_name, + wallet_id=wallet_id, ) publish_message = WalletCreditsMessage.model_construct( wallet_id=wallet_id, diff --git a/services/resource-usage-tracker/tests/unit/conftest.py b/services/resource-usage-tracker/tests/unit/conftest.py index 1a6a864a447..61f6dc0cb29 100644 --- a/services/resource-usage-tracker/tests/unit/conftest.py +++ b/services/resource-usage-tracker/tests/unit/conftest.py @@ -32,6 +32,8 @@ "pytest_simcore.docker_registry", "pytest_simcore.docker_swarm", "pytest_simcore.environment_configs", + "pytest_simcore.faker_projects_data", + "pytest_simcore.faker_products_data", "pytest_simcore.postgres_service", "pytest_simcore.pydantic_models", "pytest_simcore.pytest_global_environs", diff --git a/services/resource-usage-tracker/tests/unit/with_dbs/conftest.py b/services/resource-usage-tracker/tests/unit/with_dbs/conftest.py index c361439e951..5a7e0008c68 100644 --- a/services/resource-usage-tracker/tests/unit/with_dbs/conftest.py +++ b/services/resource-usage-tracker/tests/unit/with_dbs/conftest.py @@ -136,7 +136,9 @@ def _creator(**overrides) -> dict[str, Any]: "user_id": faker.pyint(), "user_email": faker.email(), "osparc_credits": -abs(faker.pyfloat()), - "transaction_status": choice(["BILLED", "PENDING", "NOT_BILLED"]), + "transaction_status": choice( + ["BILLED", "PENDING", "NOT_BILLED", "IN_DEBT"] + ), "transaction_classification": "DEDUCT_SERVICE_RUN", "service_run_id": faker.uuid4(), "payment_transaction_id": faker.uuid4(), diff --git a/services/resource-usage-tracker/tests/unit/with_dbs/test_api_credit_transactions.py b/services/resource-usage-tracker/tests/unit/with_dbs/test_api_credit_transactions.py index 244a74c62d7..042b536c563 100644 --- a/services/resource-usage-tracker/tests/unit/with_dbs/test_api_credit_transactions.py +++ b/services/resource-usage-tracker/tests/unit/with_dbs/test_api_credit_transactions.py @@ -1,14 +1,33 @@ from collections.abc import Iterator +from datetime import UTC, datetime, timedelta from decimal import Decimal from typing import Callable import httpx import pytest import sqlalchemy as sa -from servicelib.rabbitmq import RabbitMQClient +from faker import Faker +from models_library.api_schemas_resource_usage_tracker.credit_transactions import ( + CreditTransactionCreateBody, + WalletTotalCredits, +) +from models_library.products import ProductName +from models_library.projects import ProjectID +from models_library.resource_tracker import ( + CreditClassification, + CreditTransactionStatus, + ServiceRunStatus, +) +from servicelib.rabbitmq import RabbitMQClient, RabbitMQRPCClient +from servicelib.rabbitmq.rpc_interfaces.resource_usage_tracker import ( + credit_transactions, +) from simcore_postgres_database.models.resource_tracker_credit_transactions import ( resource_tracker_credit_transactions, ) +from simcore_postgres_database.models.resource_tracker_service_runs import ( + resource_tracker_service_runs, +) from starlette import status from yarl import URL @@ -32,12 +51,16 @@ def resource_tracker_credit_transactions_db( con.execute(resource_tracker_credit_transactions.delete()) +_WALLET_ID = 1 + + async def test_credit_transactions_workflow( create_rabbitmq_client: Callable[[str], RabbitMQClient], mocked_redis_server: None, postgres_db: sa.engine.Engine, async_client: httpx.AsyncClient, resource_tracker_credit_transactions_db: None, + rpc_client: RabbitMQRPCClient, ): url = URL("/v1/credit-transactions") @@ -45,7 +68,7 @@ async def test_credit_transactions_workflow( url=f"{url}", json={ "product_name": "osparc", - "wallet_id": 1, + "wallet_id": _WALLET_ID, "wallet_name": "string", "user_id": 1, "user_email": "string", @@ -62,7 +85,7 @@ async def test_credit_transactions_workflow( url=f"{url}", json={ "product_name": "osparc", - "wallet_id": 1, + "wallet_id": _WALLET_ID, "wallet_name": "string", "user_id": 1, "user_email": "string", @@ -98,5 +121,333 @@ async def test_credit_transactions_workflow( ) assert response.status_code == status.HTTP_200_OK data = response.json() - assert data["wallet_id"] == 1 - assert data["available_osparc_credits"] == Decimal(1340.04) + assert data["wallet_id"] == _WALLET_ID + _expected_credits = Decimal("1340.04") + assert data["available_osparc_credits"] == float(_expected_credits) + + output = await credit_transactions.get_wallet_total_credits( + rpc_client, + product_name="osparc", + wallet_id=_WALLET_ID, + ) + assert output.available_osparc_credits == _expected_credits + + +_USER_ID_1 = 1 +_USER_ID_2 = 2 +_SERVICE_RUN_ID_1 = "1" +_SERVICE_RUN_ID_2 = "2" +_SERVICE_RUN_ID_3 = "3" +_SERVICE_RUN_ID_4 = "4" +_WALLET_ID_FOR_PAYING_DEBT__NOT_ENOUGH_CREDITS = 2 +_WALLET_ID_FOR_PAYING_DEBT__ENOUGH_CREDITS = 3 + + +@pytest.fixture() +def resource_tracker_setup_db( + postgres_db: sa.engine.Engine, + random_resource_tracker_service_run, + random_resource_tracker_credit_transactions, + project_id: ProjectID, + product_name: ProductName, + faker: Faker, +) -> Iterator[None]: + with postgres_db.connect() as con: + # Service run table + result = con.execute( + resource_tracker_service_runs.insert() + .values( + [ + random_resource_tracker_service_run( + user_id=_USER_ID_1, + service_run_id=_SERVICE_RUN_ID_1, + product_name=product_name, + started_at=datetime.now(tz=UTC) - timedelta(hours=1), + stopped_at=datetime.now(tz=UTC), + project_id=project_id, + service_run_status=ServiceRunStatus.SUCCESS, + ), + random_resource_tracker_service_run( + user_id=_USER_ID_2, # <-- different user + service_run_id=_SERVICE_RUN_ID_2, + product_name=product_name, + started_at=datetime.now(tz=UTC) - timedelta(hours=1), + stopped_at=datetime.now(tz=UTC), + project_id=project_id, + service_run_status=ServiceRunStatus.SUCCESS, + ), + random_resource_tracker_service_run( + user_id=_USER_ID_1, + service_run_id=_SERVICE_RUN_ID_3, + product_name=product_name, + started_at=datetime.now(tz=UTC) - timedelta(hours=1), + stopped_at=datetime.now(tz=UTC), + project_id=project_id, + service_run_status=ServiceRunStatus.SUCCESS, + ), + random_resource_tracker_service_run( + user_id=_USER_ID_1, + service_run_id=_SERVICE_RUN_ID_4, + product_name=product_name, + started_at=datetime.now(tz=UTC) - timedelta(hours=1), + stopped_at=datetime.now(tz=UTC), + project_id=faker.uuid4(), # <-- different project + service_run_status=ServiceRunStatus.SUCCESS, + ), + ] + ) + .returning(resource_tracker_service_runs) + ) + row = result.first() + assert row + + # Transaction table + result = con.execute( + resource_tracker_credit_transactions.insert() + .values( + [ + random_resource_tracker_credit_transactions( + user_id=_USER_ID_1, + service_run_id=_SERVICE_RUN_ID_1, + product_name=product_name, + osparc_credits=-50, + transaction_status=CreditTransactionStatus.BILLED, + transaction_classification=CreditClassification.DEDUCT_SERVICE_RUN, + wallet_id=_WALLET_ID, + ), + random_resource_tracker_credit_transactions( + user_id=_USER_ID_2, # <-- different user + service_run_id=_SERVICE_RUN_ID_2, + product_name=product_name, + osparc_credits=-70, + transaction_status=CreditTransactionStatus.BILLED, + transaction_classification=CreditClassification.DEDUCT_SERVICE_RUN, + wallet_id=_WALLET_ID, + ), + random_resource_tracker_credit_transactions( + user_id=_USER_ID_1, + osparc_credits=-100, + service_run_id=_SERVICE_RUN_ID_3, + product_name=product_name, + transaction_status=CreditTransactionStatus.IN_DEBT, # <-- IN DEBT + transaction_classification=CreditClassification.DEDUCT_SERVICE_RUN, + wallet_id=_WALLET_ID, + ), + random_resource_tracker_credit_transactions( + user_id=_USER_ID_1, + osparc_credits=-90, + service_run_id=_SERVICE_RUN_ID_4, + product_name=product_name, + transaction_status=CreditTransactionStatus.BILLED, + transaction_classification=CreditClassification.DEDUCT_SERVICE_RUN, + wallet_id=_WALLET_ID, + ), + # We will add 2 more wallets for paying a debt test + random_resource_tracker_credit_transactions( + user_id=_USER_ID_1, + osparc_credits=50, # <-- Not enough credits to pay the DEBT (-100) + service_run_id=None, + product_name=product_name, + transaction_status=CreditTransactionStatus.BILLED, + transaction_classification=CreditClassification.ADD_WALLET_TOP_UP, + wallet_id=_WALLET_ID_FOR_PAYING_DEBT__NOT_ENOUGH_CREDITS, + ), + random_resource_tracker_credit_transactions( + user_id=_USER_ID_1, + osparc_credits=500, # <-- Enough credits to pay the DEBT (-100) + service_run_id=None, + product_name=product_name, + transaction_status=CreditTransactionStatus.BILLED, + transaction_classification=CreditClassification.ADD_WALLET_TOP_UP, + wallet_id=_WALLET_ID_FOR_PAYING_DEBT__ENOUGH_CREDITS, + ), + ] + ) + .returning(resource_tracker_credit_transactions) + ) + row = result.first() + assert row + + yield + + con.execute(resource_tracker_credit_transactions.delete()) + con.execute(resource_tracker_service_runs.delete()) + + +async def test_get_project_wallet_total_credits( + mocked_redis_server: None, + resource_tracker_setup_db: None, + rpc_client: RabbitMQRPCClient, + project_id: ProjectID, + product_name: ProductName, +): + output = await credit_transactions.get_project_wallet_total_credits( + rpc_client, + product_name=product_name, + wallet_id=_WALLET_ID, + project_id=project_id, + ) + assert isinstance(output, WalletTotalCredits) + assert output.available_osparc_credits == -220 + + output = await credit_transactions.get_project_wallet_total_credits( + rpc_client, + product_name=product_name, + wallet_id=_WALLET_ID, + project_id=project_id, + transaction_status=CreditTransactionStatus.IN_DEBT, + ) + assert isinstance(output, WalletTotalCredits) + assert output.available_osparc_credits == -100 + + +async def test_pay_project_debt( + mocked_redis_server: None, + resource_tracker_setup_db: None, + rpc_client: RabbitMQRPCClient, + project_id: ProjectID, + product_name: ProductName, +): + total_wallet_credits_for_wallet_in_debt_in_beginning = ( + await credit_transactions.get_wallet_total_credits( + rpc_client, + product_name=product_name, + wallet_id=_WALLET_ID, + ) + ) + + output = await credit_transactions.get_project_wallet_total_credits( + rpc_client, + product_name=product_name, + wallet_id=_WALLET_ID, + project_id=project_id, + transaction_status=CreditTransactionStatus.IN_DEBT, + ) + assert isinstance(output, WalletTotalCredits) + assert output.available_osparc_credits == -100 + _project_debt_amount = output.available_osparc_credits + + # We test situation when new and current wallet transaction amount are not setup properly by the client (ex. webserver) + new_wallet_transaction = CreditTransactionCreateBody( + product_name=product_name, + wallet_id=_WALLET_ID_FOR_PAYING_DEBT__NOT_ENOUGH_CREDITS, + wallet_name="new wallet", + user_id=_USER_ID_1, + user_email="test@test.com", + osparc_credits=_project_debt_amount - 50, # <-- Negative number + payment_transaction_id=f"Payment transaction from wallet {_WALLET_ID} to wallet {_WALLET_ID_FOR_PAYING_DEBT__NOT_ENOUGH_CREDITS}", + created_at=datetime.now(UTC), + ) + current_wallet_transaction = CreditTransactionCreateBody( + product_name=product_name, + wallet_id=_WALLET_ID, + wallet_name="current wallet", + user_id=_USER_ID_1, + user_email="test@test.com", + osparc_credits=-_project_debt_amount, # <-- Positive number + payment_transaction_id=f"Payment transaction from wallet {_WALLET_ID_FOR_PAYING_DEBT__NOT_ENOUGH_CREDITS} to wallet {_WALLET_ID}", + created_at=datetime.now(UTC), + ) + + with pytest.raises(ValueError): + await credit_transactions.pay_project_debt( + rpc_client, + project_id=project_id, + current_wallet_transaction=current_wallet_transaction, + new_wallet_transaction=new_wallet_transaction, + ) + + # We test situation when the new wallet doesn't have enough credits to pay the debt + new_wallet_transaction = CreditTransactionCreateBody( + product_name=product_name, + wallet_id=_WALLET_ID_FOR_PAYING_DEBT__NOT_ENOUGH_CREDITS, + wallet_name="new wallet", + user_id=_USER_ID_1, + user_email="test@test.com", + osparc_credits=_project_debt_amount, # <-- Negative number + payment_transaction_id=f"Payment transaction from wallet {_WALLET_ID} to wallet {_WALLET_ID_FOR_PAYING_DEBT__NOT_ENOUGH_CREDITS}", + created_at=datetime.now(UTC), + ) + current_wallet_transaction = CreditTransactionCreateBody( + product_name=product_name, + wallet_id=_WALLET_ID, + wallet_name="current wallet", + user_id=_USER_ID_1, + user_email="test@test.com", + osparc_credits=-_project_debt_amount, # <-- Positive number + payment_transaction_id=f"Payment transaction from wallet {_WALLET_ID_FOR_PAYING_DEBT__NOT_ENOUGH_CREDITS} to wallet {_WALLET_ID}", + created_at=datetime.now(UTC), + ) + + with pytest.raises(ValueError): + await credit_transactions.pay_project_debt( + rpc_client, + project_id=project_id, + current_wallet_transaction=current_wallet_transaction, + new_wallet_transaction=new_wallet_transaction, + ) + + # We test the proper situation, when new wallet pays the debt of the project + new_wallet_transaction = CreditTransactionCreateBody( + product_name=product_name, + wallet_id=_WALLET_ID_FOR_PAYING_DEBT__ENOUGH_CREDITS, + wallet_name="new wallet", + user_id=_USER_ID_1, + user_email="test@test.com", + osparc_credits=_project_debt_amount, # <-- Negative number + payment_transaction_id=f"Payment transaction from wallet {_WALLET_ID} to wallet {_WALLET_ID_FOR_PAYING_DEBT__ENOUGH_CREDITS}", + created_at=datetime.now(UTC), + ) + current_wallet_transaction = CreditTransactionCreateBody( + product_name=product_name, + wallet_id=_WALLET_ID, + wallet_name="current wallet", + user_id=_USER_ID_1, + user_email="test@test.com", + osparc_credits=-_project_debt_amount, # <-- Positive number + payment_transaction_id=f"Payment transaction from wallet {_WALLET_ID_FOR_PAYING_DEBT__ENOUGH_CREDITS} to wallet {_WALLET_ID}", + created_at=datetime.now(UTC), + ) + + await credit_transactions.pay_project_debt( + rpc_client, + project_id=project_id, + current_wallet_transaction=current_wallet_transaction, + new_wallet_transaction=new_wallet_transaction, + ) + + # We additionaly check that the project is not in the DEBT anymore + output = await credit_transactions.get_project_wallet_total_credits( + rpc_client, + product_name=product_name, + wallet_id=_WALLET_ID, + project_id=project_id, + transaction_status=CreditTransactionStatus.IN_DEBT, + ) + assert isinstance(output, WalletTotalCredits) + assert output.available_osparc_credits == 0 + + # We check whether the credits were deducted from the new wallet + output = await credit_transactions.get_wallet_total_credits( + rpc_client, + product_name=product_name, + wallet_id=_WALLET_ID_FOR_PAYING_DEBT__ENOUGH_CREDITS, + ) + assert isinstance(output, WalletTotalCredits) + assert ( + output.available_osparc_credits + == 400 # <-- 100 was deduced from the new wallet + ) + + # We check whether the credits were added back to the original wallet + output = await credit_transactions.get_wallet_total_credits( + rpc_client, + product_name=product_name, + wallet_id=_WALLET_ID, + ) + assert isinstance(output, WalletTotalCredits) + assert ( + output.available_osparc_credits + == total_wallet_credits_for_wallet_in_debt_in_beginning.available_osparc_credits + + 100 # <-- 100 was added to the original wallet + ) 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 514efafa47d..a34f8e0241e 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 @@ -86,6 +86,7 @@ ProjectNodeResourcesInvalidError, ProjectNotFoundError, ProjectStartsTooManyDynamicNodesError, + ProjectWalletDebtError, ) _logger = logging.getLogger(__name__) @@ -107,7 +108,7 @@ async def wrapper(request: web.Request) -> web.StreamResponse: CatalogItemNotFoundError, ) as exc: raise web.HTTPNotFound(reason=f"{exc}") from exc - except WalletNotEnoughCreditsError as exc: + except (WalletNotEnoughCreditsError, ProjectWalletDebtError) as exc: raise web.HTTPPaymentRequired(reason=f"{exc}") from exc except ProjectInvalidRightsError as exc: raise web.HTTPUnauthorized(reason=f"{exc}") from exc 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 6b6256c00e5..d38935cdcf5 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 @@ -36,6 +36,7 @@ from ..users.exceptions import UserDefaultWalletNotFoundError from ..utils_aiohttp import envelope_json_response from ..wallets.errors import WalletNotEnoughCreditsError +from . import api as projects_service from . import projects_api from ._common.models import ProjectPathParams, RequestContext from .exceptions import ( @@ -127,6 +128,12 @@ async def open_project(request: web.Request) -> web.Response: ), ) + await projects_service.raise_if_project_is_in_debt( + request.app, + project_id=path_params.project_id, + product_name=req_ctx.product_name, + ) + product: Product = get_current_product(request) if not await projects_api.try_open_project_for_user( diff --git a/services/web/server/src/simcore_service_webserver/projects/_wallets_api.py b/services/web/server/src/simcore_service_webserver/projects/_wallets_api.py index d8366f62c32..960e3476821 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_wallets_api.py +++ b/services/web/server/src/simcore_service_webserver/projects/_wallets_api.py @@ -14,6 +14,10 @@ credit_transactions, service_runs, ) +from simcore_service_webserver.projects.exceptions import ( + ProjectWalletDebtError, + ProjectWalletPendingTransactionError, +) from ..rabbitmq import get_rabbitmq_rpc_client from ..users import api as users_api @@ -31,6 +35,31 @@ async def get_project_wallet(app, project_id: ProjectID): return wallet +async def raise_if_project_is_in_debt( + app, *, project_id: ProjectID, product_name: ProductName +): + db: ProjectDBAPI = ProjectDBAPI.get_from_app_context(app) + + current_project_wallet = await db.get_project_wallet(project_uuid=project_id) + rpc_client = get_rabbitmq_rpc_client(app) + + if current_project_wallet: + # Do not allow to change wallet if the project connected wallet is in DEBT! + project_wallet_credits_in_debt = ( + await credit_transactions.get_project_wallet_total_credits( + rpc_client, + product_name=product_name, + wallet_id=current_project_wallet.wallet_id, + project_id=project_id, + transaction_status=CreditTransactionStatus.IN_DEBT, + ) + ) + if project_wallet_credits_in_debt.available_osparc_credits < 0: + raise ProjectWalletDebtError( + debt_amount=project_wallet_credits_in_debt.available_osparc_credits + ) + + async def connect_wallet_to_project( app, *, @@ -54,25 +83,26 @@ async def connect_wallet_to_project( if current_project_wallet: # Do not allow to change wallet if the project connected wallet is in DEBT! - project_wallet_credits = await credit_transactions.get_wallet_total_credits( - rpc_client, - product_name=product_name, - wallet_id=current_project_wallet.wallet_id, - project_id=project_id, - transaction_status=CreditTransactionStatus.IN_DEBT, + project_wallet_credits_in_debt = ( + await credit_transactions.get_project_wallet_total_credits( + rpc_client, + product_name=product_name, + wallet_id=current_project_wallet.wallet_id, + project_id=project_id, + transaction_status=CreditTransactionStatus.IN_DEBT, + ) ) - if project_wallet_credits.available_osparc_credits > 0: - msg = ( - f"Current Project Wallet {current_project_wallet.wallet_id} is in DEBT" + if project_wallet_credits_in_debt.available_osparc_credits < 0: + raise ProjectWalletDebtError( + debt_amount=project_wallet_credits_in_debt.available_osparc_credits ) - raise ValueError(msg) # Do not allow to change wallet if the project has transaction in PENDING! project_service_runs_in_progress = await service_runs.get_service_run_page( rpc_client, user_id=user_id, product_name=product_name, - wallet_id=wallet_id, + wallet_id=current_project_wallet.wallet_id, access_all_wallet_usage=True, transaction_status=CreditTransactionStatus.PENDING, project_id=project_id, @@ -80,8 +110,7 @@ async def connect_wallet_to_project( limit=1, ) if project_service_runs_in_progress.total > 0: - msg = "Can not change the wallet, as project has currently pending transaction" - raise ValueError(msg) + raise ProjectWalletPendingTransactionError await db.connect_wallet_to_project(project_uuid=project_id, wallet_id=wallet_id) return wallet @@ -108,11 +137,11 @@ async def pay_debt_with_different_wallet( Parameters: - current_wallet_id: ID of Wallet A (the wallet currently linked to the project). - new_wallet_id: ID of Wallet B (the wallet the user wants to use to pay the debt). - - debt_amount: The amount to be transferred (e.g., 100 credits). + - debt_amount: The amount of debt to be payed (e.g., -100 credits). Needs to be negative. Process: - 1. Transfer the specified debt amount (100 credits) from Wallet B to Wallet A. - 2. Update the project's debt status (e.g., unblock the project). + 1. Transfer the specified debt amount from Wallet B to Wallet A. + 2. Update the project's debt status (this unblocks the project). Outcome: The project's debt is paid, Wallet A is credited, and Wallet B is debited. @@ -146,8 +175,8 @@ async def pay_debt_with_different_wallet( wallet_name=new_wallet.name, user_id=user_id, user_email=user["email"], - osparc_credits=-debt_amount, # <-- Negative number - payment_transaction_id=f"Payment transaction from wallet {current_wallet_id} to wallet {new_wallet_id}", + osparc_credits=debt_amount, # <-- Negative number + payment_transaction_id=f"Payment transaction from wallet {current_wallet_id} to wallet {new_wallet_id}. Project id {project_id}.", created_at=_created_at, ) @@ -157,8 +186,8 @@ async def pay_debt_with_different_wallet( wallet_name=current_wallet.name, user_id=user_id, user_email=user["email"], - osparc_credits=debt_amount, # <-- Positive number - payment_transaction_id=f"Payment transaction from wallet {new_wallet_id} to wallet {current_wallet_id}", + osparc_credits=-debt_amount, # <-- Positive number + payment_transaction_id=f"Payment transaction from wallet {new_wallet_id} to wallet {current_wallet_id}. Project id {project_id}.", created_at=_created_at, ) diff --git a/services/web/server/src/simcore_service_webserver/projects/_wallets_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_wallets_handlers.py index f5e4faab7a4..48a1bbb27a7 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_wallets_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_wallets_handlers.py @@ -5,12 +5,13 @@ import functools import logging from decimal import Decimal +from typing import Annotated from aiohttp import web from models_library.api_schemas_webserver.wallets import WalletGet from models_library.projects import ProjectID from models_library.wallets import WalletID -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from servicelib.aiohttp import status from servicelib.aiohttp.requests_validation import ( parse_request_body_as, @@ -26,7 +27,12 @@ from . import _wallets_api as wallets_api from . import projects_api from ._common.models import ProjectPathParams, RequestContext -from .exceptions import ProjectInvalidRightsError, ProjectNotFoundError +from .exceptions import ( + ProjectInvalidRightsError, + ProjectNotFoundError, + ProjectWalletDebtError, + ProjectWalletPendingTransactionError, +) _logger = logging.getLogger(__name__) @@ -43,7 +49,14 @@ async def wrapper(request: web.Request) -> web.StreamResponse: except WalletNotFoundError as exc: raise web.HTTPNotFound(reason=f"{exc}") from exc - except (WalletAccessForbiddenError, ProjectInvalidRightsError) as exc: + except ProjectWalletDebtError as exc: + raise web.HTTPPaymentRequired(reason=f"{exc}") from exc + + except ( + WalletAccessForbiddenError, + ProjectInvalidRightsError, + ProjectWalletPendingTransactionError, + ) as exc: raise web.HTTPForbidden(reason=f"{exc}") from exc return wrapper @@ -111,7 +124,7 @@ async def connect_wallet_to_project(request: web.Request): class _PayProjectDebtBody(BaseModel): - amount: Decimal + amount: Annotated[Decimal, Field(lt=0)] model_config = ConfigDict(extra="forbid") diff --git a/services/web/server/src/simcore_service_webserver/projects/api.py b/services/web/server/src/simcore_service_webserver/projects/api.py index c7b44426c83..e75add4e898 100644 --- a/services/web/server/src/simcore_service_webserver/projects/api.py +++ b/services/web/server/src/simcore_service_webserver/projects/api.py @@ -11,7 +11,11 @@ ) from ._permalink_api import ProjectPermalink from ._permalink_api import register_factory as register_permalink_factory -from ._wallets_api import connect_wallet_to_project, get_project_wallet +from ._wallets_api import ( + connect_wallet_to_project, + get_project_wallet, + raise_if_project_is_in_debt, +) __all__: tuple[str, ...] = ( "check_user_project_permission", @@ -22,6 +26,7 @@ "has_user_project_access_rights", "ProjectPermalink", "register_permalink_factory", + "raise_if_project_is_in_debt", ) diff --git a/services/web/server/src/simcore_service_webserver/projects/exceptions.py b/services/web/server/src/simcore_service_webserver/projects/exceptions.py index 76cadc26987..a2804858502 100644 --- a/services/web/server/src/simcore_service_webserver/projects/exceptions.py +++ b/services/web/server/src/simcore_service_webserver/projects/exceptions.py @@ -1,4 +1,5 @@ """Defines the different exceptions that may arise in the projects subpackage""" + # mypy: disable-error-code=truthy-function from typing import Any @@ -237,5 +238,17 @@ class ProjectGroupNotFoundError(BaseProjectError): msg_template = "Project group not found. {reason}" +class ProjectWalletDebtError(BaseProjectError): + msg_template = ( + "Project is in debt {debt_amount} credits. It is forbidden to change wallet." + ) + + +class ProjectWalletPendingTransactionError(BaseProjectError): + msg_template = ( + "Project has currently pending transactions. It is forbidden to change wallet." + ) + + assert ProjectLockError # nosec __all__: tuple[str, ...] = ("ProjectLockError",) From ec58d66276779cc233172f73a4b92d5ccc7fe164 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Fri, 17 Jan 2025 15:22:47 +0100 Subject: [PATCH 07/17] improvements and tests --- .../services/modules/db/service_runs_db.py | 2 +- .../with_dbs/test_api_credit_transactions.py | 70 ++++++++++++++++++- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/service_runs_db.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/service_runs_db.py index 3e411f434f6..335c743baec 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/service_runs_db.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/service_runs_db.py @@ -297,7 +297,7 @@ async def list_service_runs_by_product_and_user_and_wallet( ) if project_id: base_query = base_query.where( - resource_tracker_service_runs.c.project_id == project_id + resource_tracker_service_runs.c.project_id == f"{project_id}" ) if transaction_status: base_query = base_query.where( diff --git a/services/resource-usage-tracker/tests/unit/with_dbs/test_api_credit_transactions.py b/services/resource-usage-tracker/tests/unit/with_dbs/test_api_credit_transactions.py index 042b536c563..d69304bd350 100644 --- a/services/resource-usage-tracker/tests/unit/with_dbs/test_api_credit_transactions.py +++ b/services/resource-usage-tracker/tests/unit/with_dbs/test_api_credit_transactions.py @@ -21,6 +21,7 @@ from servicelib.rabbitmq import RabbitMQClient, RabbitMQRPCClient from servicelib.rabbitmq.rpc_interfaces.resource_usage_tracker import ( credit_transactions, + service_runs, ) from simcore_postgres_database.models.resource_tracker_credit_transactions import ( resource_tracker_credit_transactions, @@ -28,6 +29,7 @@ from simcore_postgres_database.models.resource_tracker_service_runs import ( resource_tracker_service_runs, ) +from simcore_service_resource_usage_tracker.services.service_runs import ServiceRunPage from starlette import status from yarl import URL @@ -166,15 +168,17 @@ def resource_tracker_setup_db( stopped_at=datetime.now(tz=UTC), project_id=project_id, service_run_status=ServiceRunStatus.SUCCESS, + wallet_id=_WALLET_ID, ), random_resource_tracker_service_run( user_id=_USER_ID_2, # <-- different user service_run_id=_SERVICE_RUN_ID_2, product_name=product_name, started_at=datetime.now(tz=UTC) - timedelta(hours=1), - stopped_at=datetime.now(tz=UTC), + stopped_at=None, project_id=project_id, - service_run_status=ServiceRunStatus.SUCCESS, + service_run_status=ServiceRunStatus.RUNNING, # <-- Runnin status + wallet_id=_WALLET_ID, ), random_resource_tracker_service_run( user_id=_USER_ID_1, @@ -184,6 +188,7 @@ def resource_tracker_setup_db( stopped_at=datetime.now(tz=UTC), project_id=project_id, service_run_status=ServiceRunStatus.SUCCESS, + wallet_id=_WALLET_ID, ), random_resource_tracker_service_run( user_id=_USER_ID_1, @@ -193,6 +198,7 @@ def resource_tracker_setup_db( stopped_at=datetime.now(tz=UTC), project_id=faker.uuid4(), # <-- different project service_run_status=ServiceRunStatus.SUCCESS, + wallet_id=_WALLET_ID, ), ] ) @@ -210,6 +216,7 @@ def resource_tracker_setup_db( user_id=_USER_ID_1, service_run_id=_SERVICE_RUN_ID_1, product_name=product_name, + payment_transaction_id=None, osparc_credits=-50, transaction_status=CreditTransactionStatus.BILLED, transaction_classification=CreditClassification.DEDUCT_SERVICE_RUN, @@ -219,8 +226,9 @@ def resource_tracker_setup_db( user_id=_USER_ID_2, # <-- different user service_run_id=_SERVICE_RUN_ID_2, product_name=product_name, + payment_transaction_id=None, osparc_credits=-70, - transaction_status=CreditTransactionStatus.BILLED, + transaction_status=CreditTransactionStatus.PENDING, # <-- Pending status transaction_classification=CreditClassification.DEDUCT_SERVICE_RUN, wallet_id=_WALLET_ID, ), @@ -229,6 +237,7 @@ def resource_tracker_setup_db( osparc_credits=-100, service_run_id=_SERVICE_RUN_ID_3, product_name=product_name, + payment_transaction_id=None, transaction_status=CreditTransactionStatus.IN_DEBT, # <-- IN DEBT transaction_classification=CreditClassification.DEDUCT_SERVICE_RUN, wallet_id=_WALLET_ID, @@ -238,6 +247,7 @@ def resource_tracker_setup_db( osparc_credits=-90, service_run_id=_SERVICE_RUN_ID_4, product_name=product_name, + payment_transaction_id=None, transaction_status=CreditTransactionStatus.BILLED, transaction_classification=CreditClassification.DEDUCT_SERVICE_RUN, wallet_id=_WALLET_ID, @@ -248,6 +258,7 @@ def resource_tracker_setup_db( osparc_credits=50, # <-- Not enough credits to pay the DEBT (-100) service_run_id=None, product_name=product_name, + payment_transaction_id="INVITATION", transaction_status=CreditTransactionStatus.BILLED, transaction_classification=CreditClassification.ADD_WALLET_TOP_UP, wallet_id=_WALLET_ID_FOR_PAYING_DEBT__NOT_ENOUGH_CREDITS, @@ -451,3 +462,56 @@ async def test_pay_project_debt( == total_wallet_credits_for_wallet_in_debt_in_beginning.available_osparc_credits + 100 # <-- 100 was added to the original wallet ) + + +async def test_list_service_runs_with_transaction_status_filter( + mocked_redis_server: None, + resource_tracker_setup_db: None, + rpc_client: RabbitMQRPCClient, + project_id: ProjectID, + product_name: ProductName, +): + result = await service_runs.get_service_run_page( + rpc_client, + user_id=_USER_ID_1, + product_name=product_name, + wallet_id=_WALLET_ID, + access_all_wallet_usage=True, + project_id=project_id, + transaction_status=CreditTransactionStatus.PENDING, + offset=0, + limit=1, + ) + assert isinstance(result, ServiceRunPage) + assert len(result.items) == 1 + assert result.total == 1 + + result = await service_runs.get_service_run_page( + rpc_client, + user_id=_USER_ID_1, + product_name=product_name, + wallet_id=_WALLET_ID, + access_all_wallet_usage=True, + project_id=project_id, + transaction_status=CreditTransactionStatus.IN_DEBT, + offset=0, + limit=1, + ) + assert isinstance(result, ServiceRunPage) + assert len(result.items) == 1 + assert result.total == 1 + + result = await service_runs.get_service_run_page( + rpc_client, + user_id=_USER_ID_1, + product_name=product_name, + wallet_id=_WALLET_ID, + access_all_wallet_usage=True, + project_id=project_id, + transaction_status=CreditTransactionStatus.BILLED, + offset=0, + limit=1, + ) + assert isinstance(result, ServiceRunPage) + assert len(result.items) == 1 + assert result.total == 1 From c7f6bdb32916b6d74c2b1322b332e8ab59a0ce1d Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Fri, 17 Jan 2025 15:57:52 +0100 Subject: [PATCH 08/17] fix tests --- .../api/rpc/_service_runs.py | 1 + .../projects/_states_handlers.py | 3 +- .../02/test_projects_wallet_handlers.py | 32 +++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_service_runs.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_service_runs.py index 725de2f9deb..db9b155f37d 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_service_runs.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_service_runs.py @@ -1,3 +1,4 @@ +# pylint: disable=too-many-arguments from fastapi import FastAPI from models_library.api_schemas_resource_usage_tracker.service_runs import ( OsparcCreditsAggregatedUsagesPage, 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 d38935cdcf5..ba76651b043 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 @@ -45,6 +45,7 @@ ProjectNotFoundError, ProjectStartsTooManyDynamicNodesError, ProjectTooManyProjectOpenedError, + ProjectWalletDebtError, ) _logger = logging.getLogger(__name__) @@ -75,7 +76,7 @@ async def _wrapper(request: web.Request) -> web.StreamResponse: except ProjectTooManyProjectOpenedError as exc: raise web.HTTPConflict(reason=f"{exc}") from exc - except WalletNotEnoughCreditsError as exc: + except (WalletNotEnoughCreditsError, ProjectWalletDebtError) as exc: raise web.HTTPPaymentRequired(reason=f"{exc}") from exc return _wrapper diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_wallet_handlers.py b/services/web/server/tests/unit/with_dbs/02/test_projects_wallet_handlers.py index 132c6f21252..8e3dbe38ea3 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_wallet_handlers.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_wallet_handlers.py @@ -6,12 +6,20 @@ # pylint: disable=too-many-statements from collections.abc import Iterator +from decimal import Decimal from http import HTTPStatus import pytest import sqlalchemy as sa from aiohttp.test_utils import TestClient +from models_library.api_schemas_resource_usage_tracker.credit_transactions import ( + WalletTotalCredits, +) +from models_library.api_schemas_resource_usage_tracker.service_runs import ( + ServiceRunPage, +) from models_library.api_schemas_webserver.wallets import WalletGet +from pytest_mock import MockerFixture from pytest_simcore.helpers.assert_checks import assert_status from pytest_simcore.helpers.webserver_login import LoggedUser, UserInfoDict from servicelib.aiohttp import status @@ -97,6 +105,28 @@ def setup_wallets_db( con.execute(wallets.delete()) +@pytest.fixture +def mock_get_project_wallet_total_credits( + mocker: MockerFixture, setup_wallets_db: list[WalletGet] +): + mocker.patch( + "simcore_service_webserver.projects._wallets_api.credit_transactions.get_project_wallet_total_credits", + spec=True, + return_value=WalletTotalCredits( + wallet_id=setup_wallets_db[0].wallet_id, available_osparc_credits=Decimal(0) + ), + ) + + +@pytest.fixture +def mock_get_service_run_page(mocker: MockerFixture): + mocker.patch( + "simcore_service_webserver.projects._wallets_api.service_runs.get_service_run_page", + spec=True, + return_value=ServiceRunPage(items=[], total=0), + ) + + @pytest.mark.parametrize("user_role,expected", [(UserRole.USER, status.HTTP_200_OK)]) async def test_project_wallets_full_workflow( client: TestClient, @@ -104,6 +134,8 @@ async def test_project_wallets_full_workflow( user_project: ProjectDict, expected: HTTPStatus, setup_wallets_db: list[WalletGet], + mock_get_project_wallet_total_credits: None, + mock_get_service_run_page: None, ): assert client.app From 89bda75388142c779ccbdf5303ee5e4e360f5aa5 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Fri, 17 Jan 2025 18:15:20 +0100 Subject: [PATCH 09/17] adding unit tests --- .../projects/_wallets_handlers.py | 6 +- .../02/test_projects_wallet_handlers.py | 61 +++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/projects/_wallets_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_wallets_handlers.py index 48a1bbb27a7..9c44e18bba4 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_wallets_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_wallets_handlers.py @@ -166,7 +166,7 @@ async def pay_project_debt(request: web.Request): # At present, once the wallet balance becomes positive, RUT updates all # projects connected to that wallet from IN_DEBT to BILLED. - return web.json_response(status=status.HTTP_501_NOT_IMPLEMENTED) + raise web.HTTPNotImplemented # The debt is being paid using a different wallet than the one currently connected to the project. # Steps: @@ -177,8 +177,8 @@ async def pay_project_debt(request: web.Request): product_name=req_ctx.product_name, project_id=path_params.project_id, user_id=req_ctx.user_id, - current_wallet_id=path_params.wallet_id, + current_wallet_id=current_wallet.wallet_id, new_wallet_id=path_params.wallet_id, debt_amount=body_params.amount, ) - return envelope_json_response(web.json_response(status=status.HTTP_204_NO_CONTENT)) + return web.json_response(status=status.HTTP_204_NO_CONTENT) diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_wallet_handlers.py b/services/web/server/tests/unit/with_dbs/02/test_projects_wallet_handlers.py index 8e3dbe38ea3..07a447de907 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_wallet_handlers.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_wallet_handlers.py @@ -8,6 +8,7 @@ from collections.abc import Iterator from decimal import Decimal from http import HTTPStatus +from unittest.mock import MagicMock import pytest import sqlalchemy as sa @@ -175,3 +176,63 @@ async def test_project_wallets_full_workflow( resp = await client.get(f"{base_url}") data, _ = await assert_status(resp, expected) assert data["walletId"] == setup_wallets_db[1].wallet_id + + +@pytest.fixture +def mock_pay_project_debt(mocker: MockerFixture): + return mocker.patch( + "simcore_service_webserver.projects._wallets_api.credit_transactions.pay_project_debt", + spec=True, + ) + + +@pytest.mark.parametrize("user_role,expected", [(UserRole.USER, status.HTTP_200_OK)]) +async def test_pay_project_debt( + client: TestClient, + logged_user: UserInfoDict, + user_project: ProjectDict, + expected: HTTPStatus, + setup_wallets_db: list[WalletGet], + mock_get_project_wallet_total_credits: None, + mock_get_service_run_page: None, + mock_pay_project_debt: MagicMock, +): + assert client.app + # Use endpoint while project is not connected to any wallet + base_url = client.app.router["pay_project_debt"].url_for( + project_id=user_project["uuid"], wallet_id=f"{setup_wallets_db[1].wallet_id}" + ) + resp = await client.post(f"{base_url}", json={"amount": -100}) + await assert_status(resp, status.HTTP_404_NOT_FOUND) + + # Connect wallet to the project + base_url = client.app.router["connect_wallet_to_project"].url_for( + project_id=user_project["uuid"], wallet_id=f"{setup_wallets_db[0].wallet_id}" + ) + resp = await client.put(f"{base_url}") + data, _ = await assert_status(resp, expected) + assert data["walletId"] == setup_wallets_db[0].wallet_id + + # Use endpoint with the same wallet as the one that is connected + base_url = client.app.router["pay_project_debt"].url_for( + project_id=user_project["uuid"], wallet_id=f"{setup_wallets_db[0].wallet_id}" + ) + resp = await client.post(f"{base_url}", json={"amount": -100}) + assert resp.status == status.HTTP_501_NOT_IMPLEMENTED + + # Use endpoint with wrong input + base_url = client.app.router["pay_project_debt"].url_for( + project_id=user_project["uuid"], wallet_id=f"{setup_wallets_db[1].wallet_id}" + ) + resp = await client.post( + f"{base_url}", json={"amount": 100} + ) # <-- Error input (must be negative!) + await assert_status(resp, status.HTTP_422_UNPROCESSABLE_ENTITY) + + # Use endpoint properly + base_url = client.app.router["pay_project_debt"].url_for( + project_id=user_project["uuid"], wallet_id=f"{setup_wallets_db[1].wallet_id}" + ) + resp = await client.post(f"{base_url}", json={"amount": -100}) + data, _ = await assert_status(resp, status.HTTP_204_NO_CONTENT) + assert mock_pay_project_debt.assert_called_once From de3528abd3458bf4d2c936b22ffac94b63c8b0e6 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Sat, 18 Jan 2025 14:33:19 +0100 Subject: [PATCH 10/17] adding test for open project --- .../resource_usage_tracker/service_runs.py | 1 + .../projects/_nodes_handlers.py | 7 +- .../projects/_states_handlers.py | 9 ++- .../projects/_wallets_api.py | 15 ++-- .../projects/_wallets_handlers.py | 4 +- .../projects/exceptions.py | 10 ++- .../02/test_projects_states_handlers.py | 75 ++++++++++++++++++- 7 files changed, 103 insertions(+), 18 deletions(-) diff --git a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/service_runs.py b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/service_runs.py index 97c8ed37e8b..0f1191ebe4f 100644 --- a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/service_runs.py +++ b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/service_runs.py @@ -1,3 +1,4 @@ +# pylint: disable=too-many-arguments import logging from typing import Final 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 a34f8e0241e..5511cea2199 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 @@ -80,13 +80,13 @@ ClustersKeeperNotAvailableError, DefaultPricingUnitNotFoundError, NodeNotFoundError, + ProjectInDebtCanNotChangeWalletError, ProjectInvalidRightsError, ProjectNodeRequiredInputsNotSetError, ProjectNodeResourcesInsufficientRightsError, ProjectNodeResourcesInvalidError, ProjectNotFoundError, ProjectStartsTooManyDynamicNodesError, - ProjectWalletDebtError, ) _logger = logging.getLogger(__name__) @@ -108,7 +108,10 @@ async def wrapper(request: web.Request) -> web.StreamResponse: CatalogItemNotFoundError, ) as exc: raise web.HTTPNotFound(reason=f"{exc}") from exc - except (WalletNotEnoughCreditsError, ProjectWalletDebtError) as exc: + except ( + WalletNotEnoughCreditsError, + ProjectInDebtCanNotChangeWalletError, + ) as exc: raise web.HTTPPaymentRequired(reason=f"{exc}") from exc except ProjectInvalidRightsError as exc: raise web.HTTPUnauthorized(reason=f"{exc}") from exc 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 ba76651b043..9e9eefc78e8 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 @@ -41,11 +41,12 @@ from ._common.models import ProjectPathParams, RequestContext from .exceptions import ( DefaultPricingUnitNotFoundError, + ProjectInDebtCanNotChangeWalletError, + ProjectInDebtCanNotOpenError, ProjectInvalidRightsError, ProjectNotFoundError, ProjectStartsTooManyDynamicNodesError, ProjectTooManyProjectOpenedError, - ProjectWalletDebtError, ) _logger = logging.getLogger(__name__) @@ -76,7 +77,11 @@ async def _wrapper(request: web.Request) -> web.StreamResponse: except ProjectTooManyProjectOpenedError as exc: raise web.HTTPConflict(reason=f"{exc}") from exc - except (WalletNotEnoughCreditsError, ProjectWalletDebtError) as exc: + except ( + WalletNotEnoughCreditsError, + ProjectInDebtCanNotChangeWalletError, + ProjectInDebtCanNotOpenError, + ) as exc: raise web.HTTPPaymentRequired(reason=f"{exc}") from exc return _wrapper diff --git a/services/web/server/src/simcore_service_webserver/projects/_wallets_api.py b/services/web/server/src/simcore_service_webserver/projects/_wallets_api.py index 960e3476821..d6d3c1a50c5 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_wallets_api.py +++ b/services/web/server/src/simcore_service_webserver/projects/_wallets_api.py @@ -14,15 +14,16 @@ credit_transactions, service_runs, ) -from simcore_service_webserver.projects.exceptions import ( - ProjectWalletDebtError, - ProjectWalletPendingTransactionError, -) from ..rabbitmq import get_rabbitmq_rpc_client from ..users import api as users_api from ..wallets import _api as wallets_api from .db import ProjectDBAPI +from .exceptions import ( + ProjectInDebtCanNotChangeWalletError, + ProjectInDebtCanNotOpenError, + ProjectWalletPendingTransactionError, +) async def get_project_wallet(app, project_id: ProjectID): @@ -44,7 +45,7 @@ async def raise_if_project_is_in_debt( rpc_client = get_rabbitmq_rpc_client(app) if current_project_wallet: - # Do not allow to change wallet if the project connected wallet is in DEBT! + # Do not allow to open project if the project connected wallet is in DEBT! project_wallet_credits_in_debt = ( await credit_transactions.get_project_wallet_total_credits( rpc_client, @@ -55,7 +56,7 @@ async def raise_if_project_is_in_debt( ) ) if project_wallet_credits_in_debt.available_osparc_credits < 0: - raise ProjectWalletDebtError( + raise ProjectInDebtCanNotOpenError( debt_amount=project_wallet_credits_in_debt.available_osparc_credits ) @@ -93,7 +94,7 @@ async def connect_wallet_to_project( ) ) if project_wallet_credits_in_debt.available_osparc_credits < 0: - raise ProjectWalletDebtError( + raise ProjectInDebtCanNotChangeWalletError( debt_amount=project_wallet_credits_in_debt.available_osparc_credits ) diff --git a/services/web/server/src/simcore_service_webserver/projects/_wallets_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_wallets_handlers.py index 9c44e18bba4..9dc9668a681 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_wallets_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_wallets_handlers.py @@ -28,9 +28,9 @@ from . import projects_api from ._common.models import ProjectPathParams, RequestContext from .exceptions import ( + ProjectInDebtCanNotChangeWalletError, ProjectInvalidRightsError, ProjectNotFoundError, - ProjectWalletDebtError, ProjectWalletPendingTransactionError, ) @@ -49,7 +49,7 @@ async def wrapper(request: web.Request) -> web.StreamResponse: except WalletNotFoundError as exc: raise web.HTTPNotFound(reason=f"{exc}") from exc - except ProjectWalletDebtError as exc: + except ProjectInDebtCanNotChangeWalletError as exc: raise web.HTTPPaymentRequired(reason=f"{exc}") from exc except ( diff --git a/services/web/server/src/simcore_service_webserver/projects/exceptions.py b/services/web/server/src/simcore_service_webserver/projects/exceptions.py index a2804858502..cfa3506dada 100644 --- a/services/web/server/src/simcore_service_webserver/projects/exceptions.py +++ b/services/web/server/src/simcore_service_webserver/projects/exceptions.py @@ -238,10 +238,12 @@ class ProjectGroupNotFoundError(BaseProjectError): msg_template = "Project group not found. {reason}" -class ProjectWalletDebtError(BaseProjectError): - msg_template = ( - "Project is in debt {debt_amount} credits. It is forbidden to change wallet." - ) +class ProjectInDebtCanNotChangeWalletError(BaseProjectError): + msg_template = "Can not change wallet on a project that is in debt. Project debt {debt_amount} credits. Project wallet {wallet_id}" + + +class ProjectInDebtCanNotOpenError(BaseProjectError): + msg_template = "Can not open project that is in debt. Project debt {debt_amount} credits. Project wallet {wallet_id}" class ProjectWalletPendingTransactionError(BaseProjectError): diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_states_handlers.py b/services/web/server/tests/unit/with_dbs/02/test_projects_states_handlers.py index f1d1933f696..a7e361cd2ff 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_states_handlers.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_states_handlers.py @@ -10,6 +10,7 @@ from collections.abc import Awaitable, Callable, Iterator from copy import deepcopy from datetime import UTC, datetime, timedelta +from decimal import Decimal from http import HTTPStatus from typing import Any from unittest import mock @@ -26,6 +27,9 @@ DynamicServiceStart, DynamicServiceStop, ) +from models_library.api_schemas_resource_usage_tracker.credit_transactions import ( + WalletTotalCredits, +) from models_library.api_schemas_webserver.projects_nodes import NodeGet, NodeGetIdle from models_library.projects import ProjectID from models_library.projects_access import Owner @@ -43,6 +47,7 @@ ) from models_library.utils.fastapi_encoders import jsonable_encoder from pydantic import PositiveInt +from pytest_mock import MockerFixture from pytest_simcore.helpers.assert_checks import assert_status from pytest_simcore.helpers.typing_env import EnvVarsDict from pytest_simcore.helpers.webserver_login import UserInfoDict, log_client_in @@ -54,6 +59,7 @@ from servicelib.aiohttp import status from servicelib.common_headers import UNDEFINED_DEFAULT_SIMCORE_USER_AGENT_VALUE from simcore_postgres_database.models.products import products +from simcore_postgres_database.models.wallets import wallets from simcore_service_webserver._meta import API_VTAG from simcore_service_webserver.db.models import UserRole from simcore_service_webserver.projects.models import ProjectDict @@ -71,7 +77,11 @@ def app_environment( ) -> EnvVarsDict: # disable the garbage collector monkeypatch.setenv("WEBSERVER_GARBAGE_COLLECTOR", "null") - return app_environment | {"WEBSERVER_GARBAGE_COLLECTOR": "null"} + monkeypatch.setenv("WEBSERVER_DEV_FEATURES_ENABLED", "1") + return app_environment | { + "WEBSERVER_GARBAGE_COLLECTOR": "null", + "WEBSERVER_DEV_FEATURES_ENABLED": "1", + } def assert_replaced(current_project, update_data): @@ -405,6 +415,69 @@ async def test_open_project( mocked_notifications_plugin["subscribe"].assert_not_called() +@pytest.fixture +def wallets_clean_db(postgres_db: sa.engine.Engine) -> Iterator[None]: + with postgres_db.connect() as con: + yield + con.execute(wallets.delete()) + + +@pytest.mark.parametrize( + "user_role,expected,return_value_credits", + [ + (UserRole.USER, status.HTTP_200_OK, Decimal(0)), + (UserRole.USER, status.HTTP_402_PAYMENT_REQUIRED, Decimal(-100)), + ], +) +async def test_open_project__in_debt( + client: TestClient, + logged_user: UserInfoDict, + user_project: ProjectDict, + client_session_id_factory: Callable[[], str], + expected: HTTPStatus, + mocked_dynamic_services_interface: dict[str, mock.Mock], + mock_service_resources: ServiceResourcesDict, + mock_orphaned_services: mock.Mock, + mock_catalog_api: dict[str, mock.Mock], + osparc_product_name: str, + mocked_notifications_plugin: dict[str, mock.Mock], + return_value_credits: Decimal, + mocker: MockerFixture, + wallets_clean_db: None, +): + # create a new wallet + url = client.app.router["create_wallet"].url_for() + resp = await client.post( + f"{url}", json={"name": "My first wallet", "description": "Custom description"} + ) + added_wallet, _ = await assert_status(resp, status.HTTP_201_CREATED) + + mock_get_project_wallet_total_credits = mocker.patch( + "simcore_service_webserver.projects._wallets_api.credit_transactions.get_project_wallet_total_credits", + spec=True, + return_value=WalletTotalCredits( + wallet_id=added_wallet["walletId"], + available_osparc_credits=return_value_credits, + ), + ) + + # Connect project to a wallet + base_url = client.app.router["connect_wallet_to_project"].url_for( + project_id=user_project["uuid"], wallet_id=f"{added_wallet['walletId']}" + ) + resp = await client.put(f"{base_url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert data["walletId"] == added_wallet["walletId"] + + # POST /v0/projects/{project_id}:open + assert client.app + url = client.app.router["open_project"].url_for(project_id=user_project["uuid"]) + resp = await client.post(f"{url}", json=client_session_id_factory()) + await assert_status(resp, expected) + + assert mock_get_project_wallet_total_credits.assert_called_once + + @pytest.mark.parametrize( "user_role,expected,save_state", [ From 80ccea26fe7f8583a2ad69ef5906580c941b2bb6 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Sat, 18 Jan 2025 14:45:37 +0100 Subject: [PATCH 11/17] openapi specs --- services/resource-usage-tracker/openapi.json | 44 ------------------- .../api/v0/openapi.yaml | 2 + 2 files changed, 2 insertions(+), 44 deletions(-) diff --git a/services/resource-usage-tracker/openapi.json b/services/resource-usage-tracker/openapi.json index af7e065a385..b267c3f0a9e 100644 --- a/services/resource-usage-tracker/openapi.json +++ b/services/resource-usage-tracker/openapi.json @@ -72,39 +72,6 @@ "title": "Wallet Id", "minimum": 0 } - }, - { - "name": "transaction_status", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "$ref": "#/components/schemas/CreditTransactionStatus" - }, - { - "type": "null" - } - ], - "title": "Transaction Status" - } - }, - { - "name": "project_id", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string", - "format": "uuid" - }, - { - "type": "null" - } - ], - "title": "Project Id" - } } ], "responses": { @@ -379,17 +346,6 @@ "title": "CreditTransactionCreated", "description": "Response Create Credit Transaction V1 Credit Transactions Post" }, - "CreditTransactionStatus": { - "type": "string", - "enum": [ - "PENDING", - "BILLED", - "IN_DEBT", - "NOT_BILLED", - "REQUIRES_MANUAL_REVIEW" - ], - "title": "CreditTransactionStatus" - }, "HTTPValidationError": { "properties": { "detail": { diff --git a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml index c7a33c6fa02..84e6f9ca1df 100644 --- a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml +++ b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml @@ -5181,6 +5181,8 @@ paths: schema: anyOf: - type: number + exclusiveMaximum: true + maximum: 0.0 - type: string title: Amount responses: From 9b8685f410c5d5f4ab83dc4dccce3a2a59e6f3ec Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Sat, 18 Jan 2025 15:00:23 +0100 Subject: [PATCH 12/17] fix --- .../api/rpc/_credit_transactions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_credit_transactions.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_credit_transactions.py index d06d07cfad7..c445196c21c 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_credit_transactions.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_credit_transactions.py @@ -54,7 +54,7 @@ async def pay_project_debt( current_wallet_transaction: CreditTransactionCreateBody, new_wallet_transaction: CreditTransactionCreateBody, ) -> None: - return await credit_transactions.pay_project_debt( + await credit_transactions.pay_project_debt( db_engine=app.state.engine, rabbitmq_client=app.state.rabbitmq_client, rut_fire_and_forget_tasks=app.state.rut_fire_and_forget_tasks, From 61bbbf9748ce3219a8f6ef4beeaa831acfcce12d Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Sat, 18 Jan 2025 15:08:20 +0100 Subject: [PATCH 13/17] fix --- .../src/simcore_service_webserver/projects/_wallets_api.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/projects/_wallets_api.py b/services/web/server/src/simcore_service_webserver/projects/_wallets_api.py index d6d3c1a50c5..4eb8657cb13 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_wallets_api.py +++ b/services/web/server/src/simcore_service_webserver/projects/_wallets_api.py @@ -57,7 +57,8 @@ async def raise_if_project_is_in_debt( ) if project_wallet_credits_in_debt.available_osparc_credits < 0: raise ProjectInDebtCanNotOpenError( - debt_amount=project_wallet_credits_in_debt.available_osparc_credits + debt_amount=project_wallet_credits_in_debt.available_osparc_credits, + wallet_id=current_project_wallet.wallet_id, ) @@ -95,7 +96,8 @@ async def connect_wallet_to_project( ) if project_wallet_credits_in_debt.available_osparc_credits < 0: raise ProjectInDebtCanNotChangeWalletError( - debt_amount=project_wallet_credits_in_debt.available_osparc_credits + debt_amount=project_wallet_credits_in_debt.available_osparc_credits, + wallet_id=current_project_wallet.wallet_id, ) # Do not allow to change wallet if the project has transaction in PENDING! From 4bec4906b1df59d71b83259e30375d8b4ac735fd Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Mon, 20 Jan 2025 11:28:44 +0100 Subject: [PATCH 14/17] review @sanderegg --- .../resource_usage_tracker/errors.py | 7 +++++++ .../services/fire_and_forget_setup.py | 3 ++- .../api/rpc/_credit_transactions.py | 5 ++++- .../services/credit_transactions.py | 15 +++++++++------ .../services/fire_and_forget_setup.py | 3 ++- .../tests/unit/with_dbs/conftest.py | 5 ++--- .../with_dbs/test_api_credit_transactions.py | 17 +++++++++++------ ...est_process_rabbitmq_message_with_billing.py | 17 ++++++++++++----- .../projects/_states_handlers.py | 2 +- .../projects/_wallets_api.py | 2 +- .../simcore_service_webserver/projects/api.py | 4 ++-- 11 files changed, 53 insertions(+), 27 deletions(-) diff --git a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/errors.py b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/errors.py index 42e578ee482..4e24b323edc 100644 --- a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/errors.py +++ b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/errors.py @@ -27,3 +27,10 @@ class LicensedItemCheckoutNotFoundError(LicensesBaseError): CanNotCheckoutServiceIsNotRunningError, LicensedItemCheckoutNotFoundError, ) + + +### Transaction Error + + +class WalletTransactionError(OsparcErrorMixin, Exception): + msg_template = "{msg}" diff --git a/services/efs-guardian/src/simcore_service_efs_guardian/services/fire_and_forget_setup.py b/services/efs-guardian/src/simcore_service_efs_guardian/services/fire_and_forget_setup.py index 379e46753ae..a38411f56a1 100644 --- a/services/efs-guardian/src/simcore_service_efs_guardian/services/fire_and_forget_setup.py +++ b/services/efs-guardian/src/simcore_service_efs_guardian/services/fire_and_forget_setup.py @@ -2,6 +2,7 @@ from collections.abc import Awaitable, Callable from fastapi import FastAPI +from servicelib.async_utils import cancel_wait_task from servicelib.logging_utils import log_catch, log_context _logger = logging.getLogger(__name__) @@ -27,7 +28,7 @@ async def _stop() -> None: assert _app # nosec if _app.state.efs_guardian_fire_and_forget_tasks: for task in _app.state.efs_guardian_fire_and_forget_tasks: - task.cancel() + await cancel_wait_task(task) return _stop diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_credit_transactions.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_credit_transactions.py index c445196c21c..6e7cfcd5ad0 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_credit_transactions.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_credit_transactions.py @@ -8,6 +8,9 @@ from models_library.resource_tracker import CreditTransactionStatus from models_library.wallets import WalletID from servicelib.rabbitmq import RPCRouter +from servicelib.rabbitmq.rpc_interfaces.resource_usage_tracker.errors import ( + WalletTransactionError, +) from ...services import credit_transactions, service_runs @@ -46,7 +49,7 @@ async def get_project_wallet_total_credits( ) -@router.expose(reraise_if_error_type=(ValueError,)) +@router.expose(reraise_if_error_type=(WalletTransactionError,)) async def pay_project_debt( app: FastAPI, *, diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/credit_transactions.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/credit_transactions.py index 9bcf201ddae..b13d8bfa7f7 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/credit_transactions.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/credit_transactions.py @@ -14,6 +14,9 @@ ) from models_library.wallets import WalletID from servicelib.rabbitmq import RabbitMQClient +from servicelib.rabbitmq.rpc_interfaces.resource_usage_tracker.errors import ( + WalletTransactionError, +) from servicelib.utils import fire_and_forget_task from simcore_postgres_database.utils_repos import transaction_context from sqlalchemy.ext.asyncio import AsyncEngine @@ -111,16 +114,16 @@ async def pay_project_debt( != new_wallet_transaction.osparc_credits ): msg = f"Project DEBT of {total_project_debt_amount.available_osparc_credits} does not equal to payment: new_wallet {new_wallet_transaction.wallet_id} credits {new_wallet_transaction.osparc_credits}, current wallet {current_wallet_transaction.wallet_id} credits {current_wallet_transaction.osparc_credits}" - raise ValueError(msg) + raise WalletTransactionError(msg=msg) if ( -total_project_debt_amount.available_osparc_credits != current_wallet_transaction.osparc_credits ): msg = f"Project DEBT of {total_project_debt_amount.available_osparc_credits} does not equal to payment: new_wallet {new_wallet_transaction.wallet_id} credits {new_wallet_transaction.osparc_credits}, current wallet {current_wallet_transaction.wallet_id} credits {current_wallet_transaction.osparc_credits}" - raise ValueError(msg) + raise WalletTransactionError(msg=msg) if current_wallet_transaction.product_name != new_wallet_transaction.product_name: msg = f"Currently we do not support credit exchange between different products. New wallet {new_wallet_transaction.wallet_id}, current wallet {current_wallet_transaction.wallet_id}" - raise ValueError(msg) + raise WalletTransactionError(msg=msg) # Does the new wallet has enough credits to pay the debt? new_wallet_total_credit_amount = await credit_transactions_db.sum_wallet_credits( @@ -134,7 +137,7 @@ async def pay_project_debt( < 0 ): msg = f"New wallet {new_wallet_transaction.wallet_id} doesn't have enough credits {new_wallet_total_credit_amount.available_osparc_credits} to pay the debt {total_project_debt_amount.available_osparc_credits} of current wallet {current_wallet_transaction.wallet_id}" - raise ValueError(msg) + raise WalletTransactionError(msg=msg) new_wallet_transaction_create = CreditTransactionCreate( product_name=new_wallet_transaction.product_name, @@ -195,7 +198,7 @@ async def pay_project_debt( db_engine, rabbitmq_client, new_wallet_transaction_create.product_name, - new_wallet_transaction_create.wallet_id, + new_wallet_transaction_create.wallet_id, # <-- New wallet ), task_suffix_name=f"sum_and_publish_credits_wallet_id{new_wallet_transaction_create.wallet_id}", fire_and_forget_tasks_collection=rut_fire_and_forget_tasks, @@ -205,7 +208,7 @@ async def pay_project_debt( db_engine, rabbitmq_client, current_wallet_transaction_create.product_name, - current_wallet_transaction_create.wallet_id, + current_wallet_transaction_create.wallet_id, # <-- Current wallet ), task_suffix_name=f"sum_and_publish_credits_wallet_id{current_wallet_transaction_create.wallet_id}", fire_and_forget_tasks_collection=rut_fire_and_forget_tasks, diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/fire_and_forget_setup.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/fire_and_forget_setup.py index bb2d7ae4d37..2523a069974 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/fire_and_forget_setup.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/fire_and_forget_setup.py @@ -2,6 +2,7 @@ from collections.abc import Awaitable, Callable from fastapi import FastAPI +from servicelib.async_utils import cancel_wait_task from servicelib.logging_utils import log_catch, log_context _logger = logging.getLogger(__name__) @@ -31,7 +32,7 @@ async def _stop() -> None: assert _app # nosec if _app.state.rut_fire_and_forget_tasks: for task in _app.state.rut_fire_and_forget_tasks: - task.cancel() + await cancel_wait_task(task) return _stop diff --git a/services/resource-usage-tracker/tests/unit/with_dbs/conftest.py b/services/resource-usage-tracker/tests/unit/with_dbs/conftest.py index 5a7e0008c68..f09623ed8f6 100644 --- a/services/resource-usage-tracker/tests/unit/with_dbs/conftest.py +++ b/services/resource-usage-tracker/tests/unit/with_dbs/conftest.py @@ -25,6 +25,7 @@ from servicelib.rabbitmq import RabbitMQRPCClient from settings_library.rabbit import RabbitSettings from simcore_postgres_database.models.resource_tracker_credit_transactions import ( + CreditTransactionClassification, resource_tracker_credit_transactions, ) from simcore_postgres_database.models.resource_tracker_service_runs import ( @@ -136,9 +137,7 @@ def _creator(**overrides) -> dict[str, Any]: "user_id": faker.pyint(), "user_email": faker.email(), "osparc_credits": -abs(faker.pyfloat()), - "transaction_status": choice( - ["BILLED", "PENDING", "NOT_BILLED", "IN_DEBT"] - ), + "transaction_status": choice(list(CreditTransactionClassification)), "transaction_classification": "DEDUCT_SERVICE_RUN", "service_run_id": faker.uuid4(), "payment_transaction_id": faker.uuid4(), diff --git a/services/resource-usage-tracker/tests/unit/with_dbs/test_api_credit_transactions.py b/services/resource-usage-tracker/tests/unit/with_dbs/test_api_credit_transactions.py index d69304bd350..baf61f3b7fc 100644 --- a/services/resource-usage-tracker/tests/unit/with_dbs/test_api_credit_transactions.py +++ b/services/resource-usage-tracker/tests/unit/with_dbs/test_api_credit_transactions.py @@ -23,6 +23,9 @@ credit_transactions, service_runs, ) +from servicelib.rabbitmq.rpc_interfaces.resource_usage_tracker.errors import ( + WalletTransactionError, +) from simcore_postgres_database.models.resource_tracker_credit_transactions import ( resource_tracker_credit_transactions, ) @@ -63,6 +66,7 @@ async def test_credit_transactions_workflow( async_client: httpx.AsyncClient, resource_tracker_credit_transactions_db: None, rpc_client: RabbitMQRPCClient, + faker: Faker, ): url = URL("/v1/credit-transactions") @@ -255,7 +259,7 @@ def resource_tracker_setup_db( # We will add 2 more wallets for paying a debt test random_resource_tracker_credit_transactions( user_id=_USER_ID_1, - osparc_credits=50, # <-- Not enough credits to pay the DEBT (-100) + osparc_credits=50, # <-- Not enough credits to pay the DEBT of -100 service_run_id=None, product_name=product_name, payment_transaction_id="INVITATION", @@ -265,7 +269,7 @@ def resource_tracker_setup_db( ), random_resource_tracker_credit_transactions( user_id=_USER_ID_1, - osparc_credits=500, # <-- Enough credits to pay the DEBT (-100) + osparc_credits=500, # <-- Enough credits to pay the DEBT of -100 service_run_id=None, product_name=product_name, transaction_status=CreditTransactionStatus.BILLED, @@ -318,6 +322,7 @@ async def test_pay_project_debt( rpc_client: RabbitMQRPCClient, project_id: ProjectID, product_name: ProductName, + faker: Faker, ): total_wallet_credits_for_wallet_in_debt_in_beginning = ( await credit_transactions.get_wallet_total_credits( @@ -344,7 +349,7 @@ async def test_pay_project_debt( wallet_id=_WALLET_ID_FOR_PAYING_DEBT__NOT_ENOUGH_CREDITS, wallet_name="new wallet", user_id=_USER_ID_1, - user_email="test@test.com", + user_email=faker.email(), osparc_credits=_project_debt_amount - 50, # <-- Negative number payment_transaction_id=f"Payment transaction from wallet {_WALLET_ID} to wallet {_WALLET_ID_FOR_PAYING_DEBT__NOT_ENOUGH_CREDITS}", created_at=datetime.now(UTC), @@ -354,13 +359,13 @@ async def test_pay_project_debt( wallet_id=_WALLET_ID, wallet_name="current wallet", user_id=_USER_ID_1, - user_email="test@test.com", + user_email=faker.email(), osparc_credits=-_project_debt_amount, # <-- Positive number payment_transaction_id=f"Payment transaction from wallet {_WALLET_ID_FOR_PAYING_DEBT__NOT_ENOUGH_CREDITS} to wallet {_WALLET_ID}", created_at=datetime.now(UTC), ) - with pytest.raises(ValueError): + with pytest.raises(WalletTransactionError): await credit_transactions.pay_project_debt( rpc_client, project_id=project_id, @@ -390,7 +395,7 @@ async def test_pay_project_debt( created_at=datetime.now(UTC), ) - with pytest.raises(ValueError): + with pytest.raises(WalletTransactionError): await credit_transactions.pay_project_debt( rpc_client, project_id=project_id, diff --git a/services/resource-usage-tracker/tests/unit/with_dbs/test_process_rabbitmq_message_with_billing.py b/services/resource-usage-tracker/tests/unit/with_dbs/test_process_rabbitmq_message_with_billing.py index 2ea005af907..3dac5fffe1a 100644 --- a/services/resource-usage-tracker/tests/unit/with_dbs/test_process_rabbitmq_message_with_billing.py +++ b/services/resource-usage-tracker/tests/unit/with_dbs/test_process_rabbitmq_message_with_billing.py @@ -12,7 +12,11 @@ SimcorePlatformStatus, WalletCreditsLimitReachedMessage, ) -from models_library.resource_tracker import UnitExtraInfo +from models_library.resource_tracker import ( + CreditClassification, + CreditTransactionStatus, + UnitExtraInfo, +) from pytest_mock.plugin import MockerFixture from servicelib.rabbitmq import RabbitMQClient from simcore_postgres_database.models.resource_tracker_credit_transactions import ( @@ -208,8 +212,11 @@ async def test_process_event_functions( await _process_start_event(engine, msg, publisher) output = await assert_credit_transactions_db_row(postgres_db, msg.service_run_id) assert output.osparc_credits == 0.0 - assert output.transaction_status == "PENDING" - assert output.transaction_classification == "DEDUCT_SERVICE_RUN" + assert output.transaction_status == CreditTransactionStatus.PENDING.value + assert ( + output.transaction_classification + == CreditClassification.DEDUCT_SERVICE_RUN.value + ) first_occurence_of_last_heartbeat_at = output.last_heartbeat_at modified_at = output.modified @@ -223,7 +230,7 @@ async def test_process_event_functions( ) first_credits_used = output.osparc_credits assert first_credits_used < 0.0 - assert output.transaction_status == "PENDING" + assert output.transaction_status == CreditTransactionStatus.PENDING.value assert first_occurence_of_last_heartbeat_at < output.last_heartbeat_at modified_at = output.modified @@ -240,7 +247,7 @@ async def test_process_event_functions( postgres_db, msg.service_run_id, modified_at ) assert output.osparc_credits < first_credits_used - assert output.transaction_status == "IN_DEBT" + assert output.transaction_status == CreditTransactionStatus.IN_DEBT.value async for attempt in AsyncRetrying( wait=wait_fixed(0.1), 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 9e9eefc78e8..bab3558b3cc 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 @@ -134,7 +134,7 @@ async def open_project(request: web.Request) -> web.Response: ), ) - await projects_service.raise_if_project_is_in_debt( + await projects_service.check_project_financial_status( request.app, project_id=path_params.project_id, product_name=req_ctx.product_name, diff --git a/services/web/server/src/simcore_service_webserver/projects/_wallets_api.py b/services/web/server/src/simcore_service_webserver/projects/_wallets_api.py index 4eb8657cb13..1610cb4c363 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_wallets_api.py +++ b/services/web/server/src/simcore_service_webserver/projects/_wallets_api.py @@ -36,7 +36,7 @@ async def get_project_wallet(app, project_id: ProjectID): return wallet -async def raise_if_project_is_in_debt( +async def check_project_financial_status( app, *, project_id: ProjectID, product_name: ProductName ): db: ProjectDBAPI = ProjectDBAPI.get_from_app_context(app) diff --git a/services/web/server/src/simcore_service_webserver/projects/api.py b/services/web/server/src/simcore_service_webserver/projects/api.py index e75add4e898..ba5f5ae14fb 100644 --- a/services/web/server/src/simcore_service_webserver/projects/api.py +++ b/services/web/server/src/simcore_service_webserver/projects/api.py @@ -12,9 +12,9 @@ from ._permalink_api import ProjectPermalink from ._permalink_api import register_factory as register_permalink_factory from ._wallets_api import ( + check_project_financial_status, connect_wallet_to_project, get_project_wallet, - raise_if_project_is_in_debt, ) __all__: tuple[str, ...] = ( @@ -26,7 +26,7 @@ "has_user_project_access_rights", "ProjectPermalink", "register_permalink_factory", - "raise_if_project_is_in_debt", + "check_project_financial_status", ) From 2ce999a10050f4840697c13472fe2ca76d709de1 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Mon, 20 Jan 2025 12:09:56 +0100 Subject: [PATCH 15/17] review @sanderegg --- .../tests/unit/test_enums.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 services/resource-usage-tracker/tests/unit/test_enums.py diff --git a/services/resource-usage-tracker/tests/unit/test_enums.py b/services/resource-usage-tracker/tests/unit/test_enums.py new file mode 100644 index 00000000000..5910692e68d --- /dev/null +++ b/services/resource-usage-tracker/tests/unit/test_enums.py @@ -0,0 +1,25 @@ +# pylint:disable=unused-variable +# pylint:disable=unused-argument +# pylint:disable=redefined-outer-name + + +from models_library import resource_tracker +from simcore_postgres_database.models.resource_tracker_credit_transactions import ( + CreditTransactionClassification, + CreditTransactionStatus, +) +from simcore_postgres_database.models.resource_tracker_service_runs import ( + ResourceTrackerServiceRunStatus, +) + + +def test_postgres_and_models_library_enums_are_in_sync(): + assert list(resource_tracker.CreditTransactionStatus) == list( + CreditTransactionStatus + ) + assert list(resource_tracker.CreditClassification) == list( + CreditTransactionClassification + ) + assert list(resource_tracker.ServiceRunStatus) == list( + ResourceTrackerServiceRunStatus + ) From 2e56581991b26126f95c3763f2c31163455f64c9 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Mon, 20 Jan 2025 13:07:20 +0100 Subject: [PATCH 16/17] fix --- .../resource-usage-tracker/tests/unit/with_dbs/conftest.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/services/resource-usage-tracker/tests/unit/with_dbs/conftest.py b/services/resource-usage-tracker/tests/unit/with_dbs/conftest.py index f09623ed8f6..2322afe8339 100644 --- a/services/resource-usage-tracker/tests/unit/with_dbs/conftest.py +++ b/services/resource-usage-tracker/tests/unit/with_dbs/conftest.py @@ -137,7 +137,9 @@ def _creator(**overrides) -> dict[str, Any]: "user_id": faker.pyint(), "user_email": faker.email(), "osparc_credits": -abs(faker.pyfloat()), - "transaction_status": choice(list(CreditTransactionClassification)), + "transaction_status": choice( + [member.value for member in CreditTransactionClassification] + ), "transaction_classification": "DEDUCT_SERVICE_RUN", "service_run_id": faker.uuid4(), "payment_transaction_id": faker.uuid4(), From b19a70686330b7ef09fec9d4e554b67a5f12157b Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Mon, 20 Jan 2025 14:12:15 +0100 Subject: [PATCH 17/17] fix --- .../resource-usage-tracker/tests/unit/with_dbs/conftest.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/services/resource-usage-tracker/tests/unit/with_dbs/conftest.py b/services/resource-usage-tracker/tests/unit/with_dbs/conftest.py index 2322afe8339..bd0cd8aaac3 100644 --- a/services/resource-usage-tracker/tests/unit/with_dbs/conftest.py +++ b/services/resource-usage-tracker/tests/unit/with_dbs/conftest.py @@ -19,6 +19,7 @@ RabbitResourceTrackingMessageType, RabbitResourceTrackingStartedMessage, ) +from models_library.resource_tracker import CreditTransactionStatus from pydantic import TypeAdapter from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict from pytest_simcore.helpers.typing_env import EnvVarsDict @@ -138,9 +139,9 @@ def _creator(**overrides) -> dict[str, Any]: "user_email": faker.email(), "osparc_credits": -abs(faker.pyfloat()), "transaction_status": choice( - [member.value for member in CreditTransactionClassification] + [member.value for member in CreditTransactionStatus] ), - "transaction_classification": "DEDUCT_SERVICE_RUN", + "transaction_classification": CreditTransactionClassification.DEDUCT_SERVICE_RUN.value, "service_run_id": faker.uuid4(), "payment_transaction_id": faker.uuid4(), "created": datetime.now(tz=timezone.utc),