From 825c22ae1c23386cd3e747c3865e5cb672acfbb3 Mon Sep 17 00:00:00 2001 From: Matus Drobuliak <60785969+matusdrobuliak66@users.noreply.github.com> Date: Wed, 11 Dec 2024 11:18:59 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20introduce=20`licensed=5Fitems=5Fpur?= =?UTF-8?q?chases`=20endpoints=20=F0=9F=97=83=EF=B8=8F=20(#6928)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On behalf of @matusdrobuliak66 --- ...g_licensed_items.py => _licensed_items.py} | 6 +- .../web-server/_licensed_items_purchases.py | 57 ++ api/specs/web-server/openapi.py | 3 +- .../licensed_items_purchases.py | 56 ++ .../licensed_items_purchases.py | 35 ++ .../api_schemas_webserver/wallets.py | 43 +- .../src/models_library/resource_tracker.py | 1 + ...source_tracker_licensed_items_purchases.py | 33 ++ ...f_add_cols_to_licensed_items_purchases_.py | 38 ++ ...7_add_cols_to_licensed_items_purchases_.py | 45 ++ ...b_add_cols_to_licensed_items_purchases_.py | 28 + .../resource_tracker_credit_transactions.py | 10 +- ...source_tracker_licensed_items_purchases.py | 25 +- .../licensed_items_purchases.py | 86 ++++ .../api/rpc/_licensed_items_purchases.py | 61 +++ .../api/rpc/routes.py | 3 +- .../exceptions/errors.py | 7 + .../models/credit_transactions.py | 4 + .../models/licensed_items_purchases.py | 46 ++ .../services/credit_transactions.py | 1 + .../services/licensed_items_purchases.py | 174 +++++++ .../modules/db/credit_transactions_db.py | 1 + .../modules/db/licensed_items_purchases_db.py | 152 ++++++ .../process_message_running_service.py | 1 + ...ons.py => test_api_credit_transactions.py} | 0 .../test_api_licensed_items_purchases.py | 78 +++ ...ing_plans.py => test_api_pricing_plans.py} | 0 ...s_rpc.py => test_api_pricing_plans_rpc.py} | 0 ...rt.py => test_api_service_runs__export.py} | 0 ...i_service_runs__list_aggregated_usages.py} | 0 ...> test_api_service_runs__list_billable.py} | 0 ...est_api_service_runs__list_with_wallet.py} | 0 ..._api_service_runs__list_without_wallet.py} | 0 .../api/v0/openapi.yaml | 487 ++++++++++++++---- .../simcore_service_webserver/application.py | 4 + .../catalog/licenses/_exceptions_handlers.py | 26 - .../catalog/licenses/_licensed_items_api.py | 77 --- .../catalog/licenses/errors.py | 9 - .../catalog/plugin.py | 3 - .../{catalog => }/licenses/__init__.py | 0 .../licenses/_exceptions_handlers.py | 40 ++ .../licenses/_licensed_items_api.py | 138 +++++ .../licenses/_licensed_items_db.py | 2 +- .../licenses/_licensed_items_handlers.py | 12 +- .../licenses/_licensed_items_purchases_api.py | 111 ++++ .../_licensed_items_purchases_handlers.py | 101 ++++ .../{catalog => }/licenses/_models.py | 34 +- .../{catalog => }/licenses/api.py | 0 .../licenses/errors.py | 13 + .../{catalog => }/licenses/plugin.py | 3 +- .../wallets/_handlers.py | 9 +- .../04/licenses/test_licensed_items_db.py | 4 +- .../licenses/test_licensed_items_handlers.py | 81 ++- .../test_licensed_items_purchases_handlers.py | 101 ++++ 54 files changed, 2002 insertions(+), 247 deletions(-) rename api/specs/web-server/{_catalog_licensed_items.py => _licensed_items.py} (90%) create mode 100644 api/specs/web-server/_licensed_items_purchases.py create mode 100644 packages/models-library/src/models_library/api_schemas_resource_usage_tracker/licensed_items_purchases.py create mode 100644 packages/models-library/src/models_library/api_schemas_webserver/licensed_items_purchases.py create mode 100644 packages/models-library/src/models_library/resource_tracker_licensed_items_purchases.py create mode 100644 packages/postgres-database/src/simcore_postgres_database/migration/versions/77ac824a77ff_add_cols_to_licensed_items_purchases_.py create mode 100644 packages/postgres-database/src/simcore_postgres_database/migration/versions/8fa15c4c3977_add_cols_to_licensed_items_purchases_.py create mode 100644 packages/postgres-database/src/simcore_postgres_database/migration/versions/d68b8128c23b_add_cols_to_licensed_items_purchases_.py create mode 100644 packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/licensed_items_purchases.py create mode 100644 services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_licensed_items_purchases.py create mode 100644 services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/models/licensed_items_purchases.py create mode 100644 services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/licensed_items_purchases.py create mode 100644 services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/licensed_items_purchases_db.py rename services/resource-usage-tracker/tests/unit/with_dbs/{test_api_resource_tracker_credit_transactions.py => test_api_credit_transactions.py} (100%) create mode 100644 services/resource-usage-tracker/tests/unit/with_dbs/test_api_licensed_items_purchases.py rename services/resource-usage-tracker/tests/unit/with_dbs/{test_api_resource_tracker_pricing_plans.py => test_api_pricing_plans.py} (100%) rename services/resource-usage-tracker/tests/unit/with_dbs/{test_api_resource_tracker_pricing_plans_rpc.py => test_api_pricing_plans_rpc.py} (100%) rename services/resource-usage-tracker/tests/unit/with_dbs/{test_api_resource_tracker_service_runs__export.py => test_api_service_runs__export.py} (100%) rename services/resource-usage-tracker/tests/unit/with_dbs/{test_api_resource_tracker_service_runs__list_aggregated_usages.py => test_api_service_runs__list_aggregated_usages.py} (100%) rename services/resource-usage-tracker/tests/unit/with_dbs/{test_api_resource_tracker_service_runs__list_billable.py => test_api_service_runs__list_billable.py} (100%) rename services/resource-usage-tracker/tests/unit/with_dbs/{test_api_resource_tracker_service_runs__list_with_wallet.py => test_api_service_runs__list_with_wallet.py} (100%) rename services/resource-usage-tracker/tests/unit/with_dbs/{test_api_resource_tracker_service_runs__list_without_wallet.py => test_api_service_runs__list_without_wallet.py} (100%) delete mode 100644 services/web/server/src/simcore_service_webserver/catalog/licenses/_exceptions_handlers.py delete mode 100644 services/web/server/src/simcore_service_webserver/catalog/licenses/_licensed_items_api.py delete mode 100644 services/web/server/src/simcore_service_webserver/catalog/licenses/errors.py rename services/web/server/src/simcore_service_webserver/{catalog => }/licenses/__init__.py (100%) create mode 100644 services/web/server/src/simcore_service_webserver/licenses/_exceptions_handlers.py create mode 100644 services/web/server/src/simcore_service_webserver/licenses/_licensed_items_api.py rename services/web/server/src/simcore_service_webserver/{catalog => }/licenses/_licensed_items_db.py (99%) rename services/web/server/src/simcore_service_webserver/{catalog => }/licenses/_licensed_items_handlers.py (92%) create mode 100644 services/web/server/src/simcore_service_webserver/licenses/_licensed_items_purchases_api.py create mode 100644 services/web/server/src/simcore_service_webserver/licenses/_licensed_items_purchases_handlers.py rename services/web/server/src/simcore_service_webserver/{catalog => }/licenses/_models.py (61%) rename services/web/server/src/simcore_service_webserver/{catalog => }/licenses/api.py (100%) create mode 100644 services/web/server/src/simcore_service_webserver/licenses/errors.py rename services/web/server/src/simcore_service_webserver/{catalog => }/licenses/plugin.py (80%) create mode 100644 services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_purchases_handlers.py diff --git a/api/specs/web-server/_catalog_licensed_items.py b/api/specs/web-server/_licensed_items.py similarity index 90% rename from api/specs/web-server/_catalog_licensed_items.py rename to api/specs/web-server/_licensed_items.py index 29b39853c95..377d6b9ab94 100644 --- a/api/specs/web-server/_catalog_licensed_items.py +++ b/api/specs/web-server/_licensed_items.py @@ -14,10 +14,8 @@ from models_library.generics import Envelope from models_library.rest_error import EnvelopedError from simcore_service_webserver._meta import API_VTAG -from simcore_service_webserver.catalog.licenses._exceptions_handlers import ( - _TO_HTTP_ERROR_MAP, -) -from simcore_service_webserver.catalog.licenses._models import ( +from simcore_service_webserver.licenses._exceptions_handlers import _TO_HTTP_ERROR_MAP +from simcore_service_webserver.licenses._models import ( LicensedItemsBodyParams, LicensedItemsListQueryParams, LicensedItemsPathParams, diff --git a/api/specs/web-server/_licensed_items_purchases.py b/api/specs/web-server/_licensed_items_purchases.py new file mode 100644 index 00000000000..8a993cef688 --- /dev/null +++ b/api/specs/web-server/_licensed_items_purchases.py @@ -0,0 +1,57 @@ +""" Helper script to generate OAS automatically +""" + +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments + +from typing import Annotated + +from _common import as_query +from fastapi import APIRouter, Depends +from models_library.api_schemas_webserver.licensed_items_purchases import ( + LicensedItemPurchaseGet, +) +from models_library.generics import Envelope +from models_library.rest_error import EnvelopedError +from models_library.rest_pagination import Page +from simcore_service_webserver._meta import API_VTAG +from simcore_service_webserver.licenses._exceptions_handlers import _TO_HTTP_ERROR_MAP +from simcore_service_webserver.licenses._models import ( + LicensedItemsPurchasesListQueryParams, + LicensedItemsPurchasesPathParams, +) +from simcore_service_webserver.wallets._handlers import WalletsPathParams + +router = APIRouter( + prefix=f"/{API_VTAG}", + tags=[ + "licenses", + ], + responses={ + i.status_code: {"model": EnvelopedError} for i in _TO_HTTP_ERROR_MAP.values() + }, +) + + +@router.get( + "/wallets/{wallet_id}/licensed-items-purchases", + response_model=Page[LicensedItemPurchaseGet], + tags=["wallets"], +) +async def list_wallet_licensed_items_purchases( + _path: Annotated[WalletsPathParams, Depends()], + _query: Annotated[as_query(LicensedItemsPurchasesListQueryParams), Depends()], +): + ... + + +@router.get( + "/licensed-items-purchases/{licensed_item_purchase_id}", + response_model=Envelope[LicensedItemPurchaseGet], +) +async def get_licensed_item_purchase( + _path: Annotated[LicensedItemsPurchasesPathParams, Depends()], +): + ... diff --git a/api/specs/web-server/openapi.py b/api/specs/web-server/openapi.py index b5fcb5fcb63..6c3bc639fb4 100644 --- a/api/specs/web-server/openapi.py +++ b/api/specs/web-server/openapi.py @@ -32,11 +32,12 @@ "_announcements", "_catalog", "_catalog_tags", # MUST BE after _catalog - "_catalog_licensed_items", "_computations", "_exporter", "_folders", "_long_running_tasks", + "_licensed_items", + "_licensed_items_purchases", "_metamodeling", "_nih_sparc", "_nih_sparc_redirections", diff --git a/packages/models-library/src/models_library/api_schemas_resource_usage_tracker/licensed_items_purchases.py b/packages/models-library/src/models_library/api_schemas_resource_usage_tracker/licensed_items_purchases.py new file mode 100644 index 00000000000..e75965a1b53 --- /dev/null +++ b/packages/models-library/src/models_library/api_schemas_resource_usage_tracker/licensed_items_purchases.py @@ -0,0 +1,56 @@ +from datetime import datetime +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 BaseModel, ConfigDict, PositiveInt + + +class LicensedItemPurchaseGet(BaseModel): + licensed_item_purchase_id: LicensedItemPurchaseID + product_name: ProductName + licensed_item_id: LicensedItemID + wallet_id: WalletID + wallet_name: str + pricing_unit_cost_id: PricingUnitCostId + pricing_unit_cost: Decimal + start_at: datetime + expire_at: datetime + num_of_seats: int + purchased_by_user: UserID + purchased_at: datetime + modified: datetime + + model_config = ConfigDict( + json_schema_extra={ + "examples": [ + { + "licensed_item_purchase_id": "beb16d18-d57d-44aa-a638-9727fa4a72ef", + "product_name": "osparc", + "licensed_item_id": "303942ef-6d31-4ba8-afbe-dbb1fce2a953", + "wallet_id": 1, + "wallet_name": "My Wallet", + "pricing_unit_cost_id": 1, + "pricing_unit_cost": 10, + "start_at": "2023-01-11 13:11:47.293595", + "expire_at": "2023-01-11 13:11:47.293595", + "num_of_seats": 1, + "purchased_by_user": 1, + "purchased_at": "2023-01-11 13:11:47.293595", + "modified": "2023-01-11 13:11:47.293595", + } + ] + } + ) + + +class LicensedItemsPurchasesPage(NamedTuple): + items: list[LicensedItemPurchaseGet] + 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 new file mode 100644 index 00000000000..0264e713256 --- /dev/null +++ b/packages/models-library/src/models_library/api_schemas_webserver/licensed_items_purchases.py @@ -0,0 +1,35 @@ +from datetime import datetime +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 ._base import OutputSchema + + +class LicensedItemPurchaseGet(OutputSchema): + licensed_item_purchase_id: LicensedItemPurchaseID + product_name: ProductName + licensed_item_id: LicensedItemID + wallet_id: WalletID + pricing_unit_cost_id: PricingUnitCostId + pricing_unit_cost: Decimal + start_at: datetime + expire_at: datetime + num_of_seats: int + purchased_by_user: UserID + purchased_at: datetime + modified_at: datetime + + +class LicensedItemPurchaseGetPage(NamedTuple): + items: list[LicensedItemPurchaseGet] + total: PositiveInt diff --git a/packages/models-library/src/models_library/api_schemas_webserver/wallets.py b/packages/models-library/src/models_library/api_schemas_webserver/wallets.py index c9460ab74c1..a4f33ab3cad 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/wallets.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/wallets.py @@ -20,18 +20,59 @@ class WalletGet(OutputSchema): created: datetime modified: datetime - model_config = ConfigDict(from_attributes=True, frozen=False) + model_config = ConfigDict( + from_attributes=True, + frozen=False, + json_schema_extra={ + "examples": [ + { + "wallet_id": 1, + "name": "My wallet", + "description": "My description", + "owner": 1, + "thumbnail": "https://example.com/payment-method/form", + "status": "ACTIVE", + "created": "2024-03-25T00:00:00", + "modified": "2024-03-25T00:00:00", + } + ] + }, + ) class WalletGetWithAvailableCredits(WalletGet): available_credits: Decimal + model_config = ConfigDict( + json_schema_extra={ + "examples": [ + { + **WalletGet.model_config["json_schema_extra"]["examples"][0], # type: ignore + "available_credits": 10.5, + } + ] + } + ) + class WalletGetPermissions(WalletGet): read: bool write: bool delete: bool + model_config = ConfigDict( + json_schema_extra={ + "examples": [ + { + **WalletGet.model_config["json_schema_extra"]["examples"][0], # type: ignore + "read": True, + "write": True, + "delete": True, + } + ] + } + ) + class CreateWalletBodyParams(OutputSchema): name: str diff --git a/packages/models-library/src/models_library/resource_tracker.py b/packages/models-library/src/models_library/resource_tracker.py index c94755817b3..20e35b7e614 100644 --- a/packages/models-library/src/models_library/resource_tracker.py +++ b/packages/models-library/src/models_library/resource_tracker.py @@ -48,6 +48,7 @@ class CreditTransactionStatus(StrAutoEnum): 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() class PricingPlanClassification(StrAutoEnum): diff --git a/packages/models-library/src/models_library/resource_tracker_licensed_items_purchases.py b/packages/models-library/src/models_library/resource_tracker_licensed_items_purchases.py new file mode 100644 index 00000000000..8cddc1d98aa --- /dev/null +++ b/packages/models-library/src/models_library/resource_tracker_licensed_items_purchases.py @@ -0,0 +1,33 @@ +from datetime import datetime +from decimal import Decimal +from typing import TypeAlias +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + +from .licensed_items import LicensedItemID +from .products import ProductName +from .resource_tracker import PricingPlanId, PricingUnitCostId, PricingUnitId +from .users import UserID +from .wallets import WalletID + +LicensedItemPurchaseID: TypeAlias = UUID + + +class LicensedItemsPurchasesCreate(BaseModel): + product_name: ProductName + licensed_item_id: LicensedItemID + wallet_id: WalletID + wallet_name: str + pricing_plan_id: PricingPlanId + pricing_unit_id: PricingUnitId + pricing_unit_cost_id: PricingUnitCostId + pricing_unit_cost: Decimal + start_at: datetime + expire_at: datetime + num_of_seats: int + purchased_by_user: UserID + user_email: str + purchased_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/77ac824a77ff_add_cols_to_licensed_items_purchases_.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/77ac824a77ff_add_cols_to_licensed_items_purchases_.py new file mode 100644 index 00000000000..d829ece7e7a --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/77ac824a77ff_add_cols_to_licensed_items_purchases_.py @@ -0,0 +1,38 @@ +"""add cols to licensed_items_purchases table 3 + +Revision ID: 77ac824a77ff +Revises: d68b8128c23b +Create Date: 2024-12-10 16:42:14.041313+00:00 + +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "77ac824a77ff" +down_revision = "d68b8128c23b" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "resource_tracker_credit_transactions", + sa.Column( + "licensed_item_purchase_id", postgresql.UUID(as_uuid=True), nullable=True + ), + ) + # ### end Alembic commands ### + op.execute( + sa.DDL( + "ALTER TYPE credittransactionclassification ADD VALUE 'DEDUCT_LICENSE_PURCHASE'" + ) + ) + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("resource_tracker_credit_transactions", "licensed_item_purchase_id") + # ### end Alembic commands ### diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/8fa15c4c3977_add_cols_to_licensed_items_purchases_.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/8fa15c4c3977_add_cols_to_licensed_items_purchases_.py new file mode 100644 index 00000000000..6f425116490 --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/8fa15c4c3977_add_cols_to_licensed_items_purchases_.py @@ -0,0 +1,45 @@ +"""add cols to licensed_items_purchases table + +Revision ID: 8fa15c4c3977 +Revises: 5e27063c3ac9 +Create Date: 2024-12-10 06:42:23.319239+00:00 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "8fa15c4c3977" +down_revision = "5e27063c3ac9" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "resource_tracker_licensed_items_purchases", + sa.Column("wallet_name", sa.String(), nullable=False), + ) + op.add_column( + "resource_tracker_licensed_items_purchases", + sa.Column("pricing_unit_cost_id", sa.BigInteger(), nullable=False), + ) + op.add_column( + "resource_tracker_licensed_items_purchases", + sa.Column("pricing_unit_cost", sa.Numeric(scale=2), nullable=True), + ) + op.add_column( + "resource_tracker_licensed_items_purchases", + sa.Column("num_of_seats", sa.SmallInteger(), nullable=False), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("resource_tracker_licensed_items_purchases", "num_of_seats") + op.drop_column("resource_tracker_licensed_items_purchases", "pricing_unit_cost") + op.drop_column("resource_tracker_licensed_items_purchases", "pricing_unit_cost_id") + op.drop_column("resource_tracker_licensed_items_purchases", "wallet_name") + # ### end Alembic commands ### diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/d68b8128c23b_add_cols_to_licensed_items_purchases_.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/d68b8128c23b_add_cols_to_licensed_items_purchases_.py new file mode 100644 index 00000000000..da729aec544 --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/d68b8128c23b_add_cols_to_licensed_items_purchases_.py @@ -0,0 +1,28 @@ +"""add cols to licensed_items_purchases table 2 + +Revision ID: d68b8128c23b +Revises: 8fa15c4c3977 +Create Date: 2024-12-10 10:24:28.071216+00:00 + +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "d68b8128c23b" +down_revision = "8fa15c4c3977" +branch_labels = None +depends_on = None + + +def upgrade(): + op.drop_column("resource_tracker_licensed_items_purchases", "licensed_item_id") + op.add_column( + "resource_tracker_licensed_items_purchases", + sa.Column("licensed_item_id", postgresql.UUID(as_uuid=True), nullable=False), + ) + + +def downgrade(): + ... 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 d1501a42431..ca4cc470b5f 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 @@ -4,6 +4,7 @@ import enum import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID from ._common import ( NUMERIC_KWARGS, @@ -26,6 +27,7 @@ class CreditTransactionClassification(str, enum.Enum): DEDUCT_SERVICE_RUN = ( "DEDUCT_SERVICE_RUN" # computational/dynamic service run costs) ) + DEDUCT_LICENSE_PURCHASE = "DEDUCT_LICENSE_PURCHASE" resource_tracker_credit_transactions = sa.Table( @@ -117,7 +119,13 @@ class CreditTransactionClassification(str, enum.Enum): "payment_transaction_id", sa.String, nullable=True, - doc="Service run id connected with this transaction", + doc="Payment transaction id connected with this transaction", + ), + sa.Column( + "licensed_item_purchase_id", + UUID(as_uuid=True), + nullable=True, + doc="Licensed item purchase id connected with this transaction", ), column_created_datetime(timezone=True), sa.Column( diff --git a/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_licensed_items_purchases.py b/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_licensed_items_purchases.py index 2a13e3d718e..bfcca3b52e8 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_licensed_items_purchases.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_licensed_items_purchases.py @@ -5,7 +5,7 @@ import sqlalchemy as sa from sqlalchemy.dialects.postgresql import UUID -from ._common import column_modified_datetime +from ._common import NUMERIC_KWARGS, column_modified_datetime from .base import metadata resource_tracker_licensed_items_purchases = sa.Table( @@ -26,7 +26,7 @@ ), sa.Column( "licensed_item_id", - sa.BigInteger, + UUID(as_uuid=True), nullable=False, ), sa.Column( @@ -34,6 +34,22 @@ sa.BigInteger, nullable=False, ), + sa.Column( + "wallet_name", + sa.String, + nullable=False, + ), + sa.Column( + "pricing_unit_cost_id", + sa.BigInteger, + nullable=False, + ), + sa.Column( + "pricing_unit_cost", + sa.Numeric(**NUMERIC_KWARGS), # type: ignore + nullable=True, + doc="Pricing unit cost used for billing purposes", + ), sa.Column( "start_at", sa.DateTime(timezone=True), @@ -46,6 +62,11 @@ nullable=False, server_default=sa.sql.func.now(), ), + sa.Column( + "num_of_seats", + sa.SmallInteger, + nullable=False, + ), sa.Column( "purchased_by_user", sa.BigInteger, 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 new file mode 100644 index 00000000000..a9463271d75 --- /dev/null +++ b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/licensed_items_purchases.py @@ -0,0 +1,86 @@ +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_purchases import ( + LicensedItemPurchaseGet, + LicensedItemsPurchasesPage, +) +from models_library.basic_types import IDStr +from models_library.products import ProductName +from models_library.rabbitmq_basic_types import RPCMethodName +from models_library.resource_tracker_licensed_items_purchases import ( + LicensedItemPurchaseID, + LicensedItemsPurchasesCreate, +) +from models_library.rest_ordering import OrderBy +from models_library.wallets import WalletID +from pydantic import AnyUrl, NonNegativeInt, TypeAdapter + +from ....logging_utils import log_decorator +from ....rabbitmq 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_items_purchases_page( + rabbitmq_rpc_client: RabbitMQRPCClient, + *, + product_name: ProductName, + wallet_id: WalletID, + offset: int = 0, + limit: int = 20, + order_by: OrderBy = OrderBy(field=IDStr("purchased_at")), +) -> LicensedItemsPurchasesPage: + result = await rabbitmq_rpc_client.request( + RESOURCE_USAGE_TRACKER_RPC_NAMESPACE, + _RPC_METHOD_NAME_ADAPTER.validate_python("get_licensed_items_purchases_page"), + product_name=product_name, + wallet_id=wallet_id, + limit=limit, + offset=offset, + order_by=order_by, + timeout_s=_DEFAULT_TIMEOUT_S, + ) + assert isinstance(result, LicensedItemsPurchasesPage) # nosec + return result + + +@log_decorator(_logger, level=logging.DEBUG) +async def get_licensed_item_purchase( + rabbitmq_rpc_client: RabbitMQRPCClient, + *, + product_name: ProductName, + licensed_item_purchase_id: LicensedItemPurchaseID, +) -> LicensedItemPurchaseGet: + result = await rabbitmq_rpc_client.request( + RESOURCE_USAGE_TRACKER_RPC_NAMESPACE, + _RPC_METHOD_NAME_ADAPTER.validate_python("get_licensed_item_purchase"), + product_name=product_name, + licensed_item_purchase_id=licensed_item_purchase_id, + timeout_s=_DEFAULT_TIMEOUT_S, + ) + assert isinstance(result, LicensedItemPurchaseGet) # nosec + return result + + +@log_decorator(_logger, level=logging.DEBUG) +async def create_licensed_item_purchase( + rabbitmq_rpc_client: RabbitMQRPCClient, *, data: LicensedItemsPurchasesCreate +) -> LicensedItemPurchaseGet: + result: AnyUrl = await rabbitmq_rpc_client.request( + RESOURCE_USAGE_TRACKER_RPC_NAMESPACE, + _RPC_METHOD_NAME_ADAPTER.validate_python("create_licensed_item_purchase"), + data=data, + timeout_s=_DEFAULT_TIMEOUT_S, + ) + assert isinstance(result, LicensedItemPurchaseGet) # nosec + return result diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_licensed_items_purchases.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_licensed_items_purchases.py new file mode 100644 index 00000000000..e8f71dfb97d --- /dev/null +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_licensed_items_purchases.py @@ -0,0 +1,61 @@ +from fastapi import FastAPI +from models_library.api_schemas_resource_usage_tracker.licensed_items_purchases import ( + LicensedItemPurchaseGet, + LicensedItemsPurchasesPage, +) +from models_library.basic_types import IDStr +from models_library.products import ProductName +from models_library.resource_tracker_licensed_items_purchases import ( + LicensedItemPurchaseID, + LicensedItemsPurchasesCreate, +) +from models_library.rest_ordering import OrderBy +from models_library.wallets import WalletID +from servicelib.rabbitmq import RPCRouter + +from ...services import licensed_items_purchases + +router = RPCRouter() + + +@router.expose(reraise_if_error_type=()) +async def get_licensed_items_purchases_page( + app: FastAPI, + *, + product_name: ProductName, + wallet_id: WalletID, + offset: int = 0, + limit: int = 20, + order_by: OrderBy = OrderBy(field=IDStr("purchased_at")), +) -> LicensedItemsPurchasesPage: + return await licensed_items_purchases.list_licensed_items_purchases( + db_engine=app.state.engine, + product_name=product_name, + offset=offset, + limit=limit, + filter_wallet_id=wallet_id, + order_by=order_by, + ) + + +@router.expose(reraise_if_error_type=()) +async def get_licensed_item_purchase( + app: FastAPI, + *, + product_name: ProductName, + licensed_item_purchase_id: LicensedItemPurchaseID, +) -> LicensedItemPurchaseGet: + return await licensed_items_purchases.get_licensed_item_purchase( + db_engine=app.state.engine, + product_name=product_name, + licensed_item_purchase_id=licensed_item_purchase_id, + ) + + +@router.expose(reraise_if_error_type=()) +async def create_licensed_item_purchase( + app: FastAPI, *, data: LicensedItemsPurchasesCreate +) -> LicensedItemPurchaseGet: + return await licensed_items_purchases.create_licensed_item_purchase( + rabbitmq_client=app.state.rabbitmq_client, db_engine=app.state.engine, data=data + ) 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 ff2e1cdb0bb..f1fd1276161 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,14 @@ from servicelib.rabbitmq import RPCRouter from ...services.modules.rabbitmq import get_rabbitmq_rpc_server -from . import _resource_tracker +from . import _licensed_items_purchases, _resource_tracker _logger = logging.getLogger(__name__) ROUTERS: list[RPCRouter] = [ _resource_tracker.router, + _licensed_items_purchases.router, ] diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/exceptions/errors.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/exceptions/errors.py index fe620d99c62..55fde04b0f6 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/exceptions/errors.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/exceptions/errors.py @@ -1,4 +1,7 @@ from common_library.errors_classes import OsparcErrorMixin +from models_library.resource_tracker_licensed_items_purchases import ( + LicensedItemPurchaseID, +) class ResourceUsageTrackerBaseError(OsparcErrorMixin, Exception): @@ -68,3 +71,7 @@ class PricingPlanNotFoundForServiceError(RutNotFoundError): msg_template = ( "Pricing plan not found for service key {service_key} version {service_version}" ) + + +class LicensedItemPurchaseNotFoundError(RutNotFoundError): + licensed_item_purchase_id: LicensedItemPurchaseID 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 4cdf74b6429..b9fd942fee0 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 @@ -11,6 +11,9 @@ PricingUnitId, ServiceRunId, ) +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 BaseModel, ConfigDict @@ -32,6 +35,7 @@ class CreditTransactionCreate(BaseModel): payment_transaction_id: str | None created_at: datetime last_heartbeat_at: datetime + licensed_item_purchase_id: LicensedItemPurchaseID | None class CreditTransactionCreditsUpdate(BaseModel): diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/models/licensed_items_purchases.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/models/licensed_items_purchases.py new file mode 100644 index 00000000000..4458bd2c258 --- /dev/null +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/models/licensed_items_purchases.py @@ -0,0 +1,46 @@ +from datetime import datetime +from decimal import Decimal + +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 BaseModel, ConfigDict + + +class LicensedItemsPurchasesDB(BaseModel): + licensed_item_purchase_id: LicensedItemPurchaseID + product_name: ProductName + licensed_item_id: LicensedItemID + wallet_id: WalletID + wallet_name: str + pricing_unit_cost_id: PricingUnitCostId + pricing_unit_cost: Decimal + start_at: datetime + expire_at: datetime + num_of_seats: int + purchased_by_user: UserID + purchased_at: datetime + modified: datetime + + model_config = ConfigDict(from_attributes=True) + + +class CreateLicensedItemsPurchasesDB(BaseModel): + product_name: ProductName + licensed_item_id: LicensedItemID + wallet_id: WalletID + wallet_name: str + pricing_unit_cost_id: PricingUnitCostId + pricing_unit_cost: Decimal + start_at: datetime + expire_at: datetime + num_of_seats: int + purchased_by_user: UserID + purchased_at: datetime + + model_config = ConfigDict(from_attributes=True) 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 c58eb76be8a..fa314ee2550 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 @@ -43,6 +43,7 @@ async def create_credit_transaction( transaction_classification=CreditClassification.ADD_WALLET_TOP_UP, service_run_id=None, payment_transaction_id=credit_transaction_create_body.payment_transaction_id, + licensed_item_purchase_id=None, created_at=credit_transaction_create_body.created_at, last_heartbeat_at=credit_transaction_create_body.created_at, ) diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/licensed_items_purchases.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/licensed_items_purchases.py new file mode 100644 index 00000000000..f88316e095b --- /dev/null +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/licensed_items_purchases.py @@ -0,0 +1,174 @@ +from typing import Annotated + +from fastapi import Depends +from models_library.api_schemas_resource_usage_tracker.licensed_items_purchases import ( + LicensedItemPurchaseGet, + LicensedItemsPurchasesPage, +) +from models_library.products import ProductName +from models_library.resource_tracker import ( + CreditClassification, + CreditTransactionStatus, +) +from models_library.resource_tracker_licensed_items_purchases import ( + LicensedItemPurchaseID, + LicensedItemsPurchasesCreate, +) +from models_library.rest_ordering import OrderBy +from models_library.wallets import WalletID +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 ..models.licensed_items_purchases import ( + CreateLicensedItemsPurchasesDB, + LicensedItemsPurchasesDB, +) +from .modules.db import credit_transactions_db, licensed_items_purchases_db +from .modules.rabbitmq import RabbitMQClient, get_rabbitmq_client +from .utils import make_negative, sum_credit_transactions_and_publish_to_rabbitmq + + +async def list_licensed_items_purchases( + db_engine: Annotated[AsyncEngine, Depends(get_resource_tracker_db_engine)], + *, + product_name: ProductName, + filter_wallet_id: WalletID, + offset: int = 0, + limit: int = 20, + order_by: OrderBy, +) -> LicensedItemsPurchasesPage: + total, licensed_items_purchases_list_db = await licensed_items_purchases_db.list_( + db_engine, + product_name=product_name, + filter_wallet_id=filter_wallet_id, + offset=offset, + limit=limit, + order_by=order_by, + ) + return LicensedItemsPurchasesPage( + total=total, + items=[ + LicensedItemPurchaseGet( + licensed_item_purchase_id=licensed_item_purchase_db.licensed_item_purchase_id, + product_name=licensed_item_purchase_db.product_name, + licensed_item_id=licensed_item_purchase_db.licensed_item_id, + wallet_id=licensed_item_purchase_db.wallet_id, + wallet_name=licensed_item_purchase_db.wallet_name, + pricing_unit_cost_id=licensed_item_purchase_db.pricing_unit_cost_id, + pricing_unit_cost=licensed_item_purchase_db.pricing_unit_cost, + start_at=licensed_item_purchase_db.start_at, + expire_at=licensed_item_purchase_db.expire_at, + num_of_seats=licensed_item_purchase_db.num_of_seats, + purchased_by_user=licensed_item_purchase_db.purchased_by_user, + purchased_at=licensed_item_purchase_db.purchased_at, + modified=licensed_item_purchase_db.modified, + ) + for licensed_item_purchase_db in licensed_items_purchases_list_db + ], + ) + + +async def get_licensed_item_purchase( + db_engine: Annotated[AsyncEngine, Depends(get_resource_tracker_db_engine)], + *, + product_name: ProductName, + licensed_item_purchase_id: LicensedItemPurchaseID, +) -> LicensedItemPurchaseGet: + licensed_item_purchase_db: LicensedItemsPurchasesDB = ( + await licensed_items_purchases_db.get( + db_engine, + product_name=product_name, + licensed_item_purchase_id=licensed_item_purchase_id, + ) + ) + + return LicensedItemPurchaseGet( + licensed_item_purchase_id=licensed_item_purchase_db.licensed_item_purchase_id, + product_name=licensed_item_purchase_db.product_name, + licensed_item_id=licensed_item_purchase_db.licensed_item_id, + wallet_id=licensed_item_purchase_db.wallet_id, + wallet_name=licensed_item_purchase_db.wallet_name, + pricing_unit_cost_id=licensed_item_purchase_db.pricing_unit_cost_id, + pricing_unit_cost=licensed_item_purchase_db.pricing_unit_cost, + start_at=licensed_item_purchase_db.start_at, + expire_at=licensed_item_purchase_db.expire_at, + num_of_seats=licensed_item_purchase_db.num_of_seats, + purchased_by_user=licensed_item_purchase_db.purchased_by_user, + purchased_at=licensed_item_purchase_db.purchased_at, + modified=licensed_item_purchase_db.modified, + ) + + +async def create_licensed_item_purchase( + rabbitmq_client: Annotated[RabbitMQClient, Depends(get_rabbitmq_client)], + db_engine: Annotated[AsyncEngine, Depends(get_resource_tracker_db_engine)], + *, + data: LicensedItemsPurchasesCreate, +) -> LicensedItemPurchaseGet: + + async with transaction_context(db_engine) as conn: + item_purchase_create = CreateLicensedItemsPurchasesDB( + product_name=data.product_name, + licensed_item_id=data.licensed_item_id, + wallet_id=data.wallet_id, + wallet_name=data.wallet_name, + pricing_unit_cost_id=data.pricing_unit_cost_id, + pricing_unit_cost=data.pricing_unit_cost, + start_at=data.start_at, + expire_at=data.expire_at, + num_of_seats=data.num_of_seats, + purchased_by_user=data.purchased_by_user, + purchased_at=data.purchased_at, + ) + + licensed_item_purchase_db: LicensedItemsPurchasesDB = ( + await licensed_items_purchases_db.create( + db_engine, connection=conn, data=item_purchase_create + ) + ) + + # Deduct credits from credit_transactions table + transaction_create = CreditTransactionCreate( + product_name=data.product_name, + wallet_id=data.wallet_id, + wallet_name=data.wallet_name, + pricing_plan_id=data.pricing_plan_id, + pricing_unit_id=data.pricing_unit_id, + pricing_unit_cost_id=data.pricing_unit_cost_id, + user_id=data.purchased_by_user, + user_email=data.user_email, + osparc_credits=make_negative(data.pricing_unit_cost), + transaction_status=CreditTransactionStatus.BILLED, + transaction_classification=CreditClassification.DEDUCT_LICENSE_PURCHASE, + service_run_id=None, + payment_transaction_id=None, + licensed_item_purchase_id=licensed_item_purchase_db.licensed_item_purchase_id, + created_at=data.start_at, + last_heartbeat_at=data.start_at, + ) + await credit_transactions_db.create_credit_transaction( + db_engine, connection=conn, data=transaction_create + ) + + # Publish wallet total credits to RabbitMQ + await sum_credit_transactions_and_publish_to_rabbitmq( + db_engine, rabbitmq_client, data.product_name, data.wallet_id + ) + + return LicensedItemPurchaseGet( + licensed_item_purchase_id=licensed_item_purchase_db.licensed_item_purchase_id, + product_name=licensed_item_purchase_db.product_name, + licensed_item_id=licensed_item_purchase_db.licensed_item_id, + wallet_id=licensed_item_purchase_db.wallet_id, + wallet_name=licensed_item_purchase_db.wallet_name, + pricing_unit_cost_id=licensed_item_purchase_db.pricing_unit_cost_id, + pricing_unit_cost=licensed_item_purchase_db.pricing_unit_cost, + start_at=licensed_item_purchase_db.start_at, + expire_at=licensed_item_purchase_db.expire_at, + num_of_seats=licensed_item_purchase_db.num_of_seats, + purchased_by_user=licensed_item_purchase_db.purchased_by_user, + purchased_at=licensed_item_purchase_db.purchased_at, + modified=licensed_item_purchase_db.modified, + ) 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 76a8e9f1dfe..254a36a9732 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 @@ -48,6 +48,7 @@ async def create_credit_transaction( transaction_classification=data.transaction_classification, service_run_id=data.service_run_id, payment_transaction_id=data.payment_transaction_id, + licensed_item_purchase_id=data.licensed_item_purchase_id, created=data.created_at, last_heartbeat_at=data.last_heartbeat_at, modified=sa.func.now(), 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 new file mode 100644 index 00000000000..e9951042ddc --- /dev/null +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/licensed_items_purchases_db.py @@ -0,0 +1,152 @@ +from typing import cast + +import sqlalchemy as sa +from models_library.products import ProductName +from models_library.resource_tracker_licensed_items_purchases import ( + LicensedItemPurchaseID, +) +from models_library.rest_ordering import OrderBy, OrderDirection +from models_library.wallets import WalletID +from pydantic import NonNegativeInt +from simcore_postgres_database.models.resource_tracker_licensed_items_purchases import ( + resource_tracker_licensed_items_purchases, +) +from simcore_postgres_database.utils_repos import ( + pass_or_acquire_connection, + transaction_context, +) +from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine + +from ....exceptions.errors import LicensedItemPurchaseNotFoundError +from ....models.licensed_items_purchases import ( + CreateLicensedItemsPurchasesDB, + LicensedItemsPurchasesDB, +) + +_SELECTION_ARGS = ( + resource_tracker_licensed_items_purchases.c.licensed_item_purchase_id, + resource_tracker_licensed_items_purchases.c.product_name, + resource_tracker_licensed_items_purchases.c.licensed_item_id, + resource_tracker_licensed_items_purchases.c.wallet_id, + resource_tracker_licensed_items_purchases.c.wallet_name, + resource_tracker_licensed_items_purchases.c.pricing_unit_cost_id, + resource_tracker_licensed_items_purchases.c.pricing_unit_cost, + resource_tracker_licensed_items_purchases.c.start_at, + resource_tracker_licensed_items_purchases.c.expire_at, + resource_tracker_licensed_items_purchases.c.num_of_seats, + resource_tracker_licensed_items_purchases.c.purchased_by_user, + resource_tracker_licensed_items_purchases.c.purchased_at, + resource_tracker_licensed_items_purchases.c.modified, +) + +assert set(LicensedItemsPurchasesDB.model_fields) == { + c.name for c in _SELECTION_ARGS +} # nosec + + +async def create( + engine: AsyncEngine, + connection: AsyncConnection | None = None, + *, + data: CreateLicensedItemsPurchasesDB, +) -> LicensedItemsPurchasesDB: + async with transaction_context(engine, connection) as conn: + result = await conn.execute( + resource_tracker_licensed_items_purchases.insert() + .values( + product_name=data.product_name, + licensed_item_id=data.licensed_item_id, + wallet_id=data.wallet_id, + wallet_name=data.wallet_name, + pricing_unit_cost_id=data.pricing_unit_cost_id, + pricing_unit_cost=data.pricing_unit_cost, + start_at=data.start_at, + expire_at=data.expire_at, + num_of_seats=data.num_of_seats, + purchased_by_user=data.purchased_by_user, + purchased_at=data.purchased_at, + modified=sa.func.now(), + ) + .returning(*_SELECTION_ARGS) + ) + row = result.first() + return LicensedItemsPurchasesDB.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[LicensedItemsPurchasesDB]]: + base_query = ( + sa.select(*_SELECTION_ARGS) + .select_from(resource_tracker_licensed_items_purchases) + .where( + (resource_tracker_licensed_items_purchases.c.product_name == product_name) + & ( + resource_tracker_licensed_items_purchases.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_purchases.c, order_by.field)) + ) + else: + list_query = base_query.order_by( + sa.desc( + getattr(resource_tracker_licensed_items_purchases.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) + + result = await conn.stream(list_query) + items: list[LicensedItemsPurchasesDB] = [ + LicensedItemsPurchasesDB.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_purchase_id: LicensedItemPurchaseID, + product_name: ProductName, +) -> LicensedItemsPurchasesDB: + base_query = ( + sa.select(*_SELECTION_ARGS) + .select_from(resource_tracker_licensed_items_purchases) + .where( + ( + resource_tracker_licensed_items_purchases.c.licensed_item_purchase_id + == licensed_item_purchase_id + ) + & (resource_tracker_licensed_items_purchases.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 LicensedItemPurchaseNotFoundError( + licensed_item_purchase_id=licensed_item_purchase_id + ) + return LicensedItemsPurchasesDB.model_validate(row) 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 8300ede8283..e9234f65435 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 @@ -143,6 +143,7 @@ async def _process_start_event( transaction_classification=CreditClassification.DEDUCT_SERVICE_RUN, service_run_id=service_run_id, payment_transaction_id=None, + licensed_item_purchase_id=None, created_at=msg.created_at, last_heartbeat_at=msg.created_at, ) diff --git a/services/resource-usage-tracker/tests/unit/with_dbs/test_api_resource_tracker_credit_transactions.py b/services/resource-usage-tracker/tests/unit/with_dbs/test_api_credit_transactions.py similarity index 100% rename from services/resource-usage-tracker/tests/unit/with_dbs/test_api_resource_tracker_credit_transactions.py rename to services/resource-usage-tracker/tests/unit/with_dbs/test_api_credit_transactions.py diff --git a/services/resource-usage-tracker/tests/unit/with_dbs/test_api_licensed_items_purchases.py b/services/resource-usage-tracker/tests/unit/with_dbs/test_api_licensed_items_purchases.py new file mode 100644 index 00000000000..e5920728d3c --- /dev/null +++ b/services/resource-usage-tracker/tests/unit/with_dbs/test_api_licensed_items_purchases.py @@ -0,0 +1,78 @@ +# pylint:disable=unused-variable +# pylint:disable=unused-argument +# pylint:disable=redefined-outer-name +# pylint:disable=too-many-arguments + +from datetime import UTC, datetime +from decimal import Decimal + +import sqlalchemy as sa +from models_library.api_schemas_resource_usage_tracker.licensed_items_purchases import ( + LicensedItemPurchaseGet, + LicensedItemsPurchasesPage, +) +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_purchases, +) + +pytest_simcore_core_services_selection = [ + "postgres", + "rabbit", +] +pytest_simcore_ops_services_selection = [ + "adminer", +] + + +async def test_rpc_licensed_items_purchases_workflow( + mocked_redis_server: None, + postgres_db: sa.engine.Engine, + rpc_client: RabbitMQRPCClient, +): + result = await licensed_items_purchases.get_licensed_items_purchases_page( + rpc_client, product_name="osparc", wallet_id=1 + ) + assert isinstance(result, LicensedItemsPurchasesPage) # nosec + assert result.items == [] + assert result.total == 0 + + _create_data = LicensedItemsPurchasesCreate( + product_name="osparc", + licensed_item_id="beb16d18-d57d-44aa-a638-9727fa4a72ef", + wallet_id=1, + 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), + num_of_seats=1, + purchased_by_user=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 + ) + assert isinstance(created_item, LicensedItemPurchaseGet) # nosec + + result = await licensed_items_purchases.get_licensed_item_purchase( + rpc_client, + product_name="osparc", + licensed_item_purchase_id=created_item.licensed_item_purchase_id, + ) + assert isinstance(result, LicensedItemPurchaseGet) # nosec + assert result.licensed_item_purchase_id == created_item.licensed_item_purchase_id + + result = await licensed_items_purchases.get_licensed_items_purchases_page( + rpc_client, product_name="osparc", wallet_id=_create_data.wallet_id + ) + assert isinstance(result, LicensedItemsPurchasesPage) # nosec + assert len(result.items) == 1 + assert result.total == 1 diff --git a/services/resource-usage-tracker/tests/unit/with_dbs/test_api_resource_tracker_pricing_plans.py b/services/resource-usage-tracker/tests/unit/with_dbs/test_api_pricing_plans.py similarity index 100% rename from services/resource-usage-tracker/tests/unit/with_dbs/test_api_resource_tracker_pricing_plans.py rename to services/resource-usage-tracker/tests/unit/with_dbs/test_api_pricing_plans.py diff --git a/services/resource-usage-tracker/tests/unit/with_dbs/test_api_resource_tracker_pricing_plans_rpc.py b/services/resource-usage-tracker/tests/unit/with_dbs/test_api_pricing_plans_rpc.py similarity index 100% rename from services/resource-usage-tracker/tests/unit/with_dbs/test_api_resource_tracker_pricing_plans_rpc.py rename to services/resource-usage-tracker/tests/unit/with_dbs/test_api_pricing_plans_rpc.py diff --git a/services/resource-usage-tracker/tests/unit/with_dbs/test_api_resource_tracker_service_runs__export.py b/services/resource-usage-tracker/tests/unit/with_dbs/test_api_service_runs__export.py similarity index 100% rename from services/resource-usage-tracker/tests/unit/with_dbs/test_api_resource_tracker_service_runs__export.py rename to services/resource-usage-tracker/tests/unit/with_dbs/test_api_service_runs__export.py diff --git a/services/resource-usage-tracker/tests/unit/with_dbs/test_api_resource_tracker_service_runs__list_aggregated_usages.py b/services/resource-usage-tracker/tests/unit/with_dbs/test_api_service_runs__list_aggregated_usages.py similarity index 100% rename from services/resource-usage-tracker/tests/unit/with_dbs/test_api_resource_tracker_service_runs__list_aggregated_usages.py rename to services/resource-usage-tracker/tests/unit/with_dbs/test_api_service_runs__list_aggregated_usages.py diff --git a/services/resource-usage-tracker/tests/unit/with_dbs/test_api_resource_tracker_service_runs__list_billable.py b/services/resource-usage-tracker/tests/unit/with_dbs/test_api_service_runs__list_billable.py similarity index 100% rename from services/resource-usage-tracker/tests/unit/with_dbs/test_api_resource_tracker_service_runs__list_billable.py rename to services/resource-usage-tracker/tests/unit/with_dbs/test_api_service_runs__list_billable.py diff --git a/services/resource-usage-tracker/tests/unit/with_dbs/test_api_resource_tracker_service_runs__list_with_wallet.py b/services/resource-usage-tracker/tests/unit/with_dbs/test_api_service_runs__list_with_wallet.py similarity index 100% rename from services/resource-usage-tracker/tests/unit/with_dbs/test_api_resource_tracker_service_runs__list_with_wallet.py rename to services/resource-usage-tracker/tests/unit/with_dbs/test_api_service_runs__list_with_wallet.py diff --git a/services/resource-usage-tracker/tests/unit/with_dbs/test_api_resource_tracker_service_runs__list_without_wallet.py b/services/resource-usage-tracker/tests/unit/with_dbs/test_api_service_runs__list_without_wallet.py similarity index 100% rename from services/resource-usage-tracker/tests/unit/with_dbs/test_api_resource_tracker_service_runs__list_without_wallet.py rename to services/resource-usage-tracker/tests/unit/with_dbs/test_api_service_runs__list_without_wallet.py 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 9a92419e514..fe6f65828b9 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 @@ -2413,108 +2413,6 @@ paths: application/json: schema: $ref: '#/components/schemas/Envelope_CatalogServiceGet_' - /v0/catalog/licensed-items: - get: - tags: - - licenses - - catalog - summary: List Licensed Items - operationId: list_licensed_items - parameters: - - name: order_by - in: query - required: false - schema: - type: string - contentMediaType: application/json - contentSchema: {} - default: '{"field":"modified","direction":"desc"}' - title: Order By - - name: limit - in: query - required: false - schema: - type: integer - default: 20 - title: Limit - - name: offset - in: query - required: false - schema: - type: integer - default: 0 - title: Offset - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Envelope_list_LicensedItemGet__' - '404': - content: - application/json: - schema: - $ref: '#/components/schemas/EnvelopedError' - description: Not Found - /v0/catalog/licensed-items/{licensed_item_id}: - get: - tags: - - licenses - - catalog - summary: Get Licensed Item - operationId: get_licensed_item - parameters: - - name: licensed_item_id - in: path - required: true - schema: - type: string - format: uuid - title: Licensed Item Id - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Envelope_LicensedItemGet_' - '404': - content: - application/json: - schema: - $ref: '#/components/schemas/EnvelopedError' - description: Not Found - /v0/catalog/licensed-items/{licensed_item_id}:purchase: - post: - tags: - - licenses - - catalog - summary: Purchase Licensed Item - operationId: purchase_licensed_item - parameters: - - name: licensed_item_id - in: path - required: true - schema: - type: string - format: uuid - title: Licensed Item Id - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/LicensedItemsBodyParams' - responses: - '204': - description: Successful Response - '404': - content: - application/json: - schema: - $ref: '#/components/schemas/EnvelopedError' - description: Not Found /v0/computations/{project_id}: get: tags: @@ -3083,6 +2981,277 @@ paths: content: application/json: schema: {} + /v0/catalog/licensed-items: + get: + tags: + - licenses + - catalog + summary: List Licensed Items + operationId: list_licensed_items + parameters: + - name: order_by + in: query + required: false + schema: + type: string + contentMediaType: application/json + contentSchema: {} + default: '{"field":"modified","direction":"desc"}' + title: Order By + - name: limit + in: query + required: false + schema: + type: integer + default: 20 + title: Limit + - name: offset + in: query + required: false + schema: + type: integer + default: 0 + title: Offset + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope_list_LicensedItemGet__' + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Not Found + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Forbidden + '402': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Payment Required + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Bad Request + /v0/catalog/licensed-items/{licensed_item_id}: + get: + tags: + - licenses + - catalog + summary: Get Licensed Item + operationId: get_licensed_item + parameters: + - name: licensed_item_id + in: path + required: true + schema: + type: string + format: uuid + title: Licensed Item Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope_LicensedItemGet_' + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Not Found + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Forbidden + '402': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Payment Required + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Bad Request + /v0/catalog/licensed-items/{licensed_item_id}:purchase: + post: + tags: + - licenses + - catalog + summary: Purchase Licensed Item + operationId: purchase_licensed_item + parameters: + - name: licensed_item_id + in: path + required: true + schema: + type: string + format: uuid + title: Licensed Item Id + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LicensedItemsBodyParams' + responses: + '204': + description: Successful Response + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Not Found + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Forbidden + '402': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Payment Required + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Bad Request + /v0/wallets/{wallet_id}/licensed-items-purchases: + get: + tags: + - licenses + - wallets + summary: List Wallet Licensed Items Purchases + operationId: list_wallet_licensed_items_purchases + parameters: + - name: wallet_id + in: path + required: true + schema: + type: integer + exclusiveMinimum: true + title: Wallet Id + minimum: 0 + - name: order_by + in: query + required: false + schema: + type: string + contentMediaType: application/json + contentSchema: {} + default: '{"field":"purchased_at","direction":"desc"}' + title: Order By + - name: limit + in: query + required: false + schema: + type: integer + default: 20 + title: Limit + - name: offset + in: query + required: false + schema: + type: integer + default: 0 + title: Offset + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Page_LicensedItemPurchaseGet_' + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Not Found + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Forbidden + '402': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Payment Required + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Bad Request + /v0/licensed-items-purchases/{licensed_item_purchase_id}: + get: + tags: + - licenses + summary: Get Licensed Item Purchase + operationId: get_licensed_item_purchase + parameters: + - name: licensed_item_purchase_id + in: path + required: true + schema: + type: string + format: uuid + title: Licensed Item Purchase Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope_LicensedItemPurchaseGet_' + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Not Found + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Forbidden + '402': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Payment Required + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Bad Request /v0/projects/{project_uuid}/checkpoint/{ref_id}/iterations: get: tags: @@ -7951,6 +8120,19 @@ components: title: Error type: object title: Envelope[LicensedItemGet] + Envelope_LicensedItemPurchaseGet_: + properties: + data: + anyOf: + - $ref: '#/components/schemas/LicensedItemPurchaseGet' + - type: 'null' + error: + anyOf: + - {} + - type: 'null' + title: Error + type: object + title: Envelope[LicensedItemPurchaseGet] Envelope_Log_: properties: data: @@ -10178,6 +10360,71 @@ components: - createdAt - modifiedAt title: LicensedItemGet + LicensedItemPurchaseGet: + properties: + licensedItemPurchaseId: + type: string + format: uuid + title: Licenseditempurchaseid + productName: + type: string + title: Productname + licensedItemId: + type: string + format: uuid + title: Licenseditemid + walletId: + type: integer + exclusiveMinimum: true + title: Walletid + minimum: 0 + pricingUnitCostId: + type: integer + exclusiveMinimum: true + title: Pricingunitcostid + minimum: 0 + pricingUnitCost: + type: string + title: Pricingunitcost + startAt: + type: string + format: date-time + title: Startat + expireAt: + type: string + format: date-time + title: Expireat + numOfSeats: + type: integer + title: Numofseats + purchasedByUser: + type: integer + exclusiveMinimum: true + title: Purchasedbyuser + minimum: 0 + purchasedAt: + type: string + format: date-time + title: Purchasedat + modifiedAt: + type: string + format: date-time + title: Modifiedat + type: object + required: + - licensedItemPurchaseId + - productName + - licensedItemId + - walletId + - pricingUnitCostId + - pricingUnitCost + - startAt + - expireAt + - numOfSeats + - purchasedByUser + - purchasedAt + - modifiedAt + title: LicensedItemPurchaseGet LicensedItemsBodyParams: properties: wallet_id: @@ -10185,14 +10432,26 @@ components: exclusiveMinimum: true title: Wallet Id minimum: 0 - num_of_seeds: + pricing_plan_id: + type: integer + exclusiveMinimum: true + title: Pricing Plan Id + minimum: 0 + pricing_unit_id: type: integer - title: Num Of Seeds + exclusiveMinimum: true + title: Pricing Unit Id + minimum: 0 + num_of_seats: + type: integer + title: Num Of Seats additionalProperties: false type: object required: - wallet_id - - num_of_seeds + - pricing_plan_id + - pricing_unit_id + - num_of_seats title: LicensedItemsBodyParams LicensedResourceType: type: string @@ -11229,6 +11488,24 @@ components: - _links - data title: Page[CheckpointApiModel] + Page_LicensedItemPurchaseGet_: + properties: + _meta: + $ref: '#/components/schemas/PageMetaInfoLimitOffset' + _links: + $ref: '#/components/schemas/PageLinks' + data: + items: + $ref: '#/components/schemas/LicensedItemPurchaseGet' + type: array + title: Data + additionalProperties: false + type: object + required: + - _meta + - _links + - data + title: Page[LicensedItemPurchaseGet] Page_PaymentTransaction_: properties: _meta: diff --git a/services/web/server/src/simcore_service_webserver/application.py b/services/web/server/src/simcore_service_webserver/application.py index 79477051ddb..a868e345380 100644 --- a/services/web/server/src/simcore_service_webserver/application.py +++ b/services/web/server/src/simcore_service_webserver/application.py @@ -26,6 +26,7 @@ from .garbage_collector.plugin import setup_garbage_collector from .groups.plugin import setup_groups from .invitations.plugin import setup_invitations +from .licenses.plugin import setup_licenses from .login.plugin import setup_login from .long_running_tasks import setup_long_running_tasks from .meta_modeling.plugin import setup_meta_modeling @@ -139,6 +140,9 @@ def create_application() -> web.Application: setup_version_control(app) setup_meta_modeling(app) + # licenses + setup_licenses(app) + # tagging setup_scicrunch(app) setup_tags(app) diff --git a/services/web/server/src/simcore_service_webserver/catalog/licenses/_exceptions_handlers.py b/services/web/server/src/simcore_service_webserver/catalog/licenses/_exceptions_handlers.py deleted file mode 100644 index 0abb7671b16..00000000000 --- a/services/web/server/src/simcore_service_webserver/catalog/licenses/_exceptions_handlers.py +++ /dev/null @@ -1,26 +0,0 @@ -import logging - -from servicelib.aiohttp import status - -from ...exception_handling import ( - ExceptionToHttpErrorMap, - HttpErrorInfo, - exception_handling_decorator, - to_exceptions_handlers_map, -) -from .errors import LicensedItemNotFoundError - -_logger = logging.getLogger(__name__) - - -_TO_HTTP_ERROR_MAP: ExceptionToHttpErrorMap = { - LicensedItemNotFoundError: HttpErrorInfo( - status.HTTP_404_NOT_FOUND, - "Market item {licensed_item_id} not found.", - ) -} - - -handle_plugin_requests_exceptions = exception_handling_decorator( - to_exceptions_handlers_map(_TO_HTTP_ERROR_MAP) -) diff --git a/services/web/server/src/simcore_service_webserver/catalog/licenses/_licensed_items_api.py b/services/web/server/src/simcore_service_webserver/catalog/licenses/_licensed_items_api.py deleted file mode 100644 index bb024b0423b..00000000000 --- a/services/web/server/src/simcore_service_webserver/catalog/licenses/_licensed_items_api.py +++ /dev/null @@ -1,77 +0,0 @@ -# pylint: disable=unused-argument - -import logging - -from aiohttp import web -from models_library.api_schemas_webserver.licensed_items import ( - LicensedItemGet, - LicensedItemGetPage, -) -from models_library.licensed_items import LicensedItemID -from models_library.products import ProductName -from models_library.rest_ordering import OrderBy -from models_library.users import UserID -from pydantic import NonNegativeInt - -from . import _licensed_items_db -from ._models import LicensedItemsBodyParams - -_logger = logging.getLogger(__name__) - - -async def get_licensed_item( - app: web.Application, - *, - licensed_item_id: LicensedItemID, - product_name: ProductName, -) -> LicensedItemGet: - - licensed_item_db = await _licensed_items_db.get( - app, licensed_item_id=licensed_item_id, product_name=product_name - ) - return LicensedItemGet( - licensed_item_id=licensed_item_db.licensed_item_id, - name=licensed_item_db.name, - licensed_resource_type=licensed_item_db.licensed_resource_type, - pricing_plan_id=licensed_item_db.pricing_plan_id, - created_at=licensed_item_db.created, - modified_at=licensed_item_db.modified, - ) - - -async def list_licensed_items( - app: web.Application, - *, - product_name: ProductName, - offset: NonNegativeInt, - limit: int, - order_by: OrderBy, -) -> LicensedItemGetPage: - total_count, licensed_item_db_list = await _licensed_items_db.list_( - app, product_name=product_name, offset=offset, limit=limit, order_by=order_by - ) - return LicensedItemGetPage( - items=[ - LicensedItemGet( - licensed_item_id=licensed_item_db.licensed_item_id, - name=licensed_item_db.name, - licensed_resource_type=licensed_item_db.licensed_resource_type, - pricing_plan_id=licensed_item_db.pricing_plan_id, - created_at=licensed_item_db.created, - modified_at=licensed_item_db.modified, - ) - for licensed_item_db in licensed_item_db_list - ], - total=total_count, - ) - - -async def purchase_licensed_item( - app: web.Application, - *, - product_name: ProductName, - user_id: UserID, - licensed_item_id: LicensedItemID, - body_params: LicensedItemsBodyParams, -) -> None: - raise NotImplementedError diff --git a/services/web/server/src/simcore_service_webserver/catalog/licenses/errors.py b/services/web/server/src/simcore_service_webserver/catalog/licenses/errors.py deleted file mode 100644 index 0c8bae69b03..00000000000 --- a/services/web/server/src/simcore_service_webserver/catalog/licenses/errors.py +++ /dev/null @@ -1,9 +0,0 @@ -from ...errors import WebServerBaseError - - -class LicensesValueError(WebServerBaseError, ValueError): - ... - - -class LicensedItemNotFoundError(LicensesValueError): - msg_template = "License good {licensed_item_id} not found" diff --git a/services/web/server/src/simcore_service_webserver/catalog/plugin.py b/services/web/server/src/simcore_service_webserver/catalog/plugin.py index 74c36bcbcb4..2af8da917f0 100644 --- a/services/web/server/src/simcore_service_webserver/catalog/plugin.py +++ b/services/web/server/src/simcore_service_webserver/catalog/plugin.py @@ -9,7 +9,6 @@ from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup from . import _handlers, _tags_handlers -from .licenses.plugin import setup_licenses _logger = logging.getLogger(__name__) @@ -28,8 +27,6 @@ def setup_catalog(app: web.Application): for route_def in _handlers.routes ) - setup_licenses(app) - app.add_routes(_handlers.routes) app.add_routes(_tags_handlers.routes) diff --git a/services/web/server/src/simcore_service_webserver/catalog/licenses/__init__.py b/services/web/server/src/simcore_service_webserver/licenses/__init__.py similarity index 100% rename from services/web/server/src/simcore_service_webserver/catalog/licenses/__init__.py rename to services/web/server/src/simcore_service_webserver/licenses/__init__.py 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 new file mode 100644 index 00000000000..d12b95fafa0 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/licenses/_exceptions_handlers.py @@ -0,0 +1,40 @@ +import logging + +from servicelib.aiohttp import status +from simcore_service_webserver.wallets.errors import WalletAccessForbiddenError + +from ..exception_handling import ( + ExceptionToHttpErrorMap, + HttpErrorInfo, + exception_handling_decorator, + to_exceptions_handlers_map, +) +from ..wallets.errors import WalletNotEnoughCreditsError +from .errors import LicensedItemNotFoundError, LicensedItemPricingPlanMatchError + +_logger = logging.getLogger(__name__) + + +_TO_HTTP_ERROR_MAP: ExceptionToHttpErrorMap = { + LicensedItemNotFoundError: HttpErrorInfo( + status.HTTP_404_NOT_FOUND, + "Market item {licensed_item_id} not found.", + ), + WalletAccessForbiddenError: HttpErrorInfo( + status.HTTP_403_FORBIDDEN, + "Wallet {wallet_id} forbidden.", + ), + WalletNotEnoughCreditsError: HttpErrorInfo( + status.HTTP_402_PAYMENT_REQUIRED, + "Not enough credits in the wallet.", + ), + LicensedItemPricingPlanMatchError: HttpErrorInfo( + status.HTTP_400_BAD_REQUEST, + "The provided pricing plan does not match the one associated with the licensed item.", + ), +} + + +handle_plugin_requests_exceptions = exception_handling_decorator( + to_exceptions_handlers_map(_TO_HTTP_ERROR_MAP) +) diff --git a/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_api.py b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_api.py new file mode 100644 index 00000000000..6feacf24b1d --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_api.py @@ -0,0 +1,138 @@ +# pylint: disable=unused-argument + +import logging +from datetime import UTC, datetime, timedelta + +from aiohttp import web +from models_library.api_schemas_webserver.licensed_items import ( + LicensedItemGet, + LicensedItemGetPage, +) +from models_library.licensed_items import LicensedItemID +from models_library.products import ProductName +from models_library.resource_tracker_licensed_items_purchases import ( + LicensedItemsPurchasesCreate, +) +from models_library.rest_ordering import OrderBy +from models_library.users import UserID +from pydantic import NonNegativeInt +from servicelib.rabbitmq.rpc_interfaces.resource_usage_tracker import ( + licensed_items_purchases, +) + +from ..rabbitmq import get_rabbitmq_rpc_client +from ..resource_usage.api import get_pricing_plan_unit +from ..users.api import get_user +from ..wallets.api import get_wallet_with_available_credits_by_user_and_wallet +from ..wallets.errors import WalletNotEnoughCreditsError +from . import _licensed_items_db +from ._models import LicensedItemsBodyParams +from .errors import LicensedItemPricingPlanMatchError + +_logger = logging.getLogger(__name__) + + +async def get_licensed_item( + app: web.Application, + *, + licensed_item_id: LicensedItemID, + product_name: ProductName, +) -> LicensedItemGet: + + licensed_item_db = await _licensed_items_db.get( + app, licensed_item_id=licensed_item_id, product_name=product_name + ) + return LicensedItemGet( + licensed_item_id=licensed_item_db.licensed_item_id, + name=licensed_item_db.name, + licensed_resource_type=licensed_item_db.licensed_resource_type, + pricing_plan_id=licensed_item_db.pricing_plan_id, + created_at=licensed_item_db.created, + modified_at=licensed_item_db.modified, + ) + + +async def list_licensed_items( + app: web.Application, + *, + product_name: ProductName, + offset: NonNegativeInt, + limit: int, + order_by: OrderBy, +) -> LicensedItemGetPage: + total_count, licensed_item_db_list = await _licensed_items_db.list_( + app, product_name=product_name, offset=offset, limit=limit, order_by=order_by + ) + return LicensedItemGetPage( + items=[ + LicensedItemGet( + licensed_item_id=licensed_item_db.licensed_item_id, + name=licensed_item_db.name, + licensed_resource_type=licensed_item_db.licensed_resource_type, + pricing_plan_id=licensed_item_db.pricing_plan_id, + created_at=licensed_item_db.created, + modified_at=licensed_item_db.modified, + ) + for licensed_item_db in licensed_item_db_list + ], + total=total_count, + ) + + +async def purchase_licensed_item( + app: web.Application, + *, + product_name: ProductName, + user_id: UserID, + licensed_item_id: LicensedItemID, + body_params: LicensedItemsBodyParams, +) -> None: + # Check user wallet permissions + wallet = await get_wallet_with_available_credits_by_user_and_wallet( + app, user_id=user_id, wallet_id=body_params.wallet_id, product_name=product_name + ) + + licensed_item = await get_licensed_item( + app, licensed_item_id=licensed_item_id, product_name=product_name + ) + + if licensed_item.pricing_plan_id != body_params.pricing_plan_id: + raise LicensedItemPricingPlanMatchError( + pricing_plan_id=body_params.pricing_plan_id, + licensed_item_id=licensed_item_id, + ) + + pricing_unit = await get_pricing_plan_unit( + app, + product_name=product_name, + pricing_plan_id=body_params.pricing_plan_id, + pricing_unit_id=body_params.pricing_unit_id, + ) + + # Check whether wallet has enough credits + if wallet.available_credits - pricing_unit.current_cost_per_unit < 0: + raise WalletNotEnoughCreditsError( + reason=f"Wallet '{wallet.name}' has {wallet.available_credits} credits." + ) + + user = await get_user(app, user_id=user_id) + + _data = LicensedItemsPurchasesCreate( + product_name=product_name, + licensed_item_id=licensed_item_id, + wallet_id=wallet.wallet_id, + wallet_name=wallet.name, + pricing_plan_id=body_params.pricing_plan_id, + pricing_unit_id=body_params.pricing_unit_id, + pricing_unit_cost_id=pricing_unit.current_cost_per_unit_id, + pricing_unit_cost=pricing_unit.current_cost_per_unit, + start_at=datetime.now(tz=UTC), + expire_at=datetime.now(tz=UTC) + + timedelta(days=30), # <-- Temporary agreement with OM for proof of concept + num_of_seats=body_params.num_of_seats, + purchased_by_user=user_id, + user_email=user["email"], + purchased_at=datetime.now(tz=UTC), + ) + rpc_client = get_rabbitmq_rpc_client(app) + await licensed_items_purchases.create_licensed_item_purchase(rpc_client, data=_data) diff --git a/services/web/server/src/simcore_service_webserver/catalog/licenses/_licensed_items_db.py b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_db.py similarity index 99% rename from services/web/server/src/simcore_service_webserver/catalog/licenses/_licensed_items_db.py rename to services/web/server/src/simcore_service_webserver/licenses/_licensed_items_db.py index fc14221ff91..e468c10f55d 100644 --- a/services/web/server/src/simcore_service_webserver/catalog/licenses/_licensed_items_db.py +++ b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_db.py @@ -27,7 +27,7 @@ from sqlalchemy.ext.asyncio import AsyncConnection from sqlalchemy.sql import select -from ...db.plugin import get_asyncpg_engine +from ..db.plugin import get_asyncpg_engine from .errors import LicensedItemNotFoundError _logger = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/catalog/licenses/_licensed_items_handlers.py b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_handlers.py similarity index 92% rename from services/web/server/src/simcore_service_webserver/catalog/licenses/_licensed_items_handlers.py rename to services/web/server/src/simcore_service_webserver/licenses/_licensed_items_handlers.py index 6ed227500e5..355d9658ebb 100644 --- a/services/web/server/src/simcore_service_webserver/catalog/licenses/_licensed_items_handlers.py +++ b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_handlers.py @@ -17,10 +17,10 @@ from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON from servicelib.rest_constants import RESPONSE_MODEL_POLICY -from ..._meta import API_VTAG as VTAG -from ...login.decorators import login_required -from ...security.decorators import permission_required -from ...utils_aiohttp import envelope_json_response +from .._meta import API_VTAG as VTAG +from ..login.decorators import login_required +from ..security.decorators import permission_required +from ..utils_aiohttp import envelope_json_response from . import _licensed_items_api from ._exceptions_handlers import handle_plugin_requests_exceptions from ._models import ( @@ -40,7 +40,7 @@ @login_required @permission_required("catalog/licensed-items.*") @handle_plugin_requests_exceptions -async def list_workspaces(request: web.Request): +async def list_licensed_items(request: web.Request): req_ctx = LicensedItemsRequestContext.model_validate(request) query_params: LicensedItemsListQueryParams = parse_request_query_parameters_as( LicensedItemsListQueryParams, request @@ -77,7 +77,7 @@ async def list_workspaces(request: web.Request): @login_required @permission_required("catalog/licensed-items.*") @handle_plugin_requests_exceptions -async def get_workspace(request: web.Request): +async def get_licensed_item(request: web.Request): req_ctx = LicensedItemsRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(LicensedItemsPathParams, request) 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 new file mode 100644 index 00000000000..4aae82ae768 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_purchases_api.py @@ -0,0 +1,111 @@ +import logging + +from aiohttp import web +from models_library.api_schemas_resource_usage_tracker import ( + licensed_items_purchases as rut_licensed_items_purchases, +) +from models_library.api_schemas_webserver import ( + licensed_items_purchases as webserver_licensed_items_purchases, +) +from models_library.products import ProductName +from models_library.resource_tracker_licensed_items_purchases import ( + LicensedItemPurchaseID, +) +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 import ( + licensed_items_purchases, +) + +from ..rabbitmq import get_rabbitmq_rpc_client +from ..wallets.api import get_wallet_by_user + +_logger = logging.getLogger(__name__) + + +async def list_licensed_items_purchases( + app: web.Application, + product_name: ProductName, + user_id: UserID, + wallet_id: WalletID, + offset: int, + limit: int, + order_by: OrderBy, +) -> webserver_licensed_items_purchases.LicensedItemPurchaseGetPage: + + # 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 + ) + + rpc_client = get_rabbitmq_rpc_client(app) + result: rut_licensed_items_purchases.LicensedItemsPurchasesPage = ( + await licensed_items_purchases.get_licensed_items_purchases_page( + rpc_client, + product_name=product_name, + wallet_id=wallet_id, + offset=offset, + limit=limit, + order_by=order_by, + ) + ) + return webserver_licensed_items_purchases.LicensedItemPurchaseGetPage( + total=result.total, + items=[ + webserver_licensed_items_purchases.LicensedItemPurchaseGet( + licensed_item_purchase_id=item.licensed_item_purchase_id, + product_name=item.product_name, + licensed_item_id=item.licensed_item_id, + wallet_id=item.wallet_id, + pricing_unit_cost_id=item.pricing_unit_cost_id, + pricing_unit_cost=item.pricing_unit_cost, + start_at=item.start_at, + expire_at=item.expire_at, + num_of_seats=item.num_of_seats, + purchased_by_user=item.purchased_by_user, + purchased_at=item.purchased_at, + modified_at=item.modified, + ) + for item in result.items + ], + ) + + +async def get_licensed_item_purchase( + app: web.Application, + product_name: ProductName, + user_id: UserID, + licensed_item_purchase_id: LicensedItemPurchaseID, +) -> webserver_licensed_items_purchases.LicensedItemPurchaseGet: + rpc_client = get_rabbitmq_rpc_client(app) + licensed_item_get: rut_licensed_items_purchases.LicensedItemPurchaseGet = ( + await licensed_items_purchases.get_licensed_item_purchase( + rpc_client, + product_name=product_name, + licensed_item_purchase_id=licensed_item_purchase_id, + ) + ) + + # Check whether user has access to the wallet + await get_wallet_by_user( + app, + user_id=user_id, + wallet_id=licensed_item_get.wallet_id, + product_name=product_name, + ) + + return webserver_licensed_items_purchases.LicensedItemPurchaseGet( + licensed_item_purchase_id=licensed_item_get.licensed_item_purchase_id, + product_name=licensed_item_get.product_name, + licensed_item_id=licensed_item_get.licensed_item_id, + wallet_id=licensed_item_get.wallet_id, + pricing_unit_cost_id=licensed_item_get.pricing_unit_cost_id, + pricing_unit_cost=licensed_item_get.pricing_unit_cost, + start_at=licensed_item_get.start_at, + expire_at=licensed_item_get.expire_at, + num_of_seats=licensed_item_get.num_of_seats, + purchased_by_user=licensed_item_get.purchased_by_user, + purchased_at=licensed_item_get.purchased_at, + modified_at=licensed_item_get.modified, + ) diff --git a/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_purchases_handlers.py b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_purchases_handlers.py new file mode 100644 index 00000000000..95f48ebbd0e --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_purchases_handlers.py @@ -0,0 +1,101 @@ +import logging + +from aiohttp import web +from models_library.api_schemas_webserver.licensed_items_purchases import ( + LicensedItemPurchaseGet, + LicensedItemPurchaseGetPage, +) +from models_library.rest_ordering import OrderBy +from models_library.rest_pagination import Page +from models_library.rest_pagination_utils import paginate_data +from servicelib.aiohttp.requests_validation import ( + parse_request_path_parameters_as, + parse_request_query_parameters_as, +) +from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON +from servicelib.rest_constants import RESPONSE_MODEL_POLICY + +from .._meta import API_VTAG as VTAG +from ..login.decorators import login_required +from ..security.decorators import permission_required +from ..utils_aiohttp import envelope_json_response +from ..wallets._handlers import WalletsPathParams +from . import _licensed_items_purchases_api +from ._exceptions_handlers import handle_plugin_requests_exceptions +from ._models import ( + LicensedItemsPurchasesListQueryParams, + LicensedItemsPurchasesPathParams, + LicensedItemsRequestContext, +) + +_logger = logging.getLogger(__name__) + +routes = web.RouteTableDef() + + +@routes.get( + f"/{VTAG}/licensed-items-purchases/{{licensed_item_purchase_id}}", + name="get_licensed_item_purchase", +) +@login_required +@permission_required("catalog/licensed-items.*") +@handle_plugin_requests_exceptions +async def get_licensed_item_purchase(request: web.Request): + req_ctx = LicensedItemsRequestContext.model_validate(request) + path_params = parse_request_path_parameters_as( + LicensedItemsPurchasesPathParams, request + ) + + licensed_item_purchase_get: LicensedItemPurchaseGet = ( + await _licensed_items_purchases_api.get_licensed_item_purchase( + app=request.app, + product_name=req_ctx.product_name, + user_id=req_ctx.user_id, + licensed_item_purchase_id=path_params.licensed_item_purchase_id, + ) + ) + + return envelope_json_response(licensed_item_purchase_get) + + +@routes.get( + f"/{VTAG}/wallets/{{wallet_id}}/licensed-items-purchases", + name="list_wallet_licensed_items_purchases", +) +@login_required +@permission_required("catalog/licensed-items.*") +@handle_plugin_requests_exceptions +async def list_wallet_licensed_items_purchases(request: web.Request): + req_ctx = LicensedItemsRequestContext.model_validate(request) + path_params = parse_request_path_parameters_as(WalletsPathParams, request) + query_params: LicensedItemsPurchasesListQueryParams = ( + parse_request_query_parameters_as( + LicensedItemsPurchasesListQueryParams, request + ) + ) + + licensed_item_purchase_get_page: LicensedItemPurchaseGetPage = ( + await _licensed_items_purchases_api.list_licensed_items_purchases( + app=request.app, + product_name=req_ctx.product_name, + user_id=req_ctx.user_id, + wallet_id=path_params.wallet_id, + offset=query_params.offset, + limit=query_params.limit, + order_by=OrderBy.model_construct(**query_params.order_by.model_dump()), + ) + ) + + page = Page[LicensedItemPurchaseGet].model_validate( + paginate_data( + chunk=licensed_item_purchase_get_page.items, + request_url=request.url, + total=licensed_item_purchase_get_page.total, + limit=query_params.limit, + offset=query_params.offset, + ) + ) + return web.Response( + text=page.model_dump_json(**RESPONSE_MODEL_POLICY), + content_type=MIMETYPE_APPLICATION_JSON, + ) diff --git a/services/web/server/src/simcore_service_webserver/catalog/licenses/_models.py b/services/web/server/src/simcore_service_webserver/licenses/_models.py similarity index 61% rename from services/web/server/src/simcore_service_webserver/catalog/licenses/_models.py rename to services/web/server/src/simcore_service_webserver/licenses/_models.py index 40d287faa92..d5c2ac0947e 100644 --- a/services/web/server/src/simcore_service_webserver/catalog/licenses/_models.py +++ b/services/web/server/src/simcore_service_webserver/licenses/_models.py @@ -2,6 +2,10 @@ from models_library.basic_types import IDStr from models_library.licensed_items import LicensedItemID +from models_library.resource_tracker import PricingPlanId, PricingUnitId +from models_library.resource_tracker_licensed_items_purchases import ( + LicensedItemPurchaseID, +) from models_library.rest_base import RequestParameters, StrictRequestParameters from models_library.rest_ordering import ( OrderBy, @@ -14,7 +18,7 @@ from pydantic import BaseModel, ConfigDict, Field from servicelib.request_keys import RQT_USERID_KEY -from ..._constants import RQ_PRODUCT_KEY +from .._constants import RQ_PRODUCT_KEY _logger = logging.getLogger(__name__) @@ -49,6 +53,32 @@ class LicensedItemsListQueryParams( class LicensedItemsBodyParams(BaseModel): wallet_id: WalletID - num_of_seeds: int + pricing_plan_id: PricingPlanId + pricing_unit_id: PricingUnitId + num_of_seats: int model_config = ConfigDict(extra="forbid") + + +class LicensedItemsPurchasesPathParams(StrictRequestParameters): + licensed_item_purchase_id: LicensedItemPurchaseID + + +_LicensedItemsPurchasesListOrderQueryParams: type[ + RequestParameters +] = create_ordering_query_model_class( + ordering_fields={ + "purchased_at", + "modified_at", + "name", + }, + default=OrderBy(field=IDStr("purchased_at"), direction=OrderDirection.DESC), + ordering_fields_api_to_column_map={"modified_at": "modified"}, +) + + +class LicensedItemsPurchasesListQueryParams( + PageQueryParameters, + _LicensedItemsPurchasesListOrderQueryParams, # type: ignore[misc, valid-type] +): + ... diff --git a/services/web/server/src/simcore_service_webserver/catalog/licenses/api.py b/services/web/server/src/simcore_service_webserver/licenses/api.py similarity index 100% rename from services/web/server/src/simcore_service_webserver/catalog/licenses/api.py rename to services/web/server/src/simcore_service_webserver/licenses/api.py diff --git a/services/web/server/src/simcore_service_webserver/licenses/errors.py b/services/web/server/src/simcore_service_webserver/licenses/errors.py new file mode 100644 index 00000000000..18c57966123 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/licenses/errors.py @@ -0,0 +1,13 @@ +from ..errors import WebServerBaseError + + +class LicensesValueError(WebServerBaseError, ValueError): + ... + + +class LicensedItemNotFoundError(LicensesValueError): + msg_template = "License good {licensed_item_id} not found" + + +class LicensedItemPricingPlanMatchError(LicensesValueError): + msg_template = "The provided pricing plan {pricing_plan_id} does not match the one associated with the licensed item {licensed_item_id}." diff --git a/services/web/server/src/simcore_service_webserver/catalog/licenses/plugin.py b/services/web/server/src/simcore_service_webserver/licenses/plugin.py similarity index 80% rename from services/web/server/src/simcore_service_webserver/catalog/licenses/plugin.py rename to services/web/server/src/simcore_service_webserver/licenses/plugin.py index ef124c69fad..6c2ea7ce0d9 100644 --- a/services/web/server/src/simcore_service_webserver/catalog/licenses/plugin.py +++ b/services/web/server/src/simcore_service_webserver/licenses/plugin.py @@ -7,7 +7,7 @@ from servicelib.aiohttp.application_keys import APP_SETTINGS_KEY from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup -from . import _licensed_items_handlers +from . import _licensed_items_handlers, _licensed_items_purchases_handlers _logger = logging.getLogger(__name__) @@ -24,3 +24,4 @@ def setup_licenses(app: web.Application): # routes app.router.add_routes(_licensed_items_handlers.routes) + app.router.add_routes(_licensed_items_purchases_handlers.routes) diff --git a/services/web/server/src/simcore_service_webserver/wallets/_handlers.py b/services/web/server/src/simcore_service_webserver/wallets/_handlers.py index 093edf71c21..9afcdb7c437 100644 --- a/services/web/server/src/simcore_service_webserver/wallets/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/wallets/_handlers.py @@ -47,7 +47,11 @@ MSG_BILLING_DETAILS_NOT_DEFINED_ERROR, MSG_PRICE_NOT_DEFINED_ERROR, ) -from .errors import WalletAccessForbiddenError, WalletNotFoundError +from .errors import ( + WalletAccessForbiddenError, + WalletNotEnoughCreditsError, + WalletNotFoundError, +) _logger = logging.getLogger(__name__) @@ -87,6 +91,9 @@ async def wrapper(request: web.Request) -> web.StreamResponse: except ProductPriceNotDefinedError as exc: raise web.HTTPConflict(reason=MSG_PRICE_NOT_DEFINED_ERROR) from exc + except WalletNotEnoughCreditsError as exc: + raise web.HTTPPaymentRequired(reason=f"{exc}") from exc + except BillingDetailsNotFoundError as exc: error_code = create_error_code(exc) diff --git a/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_db.py b/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_db.py index 5455c280cd7..910e1bdf3f4 100644 --- a/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_db.py +++ b/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_db.py @@ -15,9 +15,9 @@ from models_library.rest_ordering import OrderBy from pytest_simcore.helpers.webserver_login import UserInfoDict from servicelib.aiohttp import status -from simcore_service_webserver.catalog.licenses import _licensed_items_db -from simcore_service_webserver.catalog.licenses.errors import LicensedItemNotFoundError from simcore_service_webserver.db.models import UserRole +from simcore_service_webserver.licenses import _licensed_items_db +from simcore_service_webserver.licenses.errors import LicensedItemNotFoundError from simcore_service_webserver.projects.models import ProjectDict diff --git a/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_handlers.py b/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_handlers.py index eb63d9bb75a..b1fee67dafa 100644 --- a/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_handlers.py +++ b/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_handlers.py @@ -7,18 +7,23 @@ import pytest from aiohttp.test_utils import TestClient +from models_library.api_schemas_resource_usage_tracker.pricing_plans import ( + PricingUnitGet, +) from models_library.api_schemas_webserver.licensed_items import LicensedItemGet +from models_library.api_schemas_webserver.wallets import WalletGetWithAvailableCredits from models_library.licensed_items import LicensedResourceType +from pytest_mock.plugin import MockerFixture from pytest_simcore.helpers.assert_checks import assert_status from pytest_simcore.helpers.webserver_login import UserInfoDict from servicelib.aiohttp import status -from simcore_service_webserver.catalog.licenses import _licensed_items_db from simcore_service_webserver.db.models import UserRole +from simcore_service_webserver.licenses import _licensed_items_db from simcore_service_webserver.projects.models import ProjectDict @pytest.mark.parametrize("user_role,expected", [(UserRole.USER, status.HTTP_200_OK)]) -async def test_licensed_items_db_crud( +async def test_licensed_items_listing( client: TestClient, logged_user: UserInfoDict, user_project: ProjectDict, @@ -58,9 +63,77 @@ async def test_licensed_items_db_crud( data, _ = await assert_status(resp, status.HTTP_200_OK) assert LicensedItemGet(**data) + +@pytest.fixture +def mock_licensed_items_purchase_functions(mocker: MockerFixture) -> tuple: + mock_wallet_credits = mocker.patch( + "simcore_service_webserver.licenses._licensed_items_api.get_wallet_with_available_credits_by_user_and_wallet", + spec=True, + return_value=WalletGetWithAvailableCredits.model_validate( + WalletGetWithAvailableCredits.model_config["json_schema_extra"]["examples"][ + 0 + ] + ), + ) + mock_get_pricing_unit = mocker.patch( + "simcore_service_webserver.licenses._licensed_items_api.get_pricing_plan_unit", + spec=True, + return_value=PricingUnitGet.model_validate( + PricingUnitGet.model_config["json_schema_extra"]["examples"][0] + ), + ) + mock_create_licensed_item_purchase = mocker.patch( + "simcore_service_webserver.licenses._licensed_items_api.licensed_items_purchases.create_licensed_item_purchase", + spec=True, + ) + + return ( + mock_wallet_credits, + mock_get_pricing_unit, + mock_create_licensed_item_purchase, + ) + + +@pytest.mark.parametrize("user_role,expected", [(UserRole.USER, status.HTTP_200_OK)]) +async def test_licensed_items_purchase( + client: TestClient, + logged_user: UserInfoDict, + user_project: ProjectDict, + osparc_product_name: str, + expected: HTTPStatus, + pricing_plan_id: int, + mock_licensed_items_purchase_functions: tuple, +): + assert client.app + + licensed_item_db = await _licensed_items_db.create( + client.app, + product_name=osparc_product_name, + name="Model A", + licensed_resource_type=LicensedResourceType.VIP_MODEL, + pricing_plan_id=pricing_plan_id, + ) + _licensed_item_id = licensed_item_db.licensed_item_id + + # get + url = client.app.router["get_licensed_item"].url_for( + licensed_item_id=f"{_licensed_item_id}" + ) + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert LicensedItemGet(**data) + # purchase url = client.app.router["purchase_licensed_item"].url_for( licensed_item_id=f"{_licensed_item_id}" ) - resp = await client.post(f"{url}", json={"wallet_id": 1, "num_of_seeds": 5}) - # NOTE: Not yet implemented + resp = await client.post( + f"{url}", + json={ + "wallet_id": 1, + "num_of_seats": 5, + "pricing_plan_id": pricing_plan_id, + "pricing_unit_id": 1, + }, + ) + await assert_status(resp, status.HTTP_204_NO_CONTENT) diff --git a/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_purchases_handlers.py b/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_purchases_handlers.py new file mode 100644 index 00000000000..ce0fddeca19 --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_purchases_handlers.py @@ -0,0 +1,101 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments +# pylint: disable=too-many-statements +from decimal import Decimal +from http import HTTPStatus + +import pytest +from aiohttp.test_utils import TestClient +from models_library.api_schemas_resource_usage_tracker import ( + licensed_items_purchases as rut_licensed_items_purchases, +) +from models_library.api_schemas_webserver.licensed_items_purchases import ( + LicensedItemPurchaseGet, +) +from pytest_mock.plugin import MockerFixture +from pytest_simcore.helpers.assert_checks import assert_status +from pytest_simcore.helpers.webserver_login import UserInfoDict +from servicelib.aiohttp import status +from simcore_service_webserver.db.models import UserRole + +_LICENSED_ITEM_PURCHASE_GET = ( + rut_licensed_items_purchases.LicensedItemPurchaseGet.model_validate( + { + "licensed_item_purchase_id": "beb16d18-d57d-44aa-a638-9727fa4a72ef", + "product_name": "osparc", + "licensed_item_id": "303942ef-6d31-4ba8-afbe-dbb1fce2a953", + "wallet_id": 1, + "wallet_name": "My Wallet", + "pricing_unit_cost_id": 1, + "pricing_unit_cost": Decimal(10), + "start_at": "2023-01-11 13:11:47.293595", + "expire_at": "2023-01-11 13:11:47.293595", + "num_of_seats": 1, + "purchased_by_user": 1, + "purchased_at": "2023-01-11 13:11:47.293595", + "modified": "2023-01-11 13:11:47.293595", + } + ) +) + +_LICENSED_ITEM_PURCHASE_PAGE = rut_licensed_items_purchases.LicensedItemsPurchasesPage( + items=[_LICENSED_ITEM_PURCHASE_GET], + total=1, +) + + +@pytest.fixture +def mock_get_licensed_items_purchases_page(mocker: MockerFixture) -> tuple: + return mocker.patch( + "simcore_service_webserver.licenses._licensed_items_purchases_api.licensed_items_purchases.get_licensed_items_purchases_page", + spec=True, + return_value=_LICENSED_ITEM_PURCHASE_PAGE, + ) + + +@pytest.fixture +def mock_get_licensed_item_purchase(mocker: MockerFixture) -> tuple: + return mocker.patch( + "simcore_service_webserver.licenses._licensed_items_purchases_api.licensed_items_purchases.get_licensed_item_purchase", + spec=True, + return_value=_LICENSED_ITEM_PURCHASE_GET, + ) + + +@pytest.fixture +def mock_get_wallet_by_user(mocker: MockerFixture) -> tuple: + return mocker.patch( + "simcore_service_webserver.licenses._licensed_items_purchases_api.get_wallet_by_user", + spec=True, + ) + + +@pytest.mark.parametrize("user_role,expected", [(UserRole.USER, status.HTTP_200_OK)]) +async def test_licensed_items_db_crud( + client: TestClient, + logged_user: UserInfoDict, + expected: HTTPStatus, + mock_get_licensed_items_purchases_page: MockerFixture, + mock_get_licensed_item_purchase: MockerFixture, + mock_get_wallet_by_user: MockerFixture, +): + assert client.app + + # list + url = client.app.router["list_wallet_licensed_items_purchases"].url_for( + wallet_id="1" + ) + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(data) == 1 + assert LicensedItemPurchaseGet(**data[0]) + + # get + url = client.app.router["get_licensed_item_purchase"].url_for( + licensed_item_purchase_id=f"{_LICENSED_ITEM_PURCHASE_PAGE.items[0].licensed_item_purchase_id}" + ) + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert LicensedItemPurchaseGet(**data)