diff --git a/packages/models-library/src/models_library/api_schemas_resource_usage_tracker/licensed_items_checkouts.py b/packages/models-library/src/models_library/api_schemas_resource_usage_tracker/licensed_items_checkouts.py new file mode 100644 index 000000000000..00e136d79efb --- /dev/null +++ b/packages/models-library/src/models_library/api_schemas_resource_usage_tracker/licensed_items_checkouts.py @@ -0,0 +1,47 @@ +from datetime import datetime +from typing import NamedTuple + +from models_library.licensed_items import LicensedItemID +from models_library.products import ProductName +from models_library.resource_tracker import ServiceRunId +from models_library.resource_tracker_licensed_items_checkouts import ( + LicensedItemCheckoutID, +) +from models_library.users import UserID +from models_library.wallets import WalletID +from pydantic import BaseModel, ConfigDict, PositiveInt + + +class LicensedItemCheckoutGet(BaseModel): + licensed_item_checkout_id: LicensedItemCheckoutID + licensed_item_id: LicensedItemID + wallet_id: WalletID + user_id: UserID + product_name: ProductName + service_run_id: ServiceRunId + started_at: datetime + stopped_at: datetime | None + num_of_seats: int + + model_config = ConfigDict( + json_schema_extra={ + "examples": [ + { + "licensed_item_checkout_id": "beb16d18-d57d-44aa-a638-9727fa4a72ef", + "licensed_item_id": "303942ef-6d31-4ba8-afbe-dbb1fce2a953", + "wallet_id": 1, + "user_id": 1, + "product_name": "osparc", + "service_run_id": "run_1", + "started_at": "2023-01-11 13:11:47.293595", + "stopped_at": "2023-01-11 13:11:47.293595", + "num_of_seats": 1, + } + ] + } + ) + + +class LicensedItemsCheckoutsPage(NamedTuple): + items: list[LicensedItemCheckoutGet] + total: PositiveInt diff --git a/packages/models-library/src/models_library/api_schemas_webserver/licensed_items_checkouts.py b/packages/models-library/src/models_library/api_schemas_webserver/licensed_items_checkouts.py new file mode 100644 index 000000000000..a3ee122ddee7 --- /dev/null +++ b/packages/models-library/src/models_library/api_schemas_webserver/licensed_items_checkouts.py @@ -0,0 +1,27 @@ +from datetime import datetime +from typing import NamedTuple + +from pydantic import PositiveInt + +from ..licensed_items import LicensedItemID +from ..products import ProductName +from ..resource_tracker_licensed_items_checkouts import LicensedItemCheckoutID +from ..users import UserID +from ..wallets import WalletID +from ._base import OutputSchema + + +class LicensedItemCheckoutGet(OutputSchema): + licensed_item_checkout_id: LicensedItemCheckoutID + licensed_item_id: LicensedItemID + wallet_id: WalletID + user_id: UserID + product_name: ProductName + started_at: datetime + stopped_at: datetime | None + num_of_seats: int + + +class LicensedItemUsageGetPage(NamedTuple): + items: list[LicensedItemCheckoutGet] + total: PositiveInt diff --git a/packages/models-library/src/models_library/api_schemas_webserver/licensed_items_purchases.py b/packages/models-library/src/models_library/api_schemas_webserver/licensed_items_purchases.py index 0264e713256e..2f413f3d10fc 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/licensed_items_purchases.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/licensed_items_purchases.py @@ -2,16 +2,14 @@ from decimal import Decimal from typing import NamedTuple -from models_library.licensed_items import LicensedItemID -from models_library.products import ProductName -from models_library.resource_tracker import PricingUnitCostId -from models_library.resource_tracker_licensed_items_purchases import ( - LicensedItemPurchaseID, -) -from models_library.users import UserID -from models_library.wallets import WalletID from pydantic import PositiveInt +from ..licensed_items import LicensedItemID +from ..products import ProductName +from ..resource_tracker import PricingUnitCostId +from ..resource_tracker_licensed_items_purchases import LicensedItemPurchaseID +from ..users import UserID +from ..wallets import WalletID from ._base import OutputSchema diff --git a/packages/models-library/src/models_library/resource_tracker_licensed_items_checkouts.py b/packages/models-library/src/models_library/resource_tracker_licensed_items_checkouts.py new file mode 100644 index 000000000000..cd09440b8221 --- /dev/null +++ b/packages/models-library/src/models_library/resource_tracker_licensed_items_checkouts.py @@ -0,0 +1,4 @@ +from typing import TypeAlias +from uuid import UUID + +LicensedItemCheckoutID: TypeAlias = UUID diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/aa6da21a0055_rename_usages_to_checkouts.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/aa6da21a0055_rename_usages_to_checkouts.py new file mode 100644 index 000000000000..882be09dd2cb --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/aa6da21a0055_rename_usages_to_checkouts.py @@ -0,0 +1,134 @@ +"""rename usages to checkouts + +Revision ID: aa6da21a0055 +Revises: 52a0e8148dd5 +Create Date: 2024-12-17 13:47:09.304574+00:00 + +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "aa6da21a0055" +down_revision = "52a0e8148dd5" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "resource_tracker_licensed_items_checkouts", + sa.Column( + "licensed_item_checkout_id", + postgresql.UUID(as_uuid=True), + server_default=sa.text("gen_random_uuid()"), + nullable=False, + ), + sa.Column("licensed_item_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("wallet_id", sa.BigInteger(), nullable=False), + sa.Column("user_id", sa.BigInteger(), nullable=False), + sa.Column("user_email", sa.String(), nullable=True), + sa.Column("product_name", sa.String(), nullable=False), + sa.Column("service_run_id", sa.String(), nullable=True), + sa.Column("started_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("stopped_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("num_of_seats", sa.SmallInteger(), nullable=False), + sa.Column( + "modified", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["product_name", "service_run_id"], + [ + "resource_tracker_service_runs.product_name", + "resource_tracker_service_runs.service_run_id", + ], + name="resource_tracker_license_checkouts_service_run_id_fkey", + onupdate="CASCADE", + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("licensed_item_checkout_id"), + ) + op.create_index( + op.f("ix_resource_tracker_licensed_items_checkouts_wallet_id"), + "resource_tracker_licensed_items_checkouts", + ["wallet_id"], + unique=False, + ) + op.drop_index( + "ix_resource_tracker_licensed_items_usage_wallet_id", + table_name="resource_tracker_licensed_items_usage", + ) + op.drop_table("resource_tracker_licensed_items_usage") + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "resource_tracker_licensed_items_usage", + sa.Column( + "licensed_item_usage_id", + postgresql.UUID(), + server_default=sa.text("gen_random_uuid()"), + autoincrement=False, + nullable=False, + ), + sa.Column("wallet_id", sa.BIGINT(), autoincrement=False, nullable=False), + sa.Column("user_id", sa.BIGINT(), autoincrement=False, nullable=False), + sa.Column("user_email", sa.VARCHAR(), autoincrement=False, nullable=True), + sa.Column("product_name", sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column("service_run_id", sa.VARCHAR(), autoincrement=False, nullable=True), + sa.Column( + "started_at", + postgresql.TIMESTAMP(timezone=True), + autoincrement=False, + nullable=False, + ), + sa.Column( + "stopped_at", + postgresql.TIMESTAMP(timezone=True), + autoincrement=False, + nullable=True, + ), + sa.Column("num_of_seats", sa.SMALLINT(), autoincrement=False, nullable=False), + sa.Column( + "modified", + postgresql.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + autoincrement=False, + nullable=False, + ), + sa.Column( + "licensed_item_id", postgresql.UUID(), autoincrement=False, nullable=False + ), + sa.ForeignKeyConstraint( + ["product_name", "service_run_id"], + [ + "resource_tracker_service_runs.product_name", + "resource_tracker_service_runs.service_run_id", + ], + name="resource_tracker_license_checkouts_service_run_id_fkey", + onupdate="CASCADE", + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint( + "licensed_item_usage_id", name="resource_tracker_licensed_items_usage_pkey" + ), + ) + op.create_index( + "ix_resource_tracker_licensed_items_usage_wallet_id", + "resource_tracker_licensed_items_usage", + ["wallet_id"], + unique=False, + ) + op.drop_index( + op.f("ix_resource_tracker_licensed_items_checkouts_wallet_id"), + table_name="resource_tracker_licensed_items_checkouts", + ) + op.drop_table("resource_tracker_licensed_items_checkouts") + # ### end Alembic commands ### diff --git a/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_licensed_items_usage.py b/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_licensed_items_checkouts.py similarity index 91% rename from packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_licensed_items_usage.py rename to packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_licensed_items_checkouts.py index 27d6afe82500..e3cabb899f7a 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_licensed_items_usage.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_licensed_items_checkouts.py @@ -7,11 +7,11 @@ from ._common import RefActions, column_modified_datetime from .base import metadata -resource_tracker_licensed_items_usage = sa.Table( - "resource_tracker_licensed_items_usage", +resource_tracker_licensed_items_checkouts = sa.Table( + "resource_tracker_licensed_items_checkouts", metadata, sa.Column( - "licensed_item_usage_id", + "licensed_item_checkout_id", UUID(as_uuid=True), nullable=False, primary_key=True, @@ -19,7 +19,7 @@ ), sa.Column( "licensed_item_id", - sa.String, + UUID(as_uuid=True), nullable=True, ), sa.Column( 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 new file mode 100644 index 000000000000..42e578ee482c --- /dev/null +++ b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/errors.py @@ -0,0 +1,29 @@ +from common_library.errors_classes import OsparcErrorMixin + + +class LicensesBaseError(OsparcErrorMixin, Exception): + ... + + +class NotEnoughAvailableSeatsError(LicensesBaseError): + msg_template = "Not enough available seats. Current available seats {available_num_of_seats} for license item {license_item_id}" + + +class CanNotCheckoutNotEnoughAvailableSeatsError(LicensesBaseError): + msg_template = "Can not checkout license item {licensed_item_id} with num of seats {num_of_seats}. Currently available seats {available_num_of_seats}" + + +class CanNotCheckoutServiceIsNotRunningError(LicensesBaseError): + msg_template = "Can not checkout license item {licensed_item_id} as dynamic service is not running. Current service {service_run}" + + +class LicensedItemCheckoutNotFoundError(LicensesBaseError): + msg_template = "Licensed item checkout {licensed_item_checkout_id} not found." + + +LICENSES_ERRORS = ( + NotEnoughAvailableSeatsError, + CanNotCheckoutNotEnoughAvailableSeatsError, + CanNotCheckoutServiceIsNotRunningError, + LicensedItemCheckoutNotFoundError, +) diff --git a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/licensed_items_checkouts.py b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/licensed_items_checkouts.py new file mode 100644 index 000000000000..62032c63383a --- /dev/null +++ b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/licensed_items_checkouts.py @@ -0,0 +1,126 @@ +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.licensed_items_checkouts import ( + LicensedItemCheckoutGet, + LicensedItemsCheckoutsPage, +) +from models_library.basic_types import IDStr +from models_library.licensed_items import LicensedItemID +from models_library.products import ProductName +from models_library.rabbitmq_basic_types import RPCMethodName +from models_library.resource_tracker import ServiceRunId +from models_library.resource_tracker_licensed_items_checkouts import ( + LicensedItemCheckoutID, +) +from models_library.rest_ordering import OrderBy +from models_library.users import UserID +from models_library.wallets import WalletID +from pydantic import NonNegativeInt, TypeAdapter + +from ....logging_utils import log_decorator +from ... import RabbitMQRPCClient + +_logger = logging.getLogger(__name__) + + +_DEFAULT_TIMEOUT_S: Final[NonNegativeInt] = 30 + +_RPC_METHOD_NAME_ADAPTER: TypeAdapter[RPCMethodName] = TypeAdapter(RPCMethodName) + + +@log_decorator(_logger, level=logging.DEBUG) +async def get_licensed_item_checkout( + rabbitmq_rpc_client: RabbitMQRPCClient, + *, + product_name: ProductName, + licensed_item_checkout_id: LicensedItemCheckoutID, +) -> LicensedItemCheckoutGet: + result = await rabbitmq_rpc_client.request( + RESOURCE_USAGE_TRACKER_RPC_NAMESPACE, + _RPC_METHOD_NAME_ADAPTER.validate_python("get_licensed_item_checkout"), + product_name=product_name, + licensed_item_checkout_id=licensed_item_checkout_id, + timeout_s=_DEFAULT_TIMEOUT_S, + ) + assert isinstance(result, LicensedItemCheckoutGet) # nosec + return result + + +@log_decorator(_logger, level=logging.DEBUG) +async def get_licensed_items_checkouts_page( + rabbitmq_rpc_client: RabbitMQRPCClient, + *, + product_name: ProductName, + filter_wallet_id: WalletID, + offset: int = 0, + limit: int = 20, + order_by: OrderBy | None = None, +) -> LicensedItemsCheckoutsPage: + """ + Default order_by field is "started_at" + """ + if order_by is None: + order_by = OrderBy(field=IDStr("started_at")) + + result = await rabbitmq_rpc_client.request( + RESOURCE_USAGE_TRACKER_RPC_NAMESPACE, + _RPC_METHOD_NAME_ADAPTER.validate_python("get_licensed_items_checkouts_page"), + product_name=product_name, + filter_wallet_id=filter_wallet_id, + limit=limit, + offset=offset, + order_by=order_by, + timeout_s=_DEFAULT_TIMEOUT_S, + ) + assert isinstance(result, LicensedItemsCheckoutsPage) # nosec + return result + + +@log_decorator(_logger, level=logging.DEBUG) +async def checkout_licensed_item( + rabbitmq_rpc_client: RabbitMQRPCClient, + *, + licensed_item_id: LicensedItemID, + wallet_id: WalletID, + product_name: ProductName, + num_of_seats: int, + service_run_id: ServiceRunId, + user_id: UserID, + user_email: str, +) -> LicensedItemCheckoutGet: + result: LicensedItemCheckoutGet = await rabbitmq_rpc_client.request( + RESOURCE_USAGE_TRACKER_RPC_NAMESPACE, + _RPC_METHOD_NAME_ADAPTER.validate_python("checkout_licensed_item"), + licensed_item_id=licensed_item_id, + wallet_id=wallet_id, + product_name=product_name, + num_of_seats=num_of_seats, + service_run_id=service_run_id, + user_id=user_id, + user_email=user_email, + timeout_s=_DEFAULT_TIMEOUT_S, + ) + assert isinstance(result, LicensedItemCheckoutGet) # nosec + return result + + +@log_decorator(_logger, level=logging.DEBUG) +async def release_licensed_item( + rabbitmq_rpc_client: RabbitMQRPCClient, + *, + licensed_item_checkout_id: LicensedItemCheckoutID, + product_name: ProductName, +) -> LicensedItemCheckoutGet: + result: LicensedItemCheckoutGet = await rabbitmq_rpc_client.request( + RESOURCE_USAGE_TRACKER_RPC_NAMESPACE, + _RPC_METHOD_NAME_ADAPTER.validate_python("release_licensed_item"), + licensed_item_checkout_id=licensed_item_checkout_id, + product_name=product_name, + timeout_s=_DEFAULT_TIMEOUT_S, + ) + assert isinstance(result, LicensedItemCheckoutGet) # nosec + return result diff --git a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/licensed_items_purchases.py b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/licensed_items_purchases.py index a9463271d75e..125dbe655a0e 100644 --- a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/licensed_items_purchases.py +++ b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/licensed_items_purchases.py @@ -17,7 +17,7 @@ ) from models_library.rest_ordering import OrderBy from models_library.wallets import WalletID -from pydantic import AnyUrl, NonNegativeInt, TypeAdapter +from pydantic import NonNegativeInt, TypeAdapter from ....logging_utils import log_decorator from ....rabbitmq import RabbitMQRPCClient @@ -38,8 +38,14 @@ async def get_licensed_items_purchases_page( wallet_id: WalletID, offset: int = 0, limit: int = 20, - order_by: OrderBy = OrderBy(field=IDStr("purchased_at")), + order_by: OrderBy | None = None, ) -> LicensedItemsPurchasesPage: + """ + Default order_by field is "purchased_at" + """ + if order_by is None: + order_by = OrderBy(field=IDStr("purchased_at")) + result = await rabbitmq_rpc_client.request( RESOURCE_USAGE_TRACKER_RPC_NAMESPACE, _RPC_METHOD_NAME_ADAPTER.validate_python("get_licensed_items_purchases_page"), @@ -76,7 +82,7 @@ async def get_licensed_item_purchase( async def create_licensed_item_purchase( rabbitmq_rpc_client: RabbitMQRPCClient, *, data: LicensedItemsPurchasesCreate ) -> LicensedItemPurchaseGet: - result: AnyUrl = await rabbitmq_rpc_client.request( + result = await rabbitmq_rpc_client.request( RESOURCE_USAGE_TRACKER_RPC_NAMESPACE, _RPC_METHOD_NAME_ADAPTER.validate_python("create_licensed_item_purchase"), data=data, diff --git a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/licenses/licensed_items.py b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/licenses/licensed_items.py index e212854bae5a..cb20f00be0a7 100644 --- a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/licenses/licensed_items.py +++ b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/licenses/licensed_items.py @@ -5,10 +5,16 @@ LicensedItemGet, LicensedItemGetPage, ) +from models_library.api_schemas_webserver.licensed_items_checkouts import ( + LicensedItemCheckoutGet, +) from models_library.licensed_items import LicensedItemID from models_library.products import ProductName from models_library.rabbitmq_basic_types import RPCMethodName from models_library.resource_tracker import ServiceRunId +from models_library.resource_tracker_licensed_items_checkouts import ( + LicensedItemCheckoutID, +) from models_library.users import UserID from models_library.wallets import WalletID from pydantic import TypeAdapter @@ -23,8 +29,8 @@ async def get_licensed_items( rabbitmq_rpc_client: RabbitMQRPCClient, *, product_name: str, - offset: int, - limit: int, + offset: int = 0, + limit: int = 20, ) -> LicensedItemGetPage: result: LicensedItemGetPage = await rabbitmq_rpc_client.request( WEBSERVER_RPC_NAMESPACE, @@ -33,26 +39,32 @@ async def get_licensed_items( offset=offset, limit=limit, ) - assert isinstance(result, LicensedItemGetPage) + assert isinstance(result, LicensedItemGetPage) # nosec return result @log_decorator(_logger, level=logging.DEBUG) -async def get_licensed_items_for_wallet( +async def get_available_licensed_items_for_wallet( rabbitmq_rpc_client: RabbitMQRPCClient, *, - user_id: UserID, product_name: ProductName, wallet_id: WalletID, -) -> LicensedItemGet: + user_id: UserID, + offset: int = 0, + limit: int = 20, +) -> LicensedItemGetPage: result: LicensedItemGet = await rabbitmq_rpc_client.request( WEBSERVER_RPC_NAMESPACE, - TypeAdapter(RPCMethodName).validate_python("get_licensed_items_for_wallet"), - user_id=user_id, + TypeAdapter(RPCMethodName).validate_python( + "get_available_licensed_items_for_wallet" + ), product_name=product_name, + user_id=user_id, wallet_id=wallet_id, + offset=offset, + limit=limit, ) - assert isinstance(result, LicensedItemGet) # nosec + assert isinstance(result, LicensedItemGetPage) # nosec return result @@ -60,45 +72,41 @@ async def get_licensed_items_for_wallet( async def checkout_licensed_item_for_wallet( rabbitmq_rpc_client: RabbitMQRPCClient, *, - user_id: UserID, product_name: ProductName, + user_id: UserID, wallet_id: WalletID, licensed_item_id: LicensedItemID, num_of_seats: int, service_run_id: ServiceRunId, -) -> None: +) -> LicensedItemCheckoutGet: result = await rabbitmq_rpc_client.request( WEBSERVER_RPC_NAMESPACE, TypeAdapter(RPCMethodName).validate_python("checkout_licensed_item_for_wallet"), - user_id=user_id, product_name=product_name, + user_id=user_id, wallet_id=wallet_id, licensed_item_id=licensed_item_id, num_of_seats=num_of_seats, service_run_id=service_run_id, ) - assert result is None # nosec + assert isinstance(result, LicensedItemCheckoutGet) # nosec + return result @log_decorator(_logger, level=logging.DEBUG) async def release_licensed_item_for_wallet( rabbitmq_rpc_client: RabbitMQRPCClient, *, - user_id: UserID, product_name: ProductName, - wallet_id: WalletID, - licensed_item_id: LicensedItemID, - num_of_seats: int, - service_run_id: ServiceRunId, -) -> None: + user_id: UserID, + licensed_item_checkout_id: LicensedItemCheckoutID, +) -> LicensedItemCheckoutGet: result = await rabbitmq_rpc_client.request( WEBSERVER_RPC_NAMESPACE, TypeAdapter(RPCMethodName).validate_python("release_licensed_item_for_wallet"), - user_id=user_id, product_name=product_name, - wallet_id=wallet_id, - licensed_item_id=licensed_item_id, - num_of_seats=num_of_seats, - service_run_id=service_run_id, + user_id=user_id, + licensed_item_checkout_id=licensed_item_checkout_id, ) - assert result is None # nosec + assert isinstance(result, LicensedItemCheckoutGet) # nosec + return result diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_licensed_items_checkouts.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_licensed_items_checkouts.py new file mode 100644 index 000000000000..19ff9847374a --- /dev/null +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_licensed_items_checkouts.py @@ -0,0 +1,94 @@ +from fastapi import FastAPI +from models_library.api_schemas_resource_usage_tracker.licensed_items_checkouts import ( + LicensedItemCheckoutGet, + LicensedItemsCheckoutsPage, +) +from models_library.licensed_items import LicensedItemID +from models_library.products import ProductName +from models_library.resource_tracker import ServiceRunId +from models_library.resource_tracker_licensed_items_checkouts import ( + LicensedItemCheckoutID, +) +from models_library.rest_ordering import OrderBy +from models_library.users import UserID +from models_library.wallets import WalletID +from servicelib.rabbitmq import RPCRouter +from servicelib.rabbitmq.rpc_interfaces.resource_usage_tracker.errors import ( + LICENSES_ERRORS, +) + +from ...services import licensed_items_checkouts + +router = RPCRouter() + + +@router.expose(reraise_if_error_type=LICENSES_ERRORS) +async def get_licensed_item_checkout( + app: FastAPI, + *, + product_name: ProductName, + licensed_item_checkout_id: LicensedItemCheckoutID, +) -> LicensedItemCheckoutGet: + return await licensed_items_checkouts.get_licensed_item_checkout( + db_engine=app.state.engine, + product_name=product_name, + licensed_item_checkout_id=licensed_item_checkout_id, + ) + + +@router.expose(reraise_if_error_type=LICENSES_ERRORS) +async def get_licensed_items_checkouts_page( + app: FastAPI, + *, + product_name: ProductName, + filter_wallet_id: WalletID, + offset: int = 0, + limit: int = 20, + order_by: OrderBy, +) -> LicensedItemsCheckoutsPage: + return await licensed_items_checkouts.list_licensed_items_checkouts( + db_engine=app.state.engine, + product_name=product_name, + filter_wallet_id=filter_wallet_id, + offset=offset, + limit=limit, + order_by=order_by, + ) + + +@router.expose(reraise_if_error_type=LICENSES_ERRORS) +async def checkout_licensed_item( + app: FastAPI, + *, + licensed_item_id: LicensedItemID, + wallet_id: WalletID, + product_name: ProductName, + num_of_seats: int, + service_run_id: ServiceRunId, + user_id: UserID, + user_email: str, +) -> LicensedItemCheckoutGet: + return await licensed_items_checkouts.checkout_licensed_item( + db_engine=app.state.engine, + licensed_item_id=licensed_item_id, + wallet_id=wallet_id, + product_name=product_name, + num_of_seats=num_of_seats, + service_run_id=service_run_id, + user_id=user_id, + user_email=user_email, + ) + + +@router.expose(reraise_if_error_type=LICENSES_ERRORS) +async def release_licensed_item( + app: FastAPI, + *, + licensed_item_checkout_id: LicensedItemCheckoutID, + product_name: ProductName, +) -> LicensedItemCheckoutGet: + return await licensed_items_checkouts.release_licensed_item( + db_engine=app.state.engine, + licensed_item_checkout_id=licensed_item_checkout_id, + product_name=product_name, + ) 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 f1fd12761610..e5da8f444110 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,7 +8,7 @@ from servicelib.rabbitmq import RPCRouter from ...services.modules.rabbitmq import get_rabbitmq_rpc_server -from . import _licensed_items_purchases, _resource_tracker +from . import _licensed_items_checkouts, _licensed_items_purchases, _resource_tracker _logger = logging.getLogger(__name__) @@ -16,6 +16,7 @@ ROUTERS: list[RPCRouter] = [ _resource_tracker.router, _licensed_items_purchases.router, + _licensed_items_checkouts.router, ] diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/models/licensed_items_checkouts.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/models/licensed_items_checkouts.py new file mode 100644 index 000000000000..30ec170a9ec5 --- /dev/null +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/models/licensed_items_checkouts.py @@ -0,0 +1,46 @@ +from datetime import datetime + +from models_library.licensed_items import LicensedItemID +from models_library.products import ProductName +from models_library.resource_tracker import ServiceRunId +from models_library.resource_tracker_licensed_items_checkouts import ( + LicensedItemCheckoutID, +) +from models_library.users import UserID +from models_library.wallets import WalletID +from pydantic import BaseModel, ConfigDict + + +class LicensedItemCheckoutDB(BaseModel): + licensed_item_checkout_id: LicensedItemCheckoutID + licensed_item_id: LicensedItemID + wallet_id: WalletID + user_id: UserID + user_email: str + product_name: ProductName + service_run_id: ServiceRunId + started_at: datetime + stopped_at: datetime | None + num_of_seats: int + modified: datetime + + model_config = ConfigDict(from_attributes=True) + + +class CreateLicensedItemCheckoutDB(BaseModel): + licensed_item_id: LicensedItemID + wallet_id: WalletID + user_id: UserID + user_email: str + product_name: ProductName + service_run_id: ServiceRunId + started_at: datetime + num_of_seats: int + + model_config = ConfigDict(from_attributes=True) + + +class UpdateLicensedItemCheckoutDB(BaseModel): + stopped_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/licensed_items_checkouts.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/licensed_items_checkouts.py new file mode 100644 index 000000000000..4f2446dfb141 --- /dev/null +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/licensed_items_checkouts.py @@ -0,0 +1,207 @@ +from datetime import UTC, datetime +from typing import Annotated + +from fastapi import Depends +from models_library.api_schemas_resource_usage_tracker.licensed_items_checkouts import ( + LicensedItemCheckoutGet, + LicensedItemsCheckoutsPage, +) +from models_library.licensed_items import LicensedItemID +from models_library.products import ProductName +from models_library.resource_tracker import ServiceRunId, ServiceRunStatus +from models_library.resource_tracker_licensed_items_checkouts import ( + LicensedItemCheckoutID, +) +from models_library.rest_ordering import OrderBy +from models_library.users import UserID +from models_library.wallets import WalletID +from servicelib.rabbitmq.rpc_interfaces.resource_usage_tracker.errors import ( + CanNotCheckoutNotEnoughAvailableSeatsError, + CanNotCheckoutServiceIsNotRunningError, + NotEnoughAvailableSeatsError, +) +from sqlalchemy.ext.asyncio import AsyncEngine + +from ..api.rest.dependencies import get_resource_tracker_db_engine +from ..models.licensed_items_checkouts import ( + CreateLicensedItemCheckoutDB, + LicensedItemCheckoutDB, +) +from .modules.db import ( + licensed_items_checkouts_db, + licensed_items_purchases_db, + service_runs_db, +) + + +async def list_licensed_items_checkouts( + db_engine: Annotated[AsyncEngine, Depends(get_resource_tracker_db_engine)], + *, + product_name: ProductName, + filter_wallet_id: WalletID, + offset: int, + limit: int, + order_by: OrderBy, +) -> LicensedItemsCheckoutsPage: + total, licensed_items_checkouts_list_db = await licensed_items_checkouts_db.list_( + db_engine, + product_name=product_name, + filter_wallet_id=filter_wallet_id, + offset=offset, + limit=limit, + order_by=order_by, + ) + return LicensedItemsCheckoutsPage( + total=total, + items=[ + LicensedItemCheckoutGet( + licensed_item_checkout_id=licensed_item_checkout_db.licensed_item_checkout_id, + licensed_item_id=licensed_item_checkout_db.licensed_item_id, + wallet_id=licensed_item_checkout_db.wallet_id, + user_id=licensed_item_checkout_db.user_id, + product_name=licensed_item_checkout_db.product_name, + service_run_id=licensed_item_checkout_db.service_run_id, + started_at=licensed_item_checkout_db.started_at, + stopped_at=licensed_item_checkout_db.stopped_at, + num_of_seats=licensed_item_checkout_db.num_of_seats, + ) + for licensed_item_checkout_db in licensed_items_checkouts_list_db + ], + ) + + +async def get_licensed_item_checkout( + db_engine: Annotated[AsyncEngine, Depends(get_resource_tracker_db_engine)], + *, + product_name: ProductName, + licensed_item_checkout_id: LicensedItemCheckoutID, +) -> LicensedItemCheckoutGet: + licensed_item_checkout_db: LicensedItemCheckoutDB = ( + await licensed_items_checkouts_db.get( + db_engine, + product_name=product_name, + licensed_item_checkout_id=licensed_item_checkout_id, + ) + ) + + return LicensedItemCheckoutGet( + licensed_item_checkout_id=licensed_item_checkout_db.licensed_item_checkout_id, + licensed_item_id=licensed_item_checkout_db.licensed_item_id, + wallet_id=licensed_item_checkout_db.wallet_id, + user_id=licensed_item_checkout_db.user_id, + product_name=licensed_item_checkout_db.product_name, + service_run_id=licensed_item_checkout_db.service_run_id, + started_at=licensed_item_checkout_db.started_at, + stopped_at=licensed_item_checkout_db.stopped_at, + num_of_seats=licensed_item_checkout_db.num_of_seats, + ) + + +async def checkout_licensed_item( + db_engine: Annotated[AsyncEngine, Depends(get_resource_tracker_db_engine)], + *, + licensed_item_id: LicensedItemID, + wallet_id: WalletID, + product_name: ProductName, + num_of_seats: int, + service_run_id: ServiceRunId, + user_id: UserID, + user_email: str, +) -> LicensedItemCheckoutGet: + + _active_purchased_seats: int = await licensed_items_purchases_db.get_active_purchased_seats_for_item_and_wallet( + db_engine, + licensed_item_id=licensed_item_id, + wallet_id=wallet_id, + product_name=product_name, + ) + + _currently_used_seats = ( + await licensed_items_checkouts_db.get_currently_used_seats_for_item_and_wallet( + db_engine, + licensed_item_id=licensed_item_id, + wallet_id=wallet_id, + product_name=product_name, + ) + ) + + available_seats = _active_purchased_seats - _currently_used_seats + if available_seats <= 0: + raise NotEnoughAvailableSeatsError( + license_item_id=licensed_item_id, available_num_of_seats=available_seats + ) + + if available_seats - num_of_seats < 0: + raise CanNotCheckoutNotEnoughAvailableSeatsError( + license_item_id=licensed_item_id, + available_num_of_seats=available_seats, + num_of_seats=num_of_seats, + ) + + # Check if the service run ID is currently running + service_run = await service_runs_db.get_service_run_by_id( + db_engine, service_run_id=service_run_id + ) + if ( + service_run is None + or service_run.service_run_status != ServiceRunStatus.RUNNING + ): + raise CanNotCheckoutServiceIsNotRunningError( + license_item_id=licensed_item_id, service_run=service_run + ) + + _create_item_checkout = CreateLicensedItemCheckoutDB( + licensed_item_id=licensed_item_id, + wallet_id=wallet_id, + user_id=user_id, + user_email=user_email, + product_name=product_name, + service_run_id=service_run_id, + started_at=datetime.now(tz=UTC), + num_of_seats=num_of_seats, + ) + licensed_item_checkout_db = await licensed_items_checkouts_db.create( + db_engine, data=_create_item_checkout + ) + + # Return checkout ID + return LicensedItemCheckoutGet( + licensed_item_checkout_id=licensed_item_checkout_db.licensed_item_checkout_id, + licensed_item_id=licensed_item_checkout_db.licensed_item_id, + wallet_id=licensed_item_checkout_db.wallet_id, + user_id=licensed_item_checkout_db.user_id, + product_name=licensed_item_checkout_db.product_name, + service_run_id=licensed_item_checkout_db.service_run_id, + started_at=licensed_item_checkout_db.started_at, + stopped_at=licensed_item_checkout_db.stopped_at, + num_of_seats=licensed_item_checkout_db.num_of_seats, + ) + + +async def release_licensed_item( + db_engine: Annotated[AsyncEngine, Depends(get_resource_tracker_db_engine)], + *, + licensed_item_checkout_id: LicensedItemCheckoutID, + product_name: ProductName, +) -> LicensedItemCheckoutGet: + + licensed_item_checkout_db: LicensedItemCheckoutDB = ( + await licensed_items_checkouts_db.update( + db_engine, + licensed_item_checkout_id=licensed_item_checkout_id, + product_name=product_name, + stopped_at=datetime.now(tz=UTC), + ) + ) + + return LicensedItemCheckoutGet( + licensed_item_checkout_id=licensed_item_checkout_db.licensed_item_checkout_id, + licensed_item_id=licensed_item_checkout_db.licensed_item_id, + wallet_id=licensed_item_checkout_db.wallet_id, + user_id=licensed_item_checkout_db.user_id, + product_name=licensed_item_checkout_db.product_name, + service_run_id=licensed_item_checkout_db.service_run_id, + started_at=licensed_item_checkout_db.started_at, + stopped_at=licensed_item_checkout_db.stopped_at, + num_of_seats=licensed_item_checkout_db.num_of_seats, + ) diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/licensed_items_checkouts_db.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/licensed_items_checkouts_db.py new file mode 100644 index 000000000000..2402a8c52bea --- /dev/null +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/licensed_items_checkouts_db.py @@ -0,0 +1,216 @@ +from datetime import datetime +from typing import cast + +import sqlalchemy as sa +from models_library.licensed_items import LicensedItemID +from models_library.products import ProductName +from models_library.resource_tracker_licensed_items_checkouts import ( + LicensedItemCheckoutID, +) +from models_library.rest_ordering import OrderBy, OrderDirection +from models_library.wallets import WalletID +from pydantic import NonNegativeInt +from servicelib.rabbitmq.rpc_interfaces.resource_usage_tracker.errors import ( + LicensedItemCheckoutNotFoundError, +) +from simcore_postgres_database.models.resource_tracker_licensed_items_checkouts import ( + resource_tracker_licensed_items_checkouts, +) +from simcore_postgres_database.utils_repos import ( + pass_or_acquire_connection, + transaction_context, +) +from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine + +from ....models.licensed_items_checkouts import ( + CreateLicensedItemCheckoutDB, + LicensedItemCheckoutDB, +) + +_SELECTION_ARGS = ( + resource_tracker_licensed_items_checkouts.c.licensed_item_checkout_id, + resource_tracker_licensed_items_checkouts.c.licensed_item_id, + resource_tracker_licensed_items_checkouts.c.wallet_id, + resource_tracker_licensed_items_checkouts.c.user_id, + resource_tracker_licensed_items_checkouts.c.user_email, + resource_tracker_licensed_items_checkouts.c.product_name, + resource_tracker_licensed_items_checkouts.c.service_run_id, + resource_tracker_licensed_items_checkouts.c.started_at, + resource_tracker_licensed_items_checkouts.c.stopped_at, + resource_tracker_licensed_items_checkouts.c.num_of_seats, + resource_tracker_licensed_items_checkouts.c.modified, +) + +assert set(LicensedItemCheckoutDB.model_fields) == { + c.name for c in _SELECTION_ARGS +} # nosec + + +async def create( + engine: AsyncEngine, + connection: AsyncConnection | None = None, + *, + data: CreateLicensedItemCheckoutDB, +) -> LicensedItemCheckoutDB: + async with transaction_context(engine, connection) as conn: + result = await conn.execute( + resource_tracker_licensed_items_checkouts.insert() + .values( + licensed_item_id=data.licensed_item_id, + wallet_id=data.wallet_id, + user_id=data.user_id, + user_email=data.user_email, + product_name=data.product_name, + service_run_id=data.service_run_id, + started_at=data.started_at, + stopped_at=None, + num_of_seats=data.num_of_seats, + modified=sa.func.now(), + ) + .returning(*_SELECTION_ARGS) + ) + row = result.first() + return LicensedItemCheckoutDB.model_validate(row) + + +async def list_( + engine: AsyncEngine, + connection: AsyncConnection | None = None, + *, + product_name: ProductName, + filter_wallet_id: WalletID, + offset: NonNegativeInt, + limit: NonNegativeInt, + order_by: OrderBy, +) -> tuple[int, list[LicensedItemCheckoutDB]]: + base_query = ( + sa.select(*_SELECTION_ARGS) + .select_from(resource_tracker_licensed_items_checkouts) + .where( + (resource_tracker_licensed_items_checkouts.c.product_name == product_name) + & ( + resource_tracker_licensed_items_checkouts.c.wallet_id + == filter_wallet_id + ) + ) + ) + + # Select total count from base_query + subquery = base_query.subquery() + count_query = sa.select(sa.func.count()).select_from(subquery) + + # Ordering and pagination + if order_by.direction == OrderDirection.ASC: + list_query = base_query.order_by( + sa.asc(getattr(resource_tracker_licensed_items_checkouts.c, order_by.field)) + ) + else: + list_query = base_query.order_by( + sa.desc( + getattr(resource_tracker_licensed_items_checkouts.c, order_by.field) + ) + ) + list_query = list_query.offset(offset).limit(limit) + + async with pass_or_acquire_connection(engine, connection) as conn: + total_count = await conn.scalar(count_query) + if total_count is None: + total_count = 0 + + result = await conn.stream(list_query) + items: list[LicensedItemCheckoutDB] = [ + LicensedItemCheckoutDB.model_validate(row) async for row in result + ] + + return cast(int, total_count), items + + +async def get( + engine: AsyncEngine, + connection: AsyncConnection | None = None, + *, + licensed_item_checkout_id: LicensedItemCheckoutID, + product_name: ProductName, +) -> LicensedItemCheckoutDB: + base_query = ( + sa.select(*_SELECTION_ARGS) + .select_from(resource_tracker_licensed_items_checkouts) + .where( + ( + resource_tracker_licensed_items_checkouts.c.licensed_item_checkout_id + == licensed_item_checkout_id + ) + & (resource_tracker_licensed_items_checkouts.c.product_name == product_name) + ) + ) + + async with pass_or_acquire_connection(engine, connection) as conn: + result = await conn.stream(base_query) + row = await result.first() + if row is None: + raise LicensedItemCheckoutNotFoundError( + licensed_item_checkout_id=licensed_item_checkout_id + ) + return LicensedItemCheckoutDB.model_validate(row) + + +async def update( + engine: AsyncEngine, + connection: AsyncConnection | None = None, + *, + licensed_item_checkout_id: LicensedItemCheckoutID, + product_name: ProductName, + stopped_at: datetime, +) -> LicensedItemCheckoutDB: + update_stmt = ( + resource_tracker_licensed_items_checkouts.update() + .values( + modified=sa.func.now(), + stopped_at=stopped_at, + ) + .where( + ( + resource_tracker_licensed_items_checkouts.c.licensed_item_checkout_id + == licensed_item_checkout_id + ) + & (resource_tracker_licensed_items_checkouts.c.product_name == product_name) + & (resource_tracker_licensed_items_checkouts.c.stopped_at.is_(None)) + ) + .returning(sa.literal_column("*")) + ) + + async with transaction_context(engine, connection) as conn: + result = await conn.execute(update_stmt) + row = result.first() + if row is None: + raise LicensedItemCheckoutNotFoundError( + licensed_item_checkout_id=licensed_item_checkout_id + ) + return LicensedItemCheckoutDB.model_validate(row) + + +async def get_currently_used_seats_for_item_and_wallet( + engine: AsyncEngine, + connection: AsyncConnection | None = None, + *, + licensed_item_id: LicensedItemID, + wallet_id: WalletID, + product_name: ProductName, +) -> int: + sum_stmt = sa.select( + sa.func.sum(resource_tracker_licensed_items_checkouts.c.num_of_seats) + ).where( + (resource_tracker_licensed_items_checkouts.c.wallet_id == wallet_id) + & ( + resource_tracker_licensed_items_checkouts.c.licensed_item_id + == licensed_item_id + ) + & (resource_tracker_licensed_items_checkouts.c.product_name == product_name) + & (resource_tracker_licensed_items_checkouts.c.stopped_at.is_(None)) + ) + + async with pass_or_acquire_connection(engine, connection) as conn: + total_sum = await conn.scalar(sum_stmt) + if total_sum is None: + return 0 + return cast(int, total_sum) diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/licensed_items_purchases_db.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/licensed_items_purchases_db.py index e9951042ddc2..2fd8718784e2 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/licensed_items_purchases_db.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/licensed_items_purchases_db.py @@ -1,6 +1,8 @@ +from datetime import UTC, datetime from typing import cast import sqlalchemy as sa +from models_library.licensed_items import LicensedItemID from models_library.products import ProductName from models_library.resource_tracker_licensed_items_purchases import ( LicensedItemPurchaseID, @@ -114,6 +116,8 @@ async def list_( async with pass_or_acquire_connection(engine, connection) as conn: total_count = await conn.scalar(count_query) + if total_count is None: + total_count = 0 result = await conn.stream(list_query) items: list[LicensedItemsPurchasesDB] = [ @@ -150,3 +154,36 @@ async def get( licensed_item_purchase_id=licensed_item_purchase_id ) return LicensedItemsPurchasesDB.model_validate(row) + + +async def get_active_purchased_seats_for_item_and_wallet( + engine: AsyncEngine, + connection: AsyncConnection | None = None, + *, + licensed_item_id: LicensedItemID, + wallet_id: WalletID, + product_name: ProductName, +) -> int: + """ + Exclude expired seats + """ + _current_time = datetime.now(tz=UTC) + + sum_stmt = sa.select( + sa.func.sum(resource_tracker_licensed_items_purchases.c.num_of_seats) + ).where( + (resource_tracker_licensed_items_purchases.c.wallet_id == wallet_id) + & ( + resource_tracker_licensed_items_purchases.c.licensed_item_id + == licensed_item_id + ) + & (resource_tracker_licensed_items_purchases.c.product_name == product_name) + & (resource_tracker_licensed_items_purchases.c.start_at <= _current_time) + & (resource_tracker_licensed_items_purchases.c.expire_at >= _current_time) + ) + + async with pass_or_acquire_connection(engine, connection) as conn: + total_sum = await conn.scalar(sum_stmt) + if total_sum is None: + return 0 + return cast(int, total_sum) 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 a4ea563803da..e7e1ace3ff8e 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 @@ -376,7 +376,9 @@ async def get_osparc_credits_aggregated_by_service( subquery = base_query.subquery() count_query = sa.select(sa.func.count()).select_from(subquery) - count_result = await conn.execute(count_query) + count_result = await conn.scalar(count_query) + if count_result is None: + count_result = 0 # Default ordering and pagination list_query = ( @@ -387,7 +389,7 @@ async def get_osparc_credits_aggregated_by_service( list_result = await conn.execute(list_query) return ( - cast(int, count_result.scalar()), + cast(int, count_result), [ OsparcCreditsAggregatedByServiceKeyDB.model_validate(row) for row in list_result.fetchall() @@ -427,10 +429,7 @@ async def export_service_runs_table_to_s3( resource_tracker_service_runs.c.stopped_at, resource_tracker_credit_transactions.c.osparc_credits, resource_tracker_credit_transactions.c.transaction_status, - sa.func.coalesce( - _project_tags_subquery.c.project_tags, - sa.cast(sa.text("'{}'"), sa.ARRAY(sa.String)), - ).label("project_tags"), + _project_tags_subquery.c.project_tags.label("project_tags"), ) .select_from( resource_tracker_service_runs.join( diff --git a/services/resource-usage-tracker/tests/unit/with_dbs/test_api_licensed_items_checkouts.py b/services/resource-usage-tracker/tests/unit/with_dbs/test_api_licensed_items_checkouts.py new file mode 100644 index 000000000000..b1036c49aefd --- /dev/null +++ b/services/resource-usage-tracker/tests/unit/with_dbs/test_api_licensed_items_checkouts.py @@ -0,0 +1,139 @@ +# pylint:disable=unused-variable +# pylint:disable=unused-argument +# pylint:disable=redefined-outer-name +# pylint:disable=too-many-arguments + + +from datetime import UTC, datetime, timedelta +from decimal import Decimal +from typing import Generator + +import pytest +import sqlalchemy as sa +from models_library.api_schemas_resource_usage_tracker.licensed_items_checkouts import ( + LicensedItemCheckoutGet, + LicensedItemsCheckoutsPage, +) +from models_library.resource_tracker_licensed_items_purchases import ( + LicensedItemsPurchasesCreate, +) +from servicelib.rabbitmq import RabbitMQRPCClient +from servicelib.rabbitmq.rpc_interfaces.resource_usage_tracker import ( + licensed_items_checkouts, + licensed_items_purchases, +) +from simcore_postgres_database.models.resource_tracker_licensed_items_checkouts import ( + resource_tracker_licensed_items_checkouts, +) +from simcore_postgres_database.models.resource_tracker_service_runs import ( + resource_tracker_service_runs, +) + +pytest_simcore_core_services_selection = [ + "postgres", + "rabbit", +] +pytest_simcore_ops_services_selection = [ + "adminer", +] + + +_USER_ID_1 = 1 +_WALLET_ID = 6 + + +@pytest.fixture() +def resource_tracker_service_run_id( + postgres_db: sa.engine.Engine, random_resource_tracker_service_run +) -> Generator[str, None, None]: + with postgres_db.connect() as con: + result = con.execute( + resource_tracker_service_runs.insert() + .values( + **random_resource_tracker_service_run( + user_id=_USER_ID_1, wallet_id=_WALLET_ID + ) + ) + .returning(resource_tracker_service_runs.c.service_run_id) + ) + row = result.first() + assert row + + yield row[0] + + con.execute(resource_tracker_licensed_items_checkouts.delete()) + con.execute(resource_tracker_service_runs.delete()) + + +async def test_rpc_licensed_items_checkouts_workflow( + mocked_redis_server: None, + resource_tracker_service_run_id: str, + rpc_client: RabbitMQRPCClient, +): + # List licensed items checkouts + output = await licensed_items_checkouts.get_licensed_items_checkouts_page( + rpc_client, + product_name="osparc", + filter_wallet_id=_WALLET_ID, + ) + assert output.total == 0 + assert output.items == [] + + # Purchase license item + _create_data = LicensedItemsPurchasesCreate( + product_name="osparc", + licensed_item_id="beb16d18-d57d-44aa-a638-9727fa4a72ef", + wallet_id=_WALLET_ID, + wallet_name="My Wallet", + pricing_plan_id=1, + pricing_unit_id=1, + pricing_unit_cost_id=1, + pricing_unit_cost=Decimal(10), + start_at=datetime.now(tz=UTC), + expire_at=datetime.now(tz=UTC) + timedelta(days=1), + num_of_seats=5, + purchased_by_user=_USER_ID_1, + user_email="test@test.com", + purchased_at=datetime.now(tz=UTC), + ) + created_item = await licensed_items_purchases.create_licensed_item_purchase( + rpc_client, data=_create_data + ) + + # Checkout with num of seats + checkout = await licensed_items_checkouts.checkout_licensed_item( + rpc_client, + licensed_item_id=created_item.licensed_item_id, + wallet_id=_WALLET_ID, + product_name="osparc", + num_of_seats=3, + service_run_id=resource_tracker_service_run_id, + user_id=_USER_ID_1, + user_email="test@test.com", + ) + + # List licensed items checkouts + output = await licensed_items_checkouts.get_licensed_items_checkouts_page( + rpc_client, + product_name="osparc", + filter_wallet_id=_WALLET_ID, + ) + assert output.total == 1 + assert isinstance(output, LicensedItemsCheckoutsPage) + + # Get licensed items checkouts + output = await licensed_items_checkouts.get_licensed_item_checkout( + rpc_client, + product_name="osparc", + licensed_item_checkout_id=output.items[0].licensed_item_checkout_id, + ) + assert isinstance(output, LicensedItemCheckoutGet) + + # Release num of seats + license_item_checkout = await licensed_items_checkouts.release_licensed_item( + rpc_client, + licensed_item_checkout_id=checkout.licensed_item_checkout_id, + product_name="osparc", + ) + assert license_item_checkout + assert isinstance(license_item_checkout.stopped_at, datetime) diff --git a/services/web/server/src/simcore_service_webserver/licenses/_exceptions_handlers.py b/services/web/server/src/simcore_service_webserver/licenses/_exceptions_handlers.py index d12b95fafa04..26cf9478b5fa 100644 --- a/services/web/server/src/simcore_service_webserver/licenses/_exceptions_handlers.py +++ b/services/web/server/src/simcore_service_webserver/licenses/_exceptions_handlers.py @@ -22,11 +22,11 @@ ), WalletAccessForbiddenError: HttpErrorInfo( status.HTTP_403_FORBIDDEN, - "Wallet {wallet_id} forbidden.", + "Credit account {wallet_id} forbidden.", ), WalletNotEnoughCreditsError: HttpErrorInfo( status.HTTP_402_PAYMENT_REQUIRED, - "Not enough credits in the wallet.", + "Not enough credits in the credit account.", ), LicensedItemPricingPlanMatchError: HttpErrorInfo( status.HTTP_400_BAD_REQUEST, diff --git a/services/web/server/src/simcore_service_webserver/licenses/_licensed_checkouts_api.py b/services/web/server/src/simcore_service_webserver/licenses/_licensed_checkouts_api.py new file mode 100644 index 000000000000..869953fccd34 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/licenses/_licensed_checkouts_api.py @@ -0,0 +1,115 @@ +from aiohttp import web +from models_library.api_schemas_resource_usage_tracker import ( + licensed_items_checkouts as rut_licensed_items_checkouts, +) +from models_library.api_schemas_webserver import ( + licensed_items_checkouts as webserver_licensed_items_checkouts, +) +from models_library.licensed_items import LicensedItemID +from models_library.products import ProductName +from models_library.resource_tracker import ServiceRunId +from models_library.resource_tracker_licensed_items_checkouts import ( + LicensedItemCheckoutID, +) +from models_library.users import UserID +from models_library.wallets import WalletID +from servicelib.rabbitmq.rpc_interfaces.resource_usage_tracker import ( + licensed_items_checkouts, +) + +from ..rabbitmq import get_rabbitmq_rpc_client +from ..users.api import get_user +from ..wallets.api import get_wallet_by_user + + +async def checkout_licensed_item_for_wallet( + app: web.Application, + *, + # access context + product_name: ProductName, + wallet_id: WalletID, + user_id: UserID, + # checkout args + licensed_item_id: LicensedItemID, + num_of_seats: int, + service_run_id: ServiceRunId, +) -> webserver_licensed_items_checkouts.LicensedItemCheckoutGet: + # Check whether user has access to the wallet + await get_wallet_by_user( + app, + user_id=user_id, + wallet_id=wallet_id, + product_name=product_name, + ) + + user = await get_user(app, user_id=user_id) + + rpc_client = get_rabbitmq_rpc_client(app) + licensed_item_get: rut_licensed_items_checkouts.LicensedItemCheckoutGet = ( + await licensed_items_checkouts.checkout_licensed_item( + rpc_client, + licensed_item_id=licensed_item_id, + wallet_id=wallet_id, + product_name=product_name, + num_of_seats=num_of_seats, + service_run_id=service_run_id, + user_id=user_id, + user_email=user["email"], + ) + ) + + return webserver_licensed_items_checkouts.LicensedItemCheckoutGet( + licensed_item_checkout_id=licensed_item_get.licensed_item_checkout_id, + licensed_item_id=licensed_item_get.licensed_item_id, + wallet_id=licensed_item_get.wallet_id, + user_id=licensed_item_get.user_id, + product_name=licensed_item_get.product_name, + started_at=licensed_item_get.started_at, + stopped_at=licensed_item_get.stopped_at, + num_of_seats=licensed_item_get.num_of_seats, + ) + + +async def release_licensed_item_for_wallet( + app: web.Application, + *, + # access context + product_name: ProductName, + user_id: UserID, + # release args + licensed_item_checkout_id: LicensedItemCheckoutID, +) -> webserver_licensed_items_checkouts.LicensedItemCheckoutGet: + rpc_client = get_rabbitmq_rpc_client(app) + + checkout_item = await licensed_items_checkouts.get_licensed_item_checkout( + rpc_client, + product_name=product_name, + licensed_item_checkout_id=licensed_item_checkout_id, + ) + + # Check whether user has access to the wallet + await get_wallet_by_user( + app, + user_id=user_id, + wallet_id=checkout_item.wallet_id, + product_name=product_name, + ) + + licensed_item_get: rut_licensed_items_checkouts.LicensedItemCheckoutGet = ( + await licensed_items_checkouts.release_licensed_item( + rpc_client, + product_name=product_name, + licensed_item_checkout_id=licensed_item_checkout_id, + ) + ) + + return webserver_licensed_items_checkouts.LicensedItemCheckoutGet( + licensed_item_checkout_id=licensed_item_get.licensed_item_checkout_id, + licensed_item_id=licensed_item_get.licensed_item_id, + wallet_id=licensed_item_get.wallet_id, + user_id=licensed_item_get.user_id, + product_name=licensed_item_get.product_name, + started_at=licensed_item_get.started_at, + stopped_at=licensed_item_get.stopped_at, + num_of_seats=licensed_item_get.num_of_seats, + ) diff --git a/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_purchases_api.py b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_purchases_api.py index 4aae82ae7680..b42d593bed10 100644 --- a/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_purchases_api.py +++ b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_purchases_api.py @@ -26,6 +26,7 @@ async def list_licensed_items_purchases( app: web.Application, + *, product_name: ProductName, user_id: UserID, wallet_id: WalletID, diff --git a/services/web/server/src/simcore_service_webserver/licenses/_rpc.py b/services/web/server/src/simcore_service_webserver/licenses/_rpc.py index fede0759b0d0..0b5d1b65fe9a 100644 --- a/services/web/server/src/simcore_service_webserver/licenses/_rpc.py +++ b/services/web/server/src/simcore_service_webserver/licenses/_rpc.py @@ -1,22 +1,31 @@ from aiohttp import web from models_library.api_schemas_webserver import WEBSERVER_RPC_NAMESPACE from models_library.api_schemas_webserver.licensed_items import LicensedItemGetPage +from models_library.api_schemas_webserver.licensed_items_checkouts import ( + LicensedItemCheckoutGet, +) from models_library.basic_types import IDStr from models_library.licensed_items import LicensedItemID from models_library.products import ProductName from models_library.resource_tracker import ServiceRunId +from models_library.resource_tracker_licensed_items_checkouts import ( + LicensedItemCheckoutID, +) from models_library.rest_ordering import OrderBy from models_library.users import UserID from models_library.wallets import WalletID from servicelib.rabbitmq import RPCRouter +from servicelib.rabbitmq.rpc_interfaces.resource_usage_tracker.errors import ( + LICENSES_ERRORS, +) from ..rabbitmq import get_rabbitmq_rpc_server -from . import _licensed_items_api +from . import _licensed_checkouts_api, _licensed_items_api router = RPCRouter() -@router.expose() +@router.expose(reraise_if_error_type=LICENSES_ERRORS) async def get_licensed_items( app: web.Application, *, @@ -37,42 +46,54 @@ async def get_licensed_items( @router.expose(reraise_if_error_type=(NotImplementedError,)) -async def get_licensed_items_for_wallet( +async def get_available_licensed_items_for_wallet( app: web.Application, *, - user_id: UserID, product_name: ProductName, + user_id: UserID, wallet_id: WalletID, -) -> None: + offset: int, + limit: int, +) -> LicensedItemGetPage: raise NotImplementedError -@router.expose(reraise_if_error_type=(NotImplementedError,)) +@router.expose(reraise_if_error_type=LICENSES_ERRORS) async def checkout_licensed_item_for_wallet( app: web.Application, *, - user_id: UserID, product_name: ProductName, + user_id: UserID, wallet_id: WalletID, licensed_item_id: LicensedItemID, num_of_seats: int, service_run_id: ServiceRunId, -) -> None: - raise NotImplementedError +) -> LicensedItemCheckoutGet: + return await _licensed_checkouts_api.checkout_licensed_item_for_wallet( + app, + licensed_item_id=licensed_item_id, + wallet_id=wallet_id, + product_name=product_name, + num_of_seats=num_of_seats, + service_run_id=service_run_id, + user_id=user_id, + ) -@router.expose(reraise_if_error_type=(NotImplementedError,)) +@router.expose(reraise_if_error_type=LICENSES_ERRORS) async def release_licensed_item_for_wallet( app: web.Application, *, - user_id: str, - product_name: str, - wallet_id: WalletID, - licensed_item_id: LicensedItemID, - num_of_seats: int, - service_run_id: ServiceRunId, -) -> None: - raise NotImplementedError + product_name: ProductName, + user_id: UserID, + licensed_item_checkout_id: LicensedItemCheckoutID, +) -> LicensedItemCheckoutGet: + return await _licensed_checkouts_api.release_licensed_item_for_wallet( + app, + product_name=product_name, + user_id=user_id, + licensed_item_checkout_id=licensed_item_checkout_id, + ) async def register_rpc_routes_on_startup(app: web.Application): diff --git a/services/web/server/tests/unit/with_dbs/04/licenses/test_licenses_rpc.py b/services/web/server/tests/unit/with_dbs/04/licenses/test_licenses_rpc.py index e3ab4f4cb3d4..6888711b2da7 100644 --- a/services/web/server/tests/unit/with_dbs/04/licenses/test_licenses_rpc.py +++ b/services/web/server/tests/unit/with_dbs/04/licenses/test_licenses_rpc.py @@ -7,6 +7,9 @@ import pytest from aiohttp.test_utils import TestClient +from models_library.api_schemas_resource_usage_tracker.licensed_items_checkouts import ( + LicensedItemCheckoutGet, +) from models_library.licensed_items import LicensedResourceType from models_library.products import ProductName from pytest_mock import MockerFixture @@ -16,8 +19,8 @@ from servicelib.rabbitmq import RabbitMQRPCClient from servicelib.rabbitmq.rpc_interfaces.webserver.licenses.licensed_items import ( checkout_licensed_item_for_wallet, + get_available_licensed_items_for_wallet, get_licensed_items, - get_licensed_items_for_wallet, release_licensed_item_for_wallet, ) from settings_library.rabbit import RabbitSettings @@ -67,12 +70,59 @@ async def rpc_client( return await rabbitmq_rpc_client("client") -async def test_api_keys_workflow( +@pytest.fixture +def mock_get_wallet_by_user(mocker: MockerFixture) -> tuple: + return mocker.patch( + "simcore_service_webserver.licenses._licensed_checkouts_api.get_wallet_by_user", + spec=True, + ) + + +_LICENSED_ITEM_CHECKOUT_GET = LicensedItemCheckoutGet.model_validate( + LicensedItemCheckoutGet.model_config["json_schema_extra"]["examples"][0] +) + + +@pytest.fixture +def mock_checkout_licensed_item(mocker: MockerFixture) -> tuple: + return mocker.patch( + "simcore_service_webserver.licenses._licensed_checkouts_api.licensed_items_checkouts.checkout_licensed_item", + spec=True, + return_value=_LICENSED_ITEM_CHECKOUT_GET, + ) + + +@pytest.fixture +def mock_get_licensed_item_checkout(mocker: MockerFixture) -> tuple: + return mocker.patch( + "simcore_service_webserver.licenses._licensed_checkouts_api.licensed_items_checkouts.get_licensed_item_checkout", + spec=True, + return_value=_LICENSED_ITEM_CHECKOUT_GET, + ) + + +@pytest.fixture +def mock_release_licensed_item(mocker: MockerFixture) -> tuple: + return mocker.patch( + "simcore_service_webserver.licenses._licensed_checkouts_api.licensed_items_checkouts.release_licensed_item", + spec=True, + return_value=_LICENSED_ITEM_CHECKOUT_GET, + ) + + +@pytest.mark.acceptance_test( + "Implements https://github.com/ITISFoundation/osparc-issues/issues/1800" +) +async def test_license_checkout_workflow( client: TestClient, rpc_client: RabbitMQRPCClient, osparc_product_name: ProductName, logged_user: UserInfoDict, pricing_plan_id: int, + mock_get_wallet_by_user: MockerFixture, + mock_checkout_licensed_item: MockerFixture, + mock_release_licensed_item: MockerFixture, + mock_get_licensed_item_checkout: MockerFixture, ): assert client.app @@ -82,7 +132,7 @@ async def test_api_keys_workflow( assert len(result.items) == 0 assert result.total == 0 - await _licensed_items_db.create( + license_item_db = await _licensed_items_db.create( client.app, product_name=osparc_product_name, name="Model A", @@ -97,31 +147,26 @@ async def test_api_keys_workflow( assert result.total == 1 with pytest.raises(NotImplementedError): - await get_licensed_items_for_wallet( + await get_available_licensed_items_for_wallet( rpc_client, user_id=logged_user["id"], product_name=osparc_product_name, wallet_id=1, ) - with pytest.raises(NotImplementedError): - await checkout_licensed_item_for_wallet( - rpc_client, - user_id=logged_user["id"], - product_name=osparc_product_name, - wallet_id=1, - licensed_item_id="c5139a2e-4e1f-4ebe-9bfd-d17f195111ee", - num_of_seats=1, - service_run_id="run_1", - ) + checkout = await checkout_licensed_item_for_wallet( + rpc_client, + product_name=osparc_product_name, + user_id=logged_user["id"], + wallet_id=1, + licensed_item_id=license_item_db.licensed_item_id, + num_of_seats=1, + service_run_id="run_1", + ) - with pytest.raises(NotImplementedError): - await release_licensed_item_for_wallet( - rpc_client, - user_id=logged_user["id"], - product_name=osparc_product_name, - wallet_id=1, - licensed_item_id="c5139a2e-4e1f-4ebe-9bfd-d17f195111ee", - num_of_seats=1, - service_run_id="run_1", - ) + await release_licensed_item_for_wallet( + rpc_client, + product_name=osparc_product_name, + user_id=logged_user["id"], + licensed_item_checkout_id=checkout.licensed_item_checkout_id, + ) diff --git a/tests/e2e-playwright/tests/platform_CI_tests/conftest.py b/tests/e2e-playwright/tests/platform_CI_tests/conftest.py index e69de29bb2d1..300e0d759722 100644 --- a/tests/e2e-playwright/tests/platform_CI_tests/conftest.py +++ b/tests/e2e-playwright/tests/platform_CI_tests/conftest.py @@ -0,0 +1,18 @@ +from pathlib import Path + +import pytest + + +@pytest.fixture +def results_path(request): + """ + Fixture to retrieve the path to the test's results directory. + """ + # Check if `results_dir` is available in the current test's user properties + results_dir = dict(request.node.user_properties).get("results_dir") + if not results_dir: + results_dir = "test-results" # Default results directory + test_name = request.node.name + test_dir = Path(results_dir) / test_name + test_dir.mkdir(parents=True, exist_ok=True) # Ensure the test directory exists + return test_dir diff --git a/tests/e2e-playwright/tests/platform_CI_tests/test_platform.py b/tests/e2e-playwright/tests/platform_CI_tests/test_platform.py index 382e41518bd0..15b3a86b730a 100644 --- a/tests/e2e-playwright/tests/platform_CI_tests/test_platform.py +++ b/tests/e2e-playwright/tests/platform_CI_tests/test_platform.py @@ -5,10 +5,11 @@ # pylint: disable=unused-argument # pylint: disable=unused-variable -from collections.abc import Iterable from pathlib import Path +from typing import Iterable import pytest +from playwright.sync_api import expect from playwright.sync_api._generated import BrowserContext, Playwright from pydantic import AnyUrl @@ -24,6 +25,7 @@ def logged_in_context( store_browser_context: bool, request: pytest.FixtureRequest, pytestconfig: pytest.Config, + results_path: Path, ) -> Iterable[BrowserContext]: is_headed = "--headed" in pytestconfig.invocation_params.args @@ -33,7 +35,14 @@ def logged_in_context( browser = playwright.chromium.launch(headless=not is_headed) context = browser.new_context(storage_state="state.json") + test_name = request.node.name + context.tracing.start( + title=f"Trace for Browser 2 in test {test_name}", + snapshots=True, + screenshots=True, + ) yield context + context.tracing.stop(path=f"{results_path}/second_browser_trace.zip") context.close() browser.close() @@ -85,7 +94,12 @@ def test_simple_workspace_workflow( and response.request.method == "POST" ) as response_info: page.get_by_test_id("newWorkspaceButton").click() + + workspace_title_field = page.get_by_test_id("workspaceEditorTitle") + # wait until the title is automatically filled up + expect(workspace_title_field).not_to_have_value("") page.get_by_test_id("workspaceEditorSave").click() + _workspace_id = response_info.value.json()["data"]["workspaceId"] page.get_by_test_id(f"workspaceItem_{_workspace_id}").click() page.get_by_test_id("workspacesAndFoldersTreeItem_null_null").click()