From 829eae9e3e4d1b6a49c4e5554a48e7c37dcf4c69 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Mon, 9 Dec 2024 16:42:39 +0100 Subject: [PATCH 01/26] db layer licensed items purchase --- .env-devel | 2 +- ...source_tracker_licensed_items_purchases.py | 4 + ...source_tracker_licensed_items_purchases.py | 18 ++- .../exceptions/errors.py | 7 + .../models/licensed_items_purchases.py | 45 ++++++ .../modules/db/licensed_items_purchases.py | 140 ++++++++++++++++++ .../tests/unit/isolated/test_tracing.py | 6 +- 7 files changed, 219 insertions(+), 3 deletions(-) create mode 100644 packages/models-library/src/models_library/resource_tracker_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/modules/db/licensed_items_purchases.py diff --git a/.env-devel b/.env-devel index cc6609460da..1683e998509 100644 --- a/.env-devel +++ b/.env-devel @@ -389,6 +389,6 @@ WEBSERVER_SOCKETIO=1 WEBSERVER_STATICWEB={} WEBSERVER_STUDIES_DISPATCHER={} WEBSERVER_TAGS=1 -WEBSERVER_TRACING=null +WEBSERVER_TRACING={} WEBSERVER_USERS={} WEBSERVER_VERSION_CONTROL=1 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..e5394b019d4 --- /dev/null +++ b/packages/models-library/src/models_library/resource_tracker_licensed_items_purchases.py @@ -0,0 +1,4 @@ +from typing import TypeAlias +from uuid import UUID + +LicensedItemPurchaseID: TypeAlias = UUID 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..43cf052eb7b 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( @@ -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), 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/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..f42690ef347 --- /dev/null +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/models/licensed_items_purchases.py @@ -0,0 +1,45 @@ +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 | None + wallet_name: str | None + pricing_unit_cost_id: PricingUnitCostId + pricing_unit_cost: Decimal + start_at: datetime + expire_at: datetime | None + 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 | None + wallet_name: str | None + pricing_unit_cost_id: PricingUnitCostId + pricing_unit_cost: Decimal + start_at: datetime + expire_at: datetime | None + purchased_by_user: UserID + purchased_at: datetime + modified: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/licensed_items_purchases.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/licensed_items_purchases.py new file mode 100644 index 00000000000..b84f7311fe0 --- /dev/null +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/licensed_items_purchases.py @@ -0,0 +1,140 @@ +from typing import cast + +import sqlalchemy as sa +from models_library.products import ProductName +from models_library.rest_ordering import OrderBy, OrderDirection +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, + LicensedItemPurchaseID, + 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.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.stream( + 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, + purchased_by_user=data.purchased_by_user, + purchased_at=data.purchased_at, + modified=sa.func.now(), + ) + .returning(*_SELECTION_ARGS) + ) + row = await result.first() + return LicensedItemsPurchasesDB.model_validate(row) + + +async def list_( + engine: AsyncEngine, + connection: AsyncConnection | None = None, + *, + product_name: ProductName, + 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) + ) + + # 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/web/server/tests/unit/isolated/test_tracing.py b/services/web/server/tests/unit/isolated/test_tracing.py index ddec0d10422..bac02e74a8c 100644 --- a/services/web/server/tests/unit/isolated/test_tracing.py +++ b/services/web/server/tests/unit/isolated/test_tracing.py @@ -18,7 +18,7 @@ def mock_webserver_service_environment( monkeypatch: pytest.MonkeyPatch, mock_webserver_service_environment: EnvVarsDict ) -> EnvVarsDict: - return mock_webserver_service_environment | setenvs_from_dict( + envs = mock_webserver_service_environment | setenvs_from_dict( monkeypatch, { "TRACING_OPENTELEMETRY_COLLECTOR_ENDPOINT": "http://opentelemetry-collector", @@ -26,6 +26,10 @@ def mock_webserver_service_environment( }, ) + envs.pop("WEBSERVER_TRACING") + + return envs + def test_middleware_restrictions_opentelemetry_is_second_middleware( mock_webserver_service_environment: EnvVarsDict, From a975a63369800b40d5303878f91136f8d4c1e6b6 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Tue, 10 Dec 2024 08:32:58 +0100 Subject: [PATCH 02/26] rut part --- .env-devel | 2 +- .../licensed_items_purchases.py | 55 +++++++ ...source_tracker_licensed_items_purchases.py | 26 ++++ ...7_add_cols_to_licensed_items_purchases_.py | 45 ++++++ ...source_tracker_licensed_items_purchases.py | 5 + .../api/rpc/_licensed_items.py | 60 ++++++++ .../api/rpc/routes.py | 3 +- .../models/licensed_items_purchases.py | 15 +- .../services/licensed_items_purchases.py | 134 ++++++++++++++++++ ...ases.py => licensed_items_purchases_db.py} | 12 +- .../catalog/licenses/_models.py | 2 +- 11 files changed, 348 insertions(+), 11 deletions(-) create mode 100644 packages/models-library/src/models_library/api_schemas_resource_usage_tracker/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 services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_licensed_items.py create mode 100644 services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/licensed_items_purchases.py rename services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/{licensed_items_purchases.py => licensed_items_purchases_db.py} (92%) diff --git a/.env-devel b/.env-devel index 1683e998509..cc6609460da 100644 --- a/.env-devel +++ b/.env-devel @@ -389,6 +389,6 @@ WEBSERVER_SOCKETIO=1 WEBSERVER_STATICWEB={} WEBSERVER_STUDIES_DISPATCHER={} WEBSERVER_TAGS=1 -WEBSERVER_TRACING={} +WEBSERVER_TRACING=null WEBSERVER_USERS={} WEBSERVER_VERSION_CONTROL=1 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..69c3c64c440 --- /dev/null +++ b/packages/models-library/src/models_library/api_schemas_resource_usage_tracker/licensed_items_purchases.py @@ -0,0 +1,55 @@ +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, PositiveInt + + +class LicensedItemPurchaseGet(BaseModel): + licensed_item_purchase_id: LicensedItemPurchaseID + product_name: ProductName + licensed_item_id: LicensedItemID + wallet_id: WalletID | None + wallet_name: str | None + 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": 1, + "product_name": "osparc", + "licensed_item_id": "Special Pricing Plan for Sleeper", + "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", + } # type: ignore[index,union-attr] + ] + } + ) + + +class LicensedItemsPurchasesPage(NamedTuple): + items: list[LicensedItemPurchaseGet] + total: PositiveInt 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 index e5394b019d4..d1ab2d88dc8 100644 --- 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 @@ -1,4 +1,30 @@ +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 PricingUnitCostId +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_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/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..39f2ba32ea3 --- /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: 38c9ac332c58 +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 = "38c9ac332c58" +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/models/resource_tracker_licensed_items_purchases.py b/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_licensed_items_purchases.py index 43cf052eb7b..c5c3e2b57ec 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 @@ -62,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/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_licensed_items.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_licensed_items.py new file mode 100644 index 00000000000..c835848b219 --- /dev/null +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_licensed_items.py @@ -0,0 +1,60 @@ +from fastapi import FastAPI +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_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="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( + 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..349f86f4a01 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, _resource_tracker _logger = logging.getLogger(__name__) ROUTERS: list[RPCRouter] = [ _resource_tracker.router, + _licensed_items.router, ] 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 index f42690ef347..4458bd2c258 100644 --- 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 @@ -16,12 +16,13 @@ class LicensedItemsPurchasesDB(BaseModel): licensed_item_purchase_id: LicensedItemPurchaseID product_name: ProductName licensed_item_id: LicensedItemID - wallet_id: WalletID | None - wallet_name: str | None + wallet_id: WalletID + wallet_name: str pricing_unit_cost_id: PricingUnitCostId pricing_unit_cost: Decimal start_at: datetime - expire_at: datetime | None + expire_at: datetime + num_of_seats: int purchased_by_user: UserID purchased_at: datetime modified: datetime @@ -32,14 +33,14 @@ class LicensedItemsPurchasesDB(BaseModel): class CreateLicensedItemsPurchasesDB(BaseModel): product_name: ProductName licensed_item_id: LicensedItemID - wallet_id: WalletID | None - wallet_name: str | None + wallet_id: WalletID + wallet_name: str pricing_unit_cost_id: PricingUnitCostId pricing_unit_cost: Decimal start_at: datetime - expire_at: datetime | None + expire_at: datetime + num_of_seats: int purchased_by_user: UserID purchased_at: datetime - modified: datetime model_config = ConfigDict(from_attributes=True) 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..3e106559b9e --- /dev/null +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/licensed_items_purchases.py @@ -0,0 +1,134 @@ +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_licensed_items_purchases import ( + LicensedItemPurchaseID, + LicensedItemsPurchasesCreate, +) +from models_library.rest_ordering import OrderBy +from models_library.wallets import WalletID +from sqlalchemy.ext.asyncio import AsyncEngine + +from ..api.rest.dependencies import get_resource_tracker_db_engine +from ..models.licensed_items_purchases import ( + CreateLicensedItemsPurchasesDB, + LicensedItemsPurchasesDB, +) +from .modules.db import licensed_items_purchases_db + + +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( + db_engine: Annotated[AsyncEngine, Depends(get_resource_tracker_db_engine)], + *, + data: LicensedItemsPurchasesCreate, +) -> LicensedItemPurchaseGet: + + _create_db_data = 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, data=_create_db_data) + ) + + 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/licensed_items_purchases.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/licensed_items_purchases_db.py similarity index 92% rename from services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/licensed_items_purchases.py rename to services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/licensed_items_purchases_db.py index b84f7311fe0..e507da9f7e2 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/licensed_items_purchases.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/licensed_items_purchases_db.py @@ -3,6 +3,7 @@ import sqlalchemy as sa from models_library.products import ProductName 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, @@ -30,6 +31,7 @@ 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, @@ -58,6 +60,7 @@ async def create( 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(), @@ -73,6 +76,7 @@ async def list_( connection: AsyncConnection | None = None, *, product_name: ProductName, + filter_wallet_id: WalletID, offset: NonNegativeInt, limit: NonNegativeInt, order_by: OrderBy, @@ -80,7 +84,13 @@ async def list_( base_query = ( sa.select(*_SELECTION_ARGS) .select_from(resource_tracker_licensed_items_purchases) - .where(resource_tracker_licensed_items_purchases.c.product_name == product_name) + .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 diff --git a/services/web/server/src/simcore_service_webserver/catalog/licenses/_models.py b/services/web/server/src/simcore_service_webserver/catalog/licenses/_models.py index 40d287faa92..884cc291431 100644 --- a/services/web/server/src/simcore_service_webserver/catalog/licenses/_models.py +++ b/services/web/server/src/simcore_service_webserver/catalog/licenses/_models.py @@ -49,6 +49,6 @@ class LicensedItemsListQueryParams( class LicensedItemsBodyParams(BaseModel): wallet_id: WalletID - num_of_seeds: int + num_of_seats: int model_config = ConfigDict(extra="forbid") From 78ef2ccdeb371bfd5b392550ee6ec3f78b285107 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Tue, 10 Dec 2024 08:44:32 +0100 Subject: [PATCH 03/26] renaming tests --- ...ker_credit_transactions.py => test_api_credit_transactions.py} | 0 ...esource_tracker_pricing_plans.py => test_api_pricing_plans.py} | 0 ...tracker_pricing_plans_rpc.py => test_api_pricing_plans_rpc.py} | 0 ...r_service_runs__export.py => test_api_service_runs__export.py} | 0 ...usages.py => test_api_service_runs__list_aggregated_usages.py} | 0 ...__list_billable.py => test_api_service_runs__list_billable.py} | 0 ..._with_wallet.py => test_api_service_runs__list_with_wallet.py} | 0 ...ut_wallet.py => test_api_service_runs__list_without_wallet.py} | 0 8 files changed, 0 insertions(+), 0 deletions(-) rename services/resource-usage-tracker/tests/unit/with_dbs/{test_api_resource_tracker_credit_transactions.py => test_api_credit_transactions.py} (100%) 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%) 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_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 From a6f49cd3a52241e4289f7ba92816e683deecd521 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Tue, 10 Dec 2024 08:55:44 +0100 Subject: [PATCH 04/26] rpc interface --- .../licensed_items_purchases.py | 86 +++++++++++++++++++ ..._items.py => _licensed_items_purchases.py} | 0 .../test_api_licensed_items_purchases.py | 80 +++++++++++++++++ 3 files changed, 166 insertions(+) create mode 100644 packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/licensed_items_purchases.py rename services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/{_licensed_items.py => _licensed_items_purchases.py} (100%) create mode 100644 services/resource-usage-tracker/tests/unit/with_dbs/test_api_licensed_items_purchases.py 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..b77c586f3cf --- /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, +) +from models_library.api_schemas_resource_usage_tracker.service_runs import ( + ServiceRunPage, +) +from models_library.products import ProductName +from models_library.rabbitmq_basic_types import RPCMethodName +from models_library.resource_tracker_licensed_items_purchases import ( + 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="purchased_at"), +) -> ServiceRunPage: + 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, ServiceRunPage) # nosec + return result + + +@log_decorator(_logger, level=logging.DEBUG) +async def get_licensed_item_purchase( + rabbitmq_rpc_client: RabbitMQRPCClient, + *, + product_name: ProductName, + wallet_id: WalletID, +) -> 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, + wallet_id=wallet_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.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_licensed_items_purchases.py similarity index 100% rename from services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_licensed_items.py rename to services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_licensed_items_purchases.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..44a6ce56016 --- /dev/null +++ b/services/resource-usage-tracker/tests/unit/with_dbs/test_api_licensed_items_purchases.py @@ -0,0 +1,80 @@ +# pylint:disable=unused-variable +# pylint:disable=unused-argument +# pylint:disable=redefined-outer-name +# pylint:disable=too-many-arguments + +import os +from unittest.mock import AsyncMock, Mock + +import pytest +import sqlalchemy as sa +from moto.server import ThreadedMotoServer +from pydantic import AnyUrl, TypeAdapter +from pytest_mock import MockerFixture +from pytest_simcore.helpers.typing_env import EnvVarsDict +from servicelib.rabbitmq import RabbitMQRPCClient +from servicelib.rabbitmq.rpc_interfaces.resource_usage_tracker import service_runs +from settings_library.s3 import S3Settings +from types_aiobotocore_s3 import S3Client + +pytest_simcore_core_services_selection = [ + "postgres", + "rabbit", +] +pytest_simcore_ops_services_selection = [ + "adminer", +] + +_USER_ID = 1 + + +@pytest.fixture +async def mocked_export(mocker: MockerFixture) -> AsyncMock: + return mocker.patch( + "simcore_service_resource_usage_tracker.services.service_runs.service_runs_db.export_service_runs_table_to_s3", + autospec=True, + ) + + +@pytest.fixture +async def mocked_presigned_link(mocker: MockerFixture) -> AsyncMock: + return mocker.patch( + "simcore_service_resource_usage_tracker.services.service_runs.SimcoreS3API.create_single_presigned_download_link", + return_value=TypeAdapter(AnyUrl).validate_python("https://www.testing.com/"), + ) + + +@pytest.fixture +async def enable_resource_usage_tracker_s3( + mock_env: EnvVarsDict, + mocked_aws_server: ThreadedMotoServer, + mocked_s3_server_envs: EnvVarsDict, + mocked_s3_server_settings: S3Settings, + s3_client: S3Client, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Create bucket + await s3_client.create_bucket(Bucket=mocked_s3_server_settings.S3_BUCKET_NAME) + + # Remove the environment variable + if "RESOURCE_USAGE_TRACKER_S3" in os.environ: + monkeypatch.delenv("RESOURCE_USAGE_TRACKER_S3") + + +@pytest.mark.rpc_test() +async def test_rpc_list_service_runs_which_was_billed( + enable_resource_usage_tracker_s3: None, + mocked_redis_server: None, + postgres_db: sa.engine.Engine, + rpc_client: RabbitMQRPCClient, + mocked_export: Mock, + mocked_presigned_link: Mock, +): + download_url = await service_runs.export_service_runs( + rpc_client, + user_id=_USER_ID, + product_name="osparc", + ) + assert isinstance(download_url, AnyUrl) # nosec + assert mocked_export.called + assert mocked_presigned_link.called From f5de79b0d343a436ff877762972fa3279e0af291 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Tue, 10 Dec 2024 11:08:11 +0100 Subject: [PATCH 05/26] RUT unit tests --- .../licensed_items_purchases.py | 1 + .../licensed_items_purchases.py | 13 +- .../api/rpc/routes.py | 4 +- .../test_api_licensed_items_purchases.py | 121 +++++++++++------- 4 files changed, 83 insertions(+), 56 deletions(-) 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 index 69c3c64c440..6afe928e88d 100644 --- 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 @@ -1,5 +1,6 @@ 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 diff --git a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/licensed_items_purchases.py b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/licensed_items_purchases.py index b77c586f3cf..95c002df7cb 100644 --- a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/licensed_items_purchases.py +++ b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/licensed_items_purchases.py @@ -6,9 +6,8 @@ ) from models_library.api_schemas_resource_usage_tracker.licensed_items_purchases import ( LicensedItemPurchaseGet, -) -from models_library.api_schemas_resource_usage_tracker.service_runs import ( - ServiceRunPage, + LicensedItemPurchaseID, + LicensedItemsPurchasesPage, ) from models_library.products import ProductName from models_library.rabbitmq_basic_types import RPCMethodName @@ -39,7 +38,7 @@ async def get_licensed_items_purchases_page( offset: int = 0, limit: int = 20, order_by: OrderBy = OrderBy(field="purchased_at"), -) -> ServiceRunPage: +) -> LicensedItemsPurchasesPage: result = await rabbitmq_rpc_client.request( RESOURCE_USAGE_TRACKER_RPC_NAMESPACE, _RPC_METHOD_NAME_ADAPTER.validate_python("get_licensed_items_purchases_page"), @@ -50,7 +49,7 @@ async def get_licensed_items_purchases_page( order_by=order_by, timeout_s=_DEFAULT_TIMEOUT_S, ) - assert isinstance(result, ServiceRunPage) # nosec + assert isinstance(result, LicensedItemsPurchasesPage) # nosec return result @@ -59,13 +58,13 @@ async def get_licensed_item_purchase( rabbitmq_rpc_client: RabbitMQRPCClient, *, product_name: ProductName, - wallet_id: WalletID, + 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, - wallet_id=wallet_id, + licensed_item_purchase_id=licensed_item_purchase_id, timeout_s=_DEFAULT_TIMEOUT_S, ) assert isinstance(result, LicensedItemPurchaseGet) # nosec 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 349f86f4a01..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,14 +8,14 @@ from servicelib.rabbitmq import RPCRouter from ...services.modules.rabbitmq import get_rabbitmq_rpc_server -from . import _licensed_items, _resource_tracker +from . import _licensed_items_purchases, _resource_tracker _logger = logging.getLogger(__name__) ROUTERS: list[RPCRouter] = [ _resource_tracker.router, - _licensed_items.router, + _licensed_items_purchases.router, ] 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 index 44a6ce56016..915b86db7c2 100644 --- 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 @@ -3,19 +3,23 @@ # pylint:disable=redefined-outer-name # pylint:disable=too-many-arguments -import os -from unittest.mock import AsyncMock, Mock +from datetime import datetime, timezone +from decimal import Decimal -import pytest +# # Remove the environment variable +# if "RESOURCE_USAGE_TRACKER_S3" in os.environ: +# monkeypatch.delenv("RESOURCE_USAGE_TRACKER_S3") import sqlalchemy as sa -from moto.server import ThreadedMotoServer -from pydantic import AnyUrl, TypeAdapter -from pytest_mock import MockerFixture -from pytest_simcore.helpers.typing_env import EnvVarsDict +from models_library.api_schemas_resource_usage_tracker.licensed_items_purchases import ( + LicensedItemPurchaseGet, +) +from models_library.resource_tracker_licensed_items_purchases import ( + LicensedItemsPurchasesCreate, +) from servicelib.rabbitmq import RabbitMQRPCClient -from servicelib.rabbitmq.rpc_interfaces.resource_usage_tracker import service_runs -from settings_library.s3 import S3Settings -from types_aiobotocore_s3 import S3Client +from servicelib.rabbitmq.rpc_interfaces.resource_usage_tracker import ( + licensed_items_purchases, +) pytest_simcore_core_services_selection = [ "postgres", @@ -25,56 +29,79 @@ "adminer", ] -_USER_ID = 1 +_USER_ID = 1 -@pytest.fixture -async def mocked_export(mocker: MockerFixture) -> AsyncMock: - return mocker.patch( - "simcore_service_resource_usage_tracker.services.service_runs.service_runs_db.export_service_runs_table_to_s3", - autospec=True, - ) +# @pytest.fixture +# async def mocked_export(mocker: MockerFixture) -> AsyncMock: +# return mocker.patch( +# "simcore_service_resource_usage_tracker.services.service_runs.service_runs_db.export_service_runs_table_to_s3", +# autospec=True, +# ) -@pytest.fixture -async def mocked_presigned_link(mocker: MockerFixture) -> AsyncMock: - return mocker.patch( - "simcore_service_resource_usage_tracker.services.service_runs.SimcoreS3API.create_single_presigned_download_link", - return_value=TypeAdapter(AnyUrl).validate_python("https://www.testing.com/"), - ) +# @pytest.fixture +# async def mocked_presigned_link(mocker: MockerFixture) -> AsyncMock: +# return mocker.patch( +# "simcore_service_resource_usage_tracker.services.service_runs.SimcoreS3API.create_single_presigned_download_link", +# return_value=TypeAdapter(AnyUrl).validate_python("https://www.testing.com/"), +# ) -@pytest.fixture -async def enable_resource_usage_tracker_s3( - mock_env: EnvVarsDict, - mocked_aws_server: ThreadedMotoServer, - mocked_s3_server_envs: EnvVarsDict, - mocked_s3_server_settings: S3Settings, - s3_client: S3Client, - monkeypatch: pytest.MonkeyPatch, -) -> None: - # Create bucket - await s3_client.create_bucket(Bucket=mocked_s3_server_settings.S3_BUCKET_NAME) - # Remove the environment variable - if "RESOURCE_USAGE_TRACKER_S3" in os.environ: - monkeypatch.delenv("RESOURCE_USAGE_TRACKER_S3") +# @pytest.fixture +# async def enable_resource_usage_tracker_s3( +# mock_env: EnvVarsDict, +# mocked_aws_server: ThreadedMotoServer, +# mocked_s3_server_envs: EnvVarsDict, +# mocked_s3_server_settings: S3Settings, +# s3_client: S3Client, +# monkeypatch: pytest.MonkeyPatch, +# ) -> None: +# # Create bucket +# await s3_client.create_bucket(Bucket=mocked_s3_server_settings.S3_BUCKET_NAME) -@pytest.mark.rpc_test() -async def test_rpc_list_service_runs_which_was_billed( - enable_resource_usage_tracker_s3: None, +async def test_rpc_licensed_items_purchases_workflow( + # enable_resource_usage_tracker_s3: None, mocked_redis_server: None, postgres_db: sa.engine.Engine, rpc_client: RabbitMQRPCClient, - mocked_export: Mock, - mocked_presigned_link: Mock, + # mocked_export: Mock, + # mocked_presigned_link: Mock, ): - download_url = await service_runs.export_service_runs( + result = await licensed_items_purchases.get_licensed_items_purchases_page( + rpc_client, product_name="osparc", wallet_id=1 + ) + assert isinstance(result, list) # nosec + + _create_data = LicensedItemsPurchasesCreate( + product_name="osparc", + licensed_item_id="beb16d18-d57d-44aa-a638-9727fa4a72ef", + wallet_id=1, + wallet_name="My Wallet", + pricing_unit_cost_id=1, + pricing_unit_cost=Decimal(10), + start_at=datetime.now(tz=timezone.utc), + expire_at=datetime.now(tz=timezone.utc), + num_of_seats=1, + purchased_by_user=1, + purchased_at=datetime.now(tz=timezone.utc), + ) + + result = await licensed_items_purchases.create_licensed_item_purchase( + rpc_client, data=_create_data + ) + assert isinstance(result, LicensedItemPurchaseGet) # nosec + + result = await licensed_items_purchases.get_licensed_item_purchase( rpc_client, - user_id=_USER_ID, product_name="osparc", + licensed_item_purchase_id=result.licensed_item_purchase_id, + ) + assert isinstance(result, LicensedItemPurchaseGet) # nosec + + result = await licensed_items_purchases.get_licensed_items_purchases_page( + rpc_client, product_name="osparc", wallet_id=_create_data.wallet_id ) - assert isinstance(download_url, AnyUrl) # nosec - assert mocked_export.called - assert mocked_presigned_link.called + assert isinstance(result, list) # nosec From acd4c881a0c32ea40c9409fc42f29cfff5bc4519 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Tue, 10 Dec 2024 11:37:40 +0100 Subject: [PATCH 06/26] RUT unit tests --- ...7_add_cols_to_licensed_items_purchases_.py | 4 +-- ...b_add_cols_to_licensed_items_purchases_.py | 28 +++++++++++++++++++ ...source_tracker_licensed_items_purchases.py | 2 +- .../modules/db/licensed_items_purchases_db.py | 4 +-- .../test_api_licensed_items_purchases.py | 22 +++++++++------ 5 files changed, 47 insertions(+), 13 deletions(-) create mode 100644 packages/postgres-database/src/simcore_postgres_database/migration/versions/d68b8128c23b_add_cols_to_licensed_items_purchases_.py 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 index 39f2ba32ea3..ee47dcb5d4a 100644 --- 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 @@ -1,7 +1,7 @@ """add cols to licensed_items_purchases table Revision ID: 8fa15c4c3977 -Revises: 38c9ac332c58 +Revises: 4d007819e61a Create Date: 2024-12-10 06:42:23.319239+00:00 """ @@ -10,7 +10,7 @@ # revision identifiers, used by Alembic. revision = "8fa15c4c3977" -down_revision = "38c9ac332c58" +down_revision = "4d007819e61a" branch_labels = None depends_on = None 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_licensed_items_purchases.py b/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_licensed_items_purchases.py index c5c3e2b57ec..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 @@ -26,7 +26,7 @@ ), sa.Column( "licensed_item_id", - sa.BigInteger, + UUID(as_uuid=True), nullable=False, ), sa.Column( diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/licensed_items_purchases_db.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/licensed_items_purchases_db.py index e507da9f7e2..67950b7b73d 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/licensed_items_purchases_db.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/licensed_items_purchases_db.py @@ -49,7 +49,7 @@ async def create( data: CreateLicensedItemsPurchasesDB, ) -> LicensedItemsPurchasesDB: async with transaction_context(engine, connection) as conn: - result = await conn.stream( + result = await conn.execute( resource_tracker_licensed_items_purchases.insert() .values( product_name=data.product_name, @@ -67,7 +67,7 @@ async def create( ) .returning(*_SELECTION_ARGS) ) - row = await result.first() + row = result.first() return LicensedItemsPurchasesDB.model_validate(row) 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 index 915b86db7c2..aaf235351c3 100644 --- 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 @@ -3,7 +3,7 @@ # pylint:disable=redefined-outer-name # pylint:disable=too-many-arguments -from datetime import datetime, timezone +from datetime import UTC, datetime from decimal import Decimal # # Remove the environment variable @@ -12,6 +12,7 @@ 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, @@ -73,7 +74,9 @@ async def test_rpc_licensed_items_purchases_workflow( result = await licensed_items_purchases.get_licensed_items_purchases_page( rpc_client, product_name="osparc", wallet_id=1 ) - assert isinstance(result, list) # nosec + assert isinstance(result, LicensedItemsPurchasesPage) # nosec + assert result.items == [] + assert result.total == 0 _create_data = LicensedItemsPurchasesCreate( product_name="osparc", @@ -82,14 +85,14 @@ async def test_rpc_licensed_items_purchases_workflow( wallet_name="My Wallet", pricing_unit_cost_id=1, pricing_unit_cost=Decimal(10), - start_at=datetime.now(tz=timezone.utc), - expire_at=datetime.now(tz=timezone.utc), + start_at=datetime.now(tz=UTC), + expire_at=datetime.now(tz=UTC), num_of_seats=1, purchased_by_user=1, - purchased_at=datetime.now(tz=timezone.utc), + purchased_at=datetime.now(tz=UTC), ) - result = await licensed_items_purchases.create_licensed_item_purchase( + created_item = await licensed_items_purchases.create_licensed_item_purchase( rpc_client, data=_create_data ) assert isinstance(result, LicensedItemPurchaseGet) # nosec @@ -97,11 +100,14 @@ async def test_rpc_licensed_items_purchases_workflow( result = await licensed_items_purchases.get_licensed_item_purchase( rpc_client, product_name="osparc", - licensed_item_purchase_id=result.licensed_item_purchase_id, + 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, list) # nosec + assert isinstance(result, LicensedItemsPurchasesPage) # nosec + assert len(result.items) == 1 + assert result.total == 1 From 317cf1dd8ae20e5ebbb35a5779f4ec7e54cb9001 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Tue, 10 Dec 2024 11:38:17 +0100 Subject: [PATCH 07/26] RUT unit tests --- .../test_api_licensed_items_purchases.py | 38 ------------------- 1 file changed, 38 deletions(-) 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 index aaf235351c3..aad656d1728 100644 --- 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 @@ -6,9 +6,6 @@ from datetime import UTC, datetime from decimal import Decimal -# # Remove the environment variable -# if "RESOURCE_USAGE_TRACKER_S3" in os.environ: -# monkeypatch.delenv("RESOURCE_USAGE_TRACKER_S3") import sqlalchemy as sa from models_library.api_schemas_resource_usage_tracker.licensed_items_purchases import ( LicensedItemPurchaseGet, @@ -31,45 +28,10 @@ ] -_USER_ID = 1 - - -# @pytest.fixture -# async def mocked_export(mocker: MockerFixture) -> AsyncMock: -# return mocker.patch( -# "simcore_service_resource_usage_tracker.services.service_runs.service_runs_db.export_service_runs_table_to_s3", -# autospec=True, -# ) - - -# @pytest.fixture -# async def mocked_presigned_link(mocker: MockerFixture) -> AsyncMock: -# return mocker.patch( -# "simcore_service_resource_usage_tracker.services.service_runs.SimcoreS3API.create_single_presigned_download_link", -# return_value=TypeAdapter(AnyUrl).validate_python("https://www.testing.com/"), -# ) - - -# @pytest.fixture -# async def enable_resource_usage_tracker_s3( -# mock_env: EnvVarsDict, -# mocked_aws_server: ThreadedMotoServer, -# mocked_s3_server_envs: EnvVarsDict, -# mocked_s3_server_settings: S3Settings, -# s3_client: S3Client, -# monkeypatch: pytest.MonkeyPatch, -# ) -> None: -# # Create bucket -# await s3_client.create_bucket(Bucket=mocked_s3_server_settings.S3_BUCKET_NAME) - - async def test_rpc_licensed_items_purchases_workflow( - # enable_resource_usage_tracker_s3: None, mocked_redis_server: None, postgres_db: sa.engine.Engine, rpc_client: RabbitMQRPCClient, - # mocked_export: Mock, - # mocked_presigned_link: Mock, ): result = await licensed_items_purchases.get_licensed_items_purchases_page( rpc_client, product_name="osparc", wallet_id=1 From 932fb00f89d107501501a4afdef226fa65e5cdb2 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Tue, 10 Dec 2024 13:40:47 +0100 Subject: [PATCH 08/26] webserver part --- .../licensed_items_purchases.py | 4 +- .../licensed_items_purchases.py | 35 +++++++ .../simcore_service_webserver/application.py | 4 + .../catalog/plugin.py | 3 - .../{catalog => }/licenses/__init__.py | 0 .../licenses/_exceptions_handlers.py | 2 +- .../licenses/_licensed_items_api.py | 0 .../licenses/_licensed_items_db.py | 2 +- .../licenses/_licensed_items_handlers.py | 12 +-- .../licenses/_licensed_items_purchases_api.py | 92 +++++++++++++++++++ .../_licensed_items_purchases_handlers.py | 91 ++++++++++++++++++ .../{catalog => }/licenses/_models.py | 4 + .../{catalog => }/licenses/api.py | 0 .../{catalog => }/licenses/errors.py | 0 .../{catalog => }/licenses/plugin.py | 0 15 files changed, 236 insertions(+), 13 deletions(-) create mode 100644 packages/models-library/src/models_library/api_schemas_webserver/licensed_items_purchases.py rename services/web/server/src/simcore_service_webserver/{catalog => }/licenses/__init__.py (100%) rename services/web/server/src/simcore_service_webserver/{catalog => }/licenses/_exceptions_handlers.py (94%) rename services/web/server/src/simcore_service_webserver/{catalog => }/licenses/_licensed_items_api.py (100%) 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 (92%) rename services/web/server/src/simcore_service_webserver/{catalog => }/licenses/api.py (100%) rename services/web/server/src/simcore_service_webserver/{catalog => }/licenses/errors.py (100%) rename services/web/server/src/simcore_service_webserver/{catalog => }/licenses/plugin.py (100%) 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 index 6afe928e88d..c147b411465 100644 --- 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 @@ -17,8 +17,8 @@ class LicensedItemPurchaseGet(BaseModel): licensed_item_purchase_id: LicensedItemPurchaseID product_name: ProductName licensed_item_id: LicensedItemID - wallet_id: WalletID | None - wallet_name: str | None + wallet_id: WalletID + wallet_name: str pricing_unit_cost_id: PricingUnitCostId pricing_unit_cost: Decimal start_at: datetime 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..1005019c58d --- /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: datetime + + +class LicensedItemPurchaseGetPage(NamedTuple): + items: list[LicensedItemPurchaseGet] + total: PositiveInt 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/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/catalog/licenses/_exceptions_handlers.py b/services/web/server/src/simcore_service_webserver/licenses/_exceptions_handlers.py similarity index 94% rename from services/web/server/src/simcore_service_webserver/catalog/licenses/_exceptions_handlers.py rename to services/web/server/src/simcore_service_webserver/licenses/_exceptions_handlers.py index 0abb7671b16..a4b5ada8925 100644 --- a/services/web/server/src/simcore_service_webserver/catalog/licenses/_exceptions_handlers.py +++ b/services/web/server/src/simcore_service_webserver/licenses/_exceptions_handlers.py @@ -2,7 +2,7 @@ from servicelib.aiohttp import status -from ...exception_handling import ( +from ..exception_handling import ( ExceptionToHttpErrorMap, HttpErrorInfo, exception_handling_decorator, diff --git a/services/web/server/src/simcore_service_webserver/catalog/licenses/_licensed_items_api.py b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_api.py similarity index 100% rename from services/web/server/src/simcore_service_webserver/catalog/licenses/_licensed_items_api.py rename to services/web/server/src/simcore_service_webserver/licenses/_licensed_items_api.py 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..b4e3b16a0ef --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_purchases_api.py @@ -0,0 +1,92 @@ +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.wallets import WalletID +from servicelib.rabbitmq.rpc_interfaces.resource_usage_tracker import ( + licensed_items_purchases, +) + +from ..rabbitmq import get_rabbitmq_rpc_client + +_logger = logging.getLogger(__name__) + + +async def list_licensed_items_purchases( + app: web.Application, + product_name: ProductName, + wallet_id: WalletID, + offset: int, + limit: int, + order_by: OrderBy, +) -> webserver_licensed_items_purchases.LicensedItemPurchaseGetPage: + 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=item.modified, + ) + for item in result.items + ], + ) + + +async def get_licensed_item_purchase( + app: web.Application, + product_name: ProductName, + 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, + ) + ) + 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=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..990942b883c --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_purchases_handlers.py @@ -0,0 +1,91 @@ +import logging + +from aiohttp import web +from models_library.api_schemas_webserver.licensed_items import LicensedItemGet +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 . import _licensed_items_purchases_api +from ._exceptions_handlers import handle_plugin_requests_exceptions +from ._models import LicensedItemsPurchasesPathParams, LicensedItemsRequestContext + +_logger = logging.getLogger(__name__) + + +routes = web.RouteTableDef() + + +@routes.get( + f"/{VTAG}/catalog/licensed-items-purchases", name="list_licensed_items_purchases" +) +@login_required +@permission_required("catalog/licensed-items.*") +@handle_plugin_requests_exceptions +async def list_licensed_items_purchases(request: web.Request): + req_ctx = LicensedItemsRequestContext.model_validate(request) + query_params: LicensedItemsListQueryParams = parse_request_query_parameters_as( + LicensedItemsListQueryParams, 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, + offset=query_params.offset, + limit=query_params.limit, + order_by=OrderBy.model_construct(**query_params.order_by.model_dump()), + ) + ) + + page = Page[LicensedItemGet].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, + ) + + +@routes.get( + f"/{VTAG}/catalog/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, + licensed_item_purchase_id=path_params.licensed_item_purchase_id, + ) + ) + + return envelope_json_response(licensed_item_purchase_get) 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 92% 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 884cc291431..5c91caa45e0 100644 --- a/services/web/server/src/simcore_service_webserver/catalog/licenses/_models.py +++ b/services/web/server/src/simcore_service_webserver/licenses/_models.py @@ -52,3 +52,7 @@ class LicensedItemsBodyParams(BaseModel): num_of_seats: int model_config = ConfigDict(extra="forbid") + + +class LicensedItemsPurchasesPathParams(StrictRequestParameters): + licensed_item_purchase_id: LicensedItemPurchaseID 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/catalog/licenses/errors.py b/services/web/server/src/simcore_service_webserver/licenses/errors.py similarity index 100% rename from services/web/server/src/simcore_service_webserver/catalog/licenses/errors.py rename to services/web/server/src/simcore_service_webserver/licenses/errors.py 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 100% 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 From 06f5743883958abc7f1804b77aaf90dc28b0ccb0 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Tue, 10 Dec 2024 14:12:00 +0100 Subject: [PATCH 09/26] webserver part --- .../licensed_items_purchases.py | 2 +- .../licenses/_licensed_items_purchases_api.py | 23 +++++- .../_licensed_items_purchases_handlers.py | 72 +++++++++++-------- .../licenses/_models.py | 25 ++++++- 4 files changed, 87 insertions(+), 35 deletions(-) diff --git a/packages/models-library/src/models_library/api_schemas_webserver/licensed_items_purchases.py b/packages/models-library/src/models_library/api_schemas_webserver/licensed_items_purchases.py index 1005019c58d..0264e713256 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/licensed_items_purchases.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/licensed_items_purchases.py @@ -27,7 +27,7 @@ class LicensedItemPurchaseGet(OutputSchema): num_of_seats: int purchased_by_user: UserID purchased_at: datetime - modified: datetime + modified_at: datetime class LicensedItemPurchaseGetPage(NamedTuple): diff --git a/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_purchases_api.py b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_purchases_api.py index b4e3b16a0ef..4aae82ae768 100644 --- a/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_purchases_api.py +++ b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_purchases_api.py @@ -12,12 +12,14 @@ 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__) @@ -25,11 +27,18 @@ 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( @@ -56,7 +65,7 @@ async def list_licensed_items_purchases( num_of_seats=item.num_of_seats, purchased_by_user=item.purchased_by_user, purchased_at=item.purchased_at, - modified=item.modified, + modified_at=item.modified, ) for item in result.items ], @@ -66,6 +75,7 @@ async def list_licensed_items_purchases( 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) @@ -76,6 +86,15 @@ async def get_licensed_item_purchase( 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, @@ -88,5 +107,5 @@ async def get_licensed_item_purchase( 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=licensed_item_get.modified, + 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 index 990942b883c..bd1c0197d09 100644 --- 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 @@ -1,7 +1,6 @@ import logging from aiohttp import web -from models_library.api_schemas_webserver.licensed_items import LicensedItemGet from models_library.api_schemas_webserver.licensed_items_purchases import ( LicensedItemPurchaseGet, LicensedItemPurchaseGetPage, @@ -20,39 +19,74 @@ 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 LicensedItemsPurchasesPathParams, LicensedItemsRequestContext +from ._models import ( + LicensedItemsPurchasesListQueryParams, + LicensedItemsPurchasesPathParams, + LicensedItemsRequestContext, +) _logger = logging.getLogger(__name__) - routes = web.RouteTableDef() @routes.get( - f"/{VTAG}/catalog/licensed-items-purchases", name="list_licensed_items_purchases" + f"/{VTAG}/catalog/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_licensed_items_purchases(request: web.Request): req_ctx = LicensedItemsRequestContext.model_validate(request) - query_params: LicensedItemsListQueryParams = parse_request_query_parameters_as( - LicensedItemsListQueryParams, 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[LicensedItemGet].model_validate( + page = Page[LicensedItemPurchaseGet].model_validate( paginate_data( chunk=licensed_item_purchase_get_page.items, request_url=request.url, @@ -65,27 +99,3 @@ async def list_licensed_items_purchases(request: web.Request): text=page.model_dump_json(**RESPONSE_MODEL_POLICY), content_type=MIMETYPE_APPLICATION_JSON, ) - - -@routes.get( - f"/{VTAG}/catalog/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, - licensed_item_purchase_id=path_params.licensed_item_purchase_id, - ) - ) - - return envelope_json_response(licensed_item_purchase_get) diff --git a/services/web/server/src/simcore_service_webserver/licenses/_models.py b/services/web/server/src/simcore_service_webserver/licenses/_models.py index 5c91caa45e0..2d8514e28e9 100644 --- a/services/web/server/src/simcore_service_webserver/licenses/_models.py +++ b/services/web/server/src/simcore_service_webserver/licenses/_models.py @@ -2,6 +2,9 @@ from models_library.basic_types import IDStr from models_library.licensed_items import LicensedItemID +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 +17,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__) @@ -56,3 +59,23 @@ class LicensedItemsBodyParams(BaseModel): 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] +): + ... From dbd1ffe7f3c7ae16de8cb00d377f1db2dcaa7a0e Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Tue, 10 Dec 2024 14:25:59 +0100 Subject: [PATCH 10/26] open api specs --- ...g_licensed_items.py => _licensed_items.py} | 6 +- .../web-server/_licensed_items_purchases.py | 55 +++ api/specs/web-server/openapi.py | 3 +- .../api/v0/openapi.yaml | 382 +++++++++++++----- .../_licensed_items_purchases_handlers.py | 4 +- .../licenses/errors.py | 2 +- 6 files changed, 339 insertions(+), 113 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 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..1b3f4b7cf71 --- /dev/null +++ b/api/specs/web-server/_licensed_items_purchases.py @@ -0,0 +1,55 @@ +""" 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 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=Envelope[list[LicensedItemPurchaseGet]], +) +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 77e656efdaa..54b0aa4361e 100644 --- a/api/specs/web-server/openapi.py +++ b/api/specs/web-server/openapi.py @@ -31,11 +31,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/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml index 0ab1f87a5c1..890d5b77af7 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 @@ -2357,108 +2357,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: @@ -3027,6 +2925,186 @@ 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 + /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/wallets/{wallet_id}/licensed-items-purchases: + get: + tags: + - licenses + 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/Envelope_list_LicensedItemPurchaseGet__' + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Not Found + /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 /v0/projects/{project_uuid}/checkpoint/{ref_id}/iterations: get: tags: @@ -7835,6 +7913,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: @@ -8616,6 +8707,22 @@ components: title: Error type: object title: Envelope[list[LicensedItemGet]] + Envelope_list_LicensedItemPurchaseGet__: + properties: + data: + anyOf: + - items: + $ref: '#/components/schemas/LicensedItemPurchaseGet' + type: array + - type: 'null' + title: Data + error: + anyOf: + - {} + - type: 'null' + title: Error + type: object + title: Envelope[list[LicensedItemPurchaseGet]] Envelope_list_OsparcCreditsAggregatedByServiceGet__: properties: data: @@ -10029,6 +10136,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: @@ -10036,14 +10208,14 @@ components: exclusiveMinimum: true title: Wallet Id minimum: 0 - num_of_seeds: + num_of_seats: type: integer - title: Num Of Seeds + title: Num Of Seats additionalProperties: false type: object required: - wallet_id - - num_of_seeds + - num_of_seats title: LicensedItemsBodyParams LicensedResourceType: type: string 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 index bd1c0197d09..95f48ebbd0e 100644 --- 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 @@ -34,7 +34,7 @@ @routes.get( - f"/{VTAG}/catalog/licensed-items-purchases/{{licensed_item_purchase_id}}", + f"/{VTAG}/licensed-items-purchases/{{licensed_item_purchase_id}}", name="get_licensed_item_purchase", ) @login_required @@ -65,7 +65,7 @@ async def get_licensed_item_purchase(request: web.Request): @login_required @permission_required("catalog/licensed-items.*") @handle_plugin_requests_exceptions -async def list_licensed_items_purchases(request: web.Request): +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 = ( diff --git a/services/web/server/src/simcore_service_webserver/licenses/errors.py b/services/web/server/src/simcore_service_webserver/licenses/errors.py index 0c8bae69b03..0313499429e 100644 --- a/services/web/server/src/simcore_service_webserver/licenses/errors.py +++ b/services/web/server/src/simcore_service_webserver/licenses/errors.py @@ -1,4 +1,4 @@ -from ...errors import WebServerBaseError +from ..errors import WebServerBaseError class LicensesValueError(WebServerBaseError, ValueError): From 6bec5fff3a54de8beeab8b9fddfbf92d0a12569e Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Tue, 10 Dec 2024 15:15:28 +0100 Subject: [PATCH 11/26] webserver part tests --- .../licensed_items_purchases.py | 4 +- .../licenses/_exceptions_handlers.py | 7 +- .../licenses/plugin.py | 3 +- .../04/licenses/test_licensed_items_db.py | 4 +- .../licenses/test_licensed_items_handlers.py | 4 +- .../test_licensed_items_purchases_handlers.py | 101 ++++++++++++++++++ 6 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_purchases_handlers.py 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 index c147b411465..c755d4954b3 100644 --- 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 @@ -32,9 +32,9 @@ class LicensedItemPurchaseGet(BaseModel): json_schema_extra={ "examples": [ { - "licensed_item_purchase_id": 1, + "licensed_item_purchase_id": "beb16d18-d57d-44aa-a638-9727fa4a72ef", "product_name": "osparc", - "licensed_item_id": "Special Pricing Plan for Sleeper", + "licensed_item_id": "303942ef-6d31-4ba8-afbe-dbb1fce2a953", "wallet_id": 1, "wallet_name": "My Wallet", "pricing_unit_cost_id": 1, diff --git a/services/web/server/src/simcore_service_webserver/licenses/_exceptions_handlers.py b/services/web/server/src/simcore_service_webserver/licenses/_exceptions_handlers.py index a4b5ada8925..720e7611671 100644 --- a/services/web/server/src/simcore_service_webserver/licenses/_exceptions_handlers.py +++ b/services/web/server/src/simcore_service_webserver/licenses/_exceptions_handlers.py @@ -1,6 +1,7 @@ import logging from servicelib.aiohttp import status +from simcore_service_webserver.wallets.errors import WalletAccessForbiddenError from ..exception_handling import ( ExceptionToHttpErrorMap, @@ -17,7 +18,11 @@ LicensedItemNotFoundError: HttpErrorInfo( status.HTTP_404_NOT_FOUND, "Market item {licensed_item_id} not found.", - ) + ), + WalletAccessForbiddenError: HttpErrorInfo( + status.HTTP_403_FORBIDDEN, + "Wallet {wallet_id} forbidden.", + ), } diff --git a/services/web/server/src/simcore_service_webserver/licenses/plugin.py b/services/web/server/src/simcore_service_webserver/licenses/plugin.py index ef124c69fad..6c2ea7ce0d9 100644 --- a/services/web/server/src/simcore_service_webserver/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/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..64f433d33dc 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 @@ -12,8 +12,8 @@ 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 @@ -62,5 +62,5 @@ async def test_licensed_items_db_crud( 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}) + resp = await client.post(f"{url}", json={"wallet_id": 1, "num_of_seats": 5}) # NOTE: Not yet implemented 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) From 3755dce4b61da4849cf1fb13ead9349c3f1723dd Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Tue, 10 Dec 2024 15:20:55 +0100 Subject: [PATCH 12/26] open api specs --- .../api/v0/openapi.yaml | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) 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 890d5b77af7..54c2bfeaa16 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 @@ -2969,6 +2969,12 @@ paths: schema: $ref: '#/components/schemas/EnvelopedError' description: Not Found + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Forbidden /v0/catalog/licensed-items/{licensed_item_id}: get: tags: @@ -2997,6 +3003,12 @@ paths: schema: $ref: '#/components/schemas/EnvelopedError' description: Not Found + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Forbidden /v0/catalog/licensed-items/{licensed_item_id}:purchase: post: tags: @@ -3027,6 +3039,12 @@ paths: schema: $ref: '#/components/schemas/EnvelopedError' description: Not Found + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Forbidden /v0/wallets/{wallet_id}/licensed-items-purchases: get: tags: @@ -3078,6 +3096,12 @@ paths: schema: $ref: '#/components/schemas/EnvelopedError' description: Not Found + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Forbidden /v0/licensed-items-purchases/{licensed_item_purchase_id}: get: tags: @@ -3105,6 +3129,12 @@ paths: schema: $ref: '#/components/schemas/EnvelopedError' description: Not Found + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Forbidden /v0/projects/{project_uuid}/checkpoint/{ref_id}/iterations: get: tags: From 418585271f35f0f42307574b3dc7ee7cb533fd50 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Tue, 10 Dec 2024 15:39:47 +0100 Subject: [PATCH 13/26] fix type --- .../licensed_items_purchases.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index c755d4954b3..e75965a1b53 100644 --- 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 @@ -38,14 +38,14 @@ class LicensedItemPurchaseGet(BaseModel): "wallet_id": 1, "wallet_name": "My Wallet", "pricing_unit_cost_id": 1, - "pricing_unit_cost": Decimal(10), + "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", - } # type: ignore[index,union-attr] + } ] } ) From 4c31cd001011c0c22cabdea9cb94c35c88a34494 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Tue, 10 Dec 2024 15:42:31 +0100 Subject: [PATCH 14/26] fix type --- .../resource_usage_tracker/licensed_items_purchases.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/licensed_items_purchases.py b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/licensed_items_purchases.py index 95c002df7cb..8cdeef79d60 100644 --- a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/licensed_items_purchases.py +++ b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/licensed_items_purchases.py @@ -9,6 +9,7 @@ LicensedItemPurchaseID, 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 ( @@ -37,7 +38,7 @@ async def get_licensed_items_purchases_page( wallet_id: WalletID, offset: int = 0, limit: int = 20, - order_by: OrderBy = OrderBy(field="purchased_at"), + order_by: OrderBy = OrderBy(field=IDStr("purchased_at")), ) -> LicensedItemsPurchasesPage: result = await rabbitmq_rpc_client.request( RESOURCE_USAGE_TRACKER_RPC_NAMESPACE, From 530fda09b465d24201ab8ca24d190fc66bfd1385 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Tue, 10 Dec 2024 15:43:16 +0100 Subject: [PATCH 15/26] fix type --- .../api/rpc/_licensed_items_purchases.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index c835848b219..1245ab3f6b4 100644 --- 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 @@ -3,6 +3,7 @@ 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, @@ -25,7 +26,7 @@ async def get_licensed_items_purchases_page( wallet_id: WalletID, offset: int = 0, limit: int = 20, - order_by: OrderBy = OrderBy(field="purchased_at"), + order_by: OrderBy = OrderBy(field=IDStr("purchased_at")), ) -> LicensedItemsPurchasesPage: return await licensed_items_purchases.list_licensed_items_purchases( db_engine=app.state.engine, From 00d40bda3036946af3b77c76761db26b2eadd9fb Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Tue, 10 Dec 2024 15:44:34 +0100 Subject: [PATCH 16/26] fix type --- .../services/modules/db/licensed_items_purchases_db.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/licensed_items_purchases_db.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/licensed_items_purchases_db.py index 67950b7b73d..e9951042ddc 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/licensed_items_purchases_db.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/licensed_items_purchases_db.py @@ -2,6 +2,9 @@ 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 @@ -17,7 +20,6 @@ from ....exceptions.errors import LicensedItemPurchaseNotFoundError from ....models.licensed_items_purchases import ( CreateLicensedItemsPurchasesDB, - LicensedItemPurchaseID, LicensedItemsPurchasesDB, ) From 969f982bc4e373f9112b34f524ce8d1d031ee3cb Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Wed, 11 Dec 2024 05:58:35 +0100 Subject: [PATCH 17/26] review @pcrespov --- .../web-server/_licensed_items_purchases.py | 4 +- .../api/v0/openapi.yaml | 37 ++++++++++--------- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/api/specs/web-server/_licensed_items_purchases.py b/api/specs/web-server/_licensed_items_purchases.py index 1b3f4b7cf71..8a993cef688 100644 --- a/api/specs/web-server/_licensed_items_purchases.py +++ b/api/specs/web-server/_licensed_items_purchases.py @@ -15,6 +15,7 @@ ) 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 ( @@ -36,7 +37,8 @@ @router.get( "/wallets/{wallet_id}/licensed-items-purchases", - response_model=Envelope[list[LicensedItemPurchaseGet]], + response_model=Page[LicensedItemPurchaseGet], + tags=["wallets"], ) async def list_wallet_licensed_items_purchases( _path: Annotated[WalletsPathParams, Depends()], 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 ad1c9e49d11..6e40be15b8a 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 @@ -3105,6 +3105,7 @@ paths: get: tags: - licenses + - wallets summary: List Wallet Licensed Items Purchases operationId: list_wallet_licensed_items_purchases parameters: @@ -3145,7 +3146,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Envelope_list_LicensedItemPurchaseGet__' + $ref: '#/components/schemas/Page_LicensedItemPurchaseGet_' '404': content: application/json: @@ -8869,22 +8870,6 @@ components: title: Error type: object title: Envelope[list[LicensedItemGet]] - Envelope_list_LicensedItemPurchaseGet__: - properties: - data: - anyOf: - - items: - $ref: '#/components/schemas/LicensedItemPurchaseGet' - type: array - - type: 'null' - title: Data - error: - anyOf: - - {} - - type: 'null' - title: Error - type: object - title: Envelope[list[LicensedItemPurchaseGet]] Envelope_list_OsparcCreditsAggregatedByServiceGet__: properties: data: @@ -11431,6 +11416,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: From 138412e32736abbc1cbf67188daa30a9ffd5abc9 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Wed, 11 Dec 2024 06:12:52 +0100 Subject: [PATCH 18/26] adding purchase item functionality --- .../src/models_library/resource_tracker.py | 1 + ...source_tracker_licensed_items_purchases.py | 5 +- ...f_add_cols_to_licensed_items_purchases_.py | 38 ++++++++++ ...7_add_cols_to_licensed_items_purchases_.py | 4 +- .../resource_tracker_credit_transactions.py | 10 ++- .../api/rpc/_licensed_items_purchases.py | 2 +- .../models/credit_transactions.py | 4 ++ .../services/credit_transactions.py | 1 + .../services/licensed_items_purchases.py | 72 ++++++++++++++----- .../modules/db/credit_transactions_db.py | 1 + .../process_message_running_service.py | 1 + .../test_api_licensed_items_purchases.py | 5 +- .../licenses/_licensed_items_api.py | 58 ++++++++++++++- .../licenses/_models.py | 3 + 14 files changed, 181 insertions(+), 24 deletions(-) create mode 100644 packages/postgres-database/src/simcore_postgres_database/migration/versions/77ac824a77ff_add_cols_to_licensed_items_purchases_.py 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 index d1ab2d88dc8..8cddc1d98aa 100644 --- 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 @@ -7,7 +7,7 @@ from .licensed_items import LicensedItemID from .products import ProductName -from .resource_tracker import PricingUnitCostId +from .resource_tracker import PricingPlanId, PricingUnitCostId, PricingUnitId from .users import UserID from .wallets import WalletID @@ -19,12 +19,15 @@ class LicensedItemsPurchasesCreate(BaseModel): 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 index ee47dcb5d4a..6f425116490 100644 --- 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 @@ -1,7 +1,7 @@ """add cols to licensed_items_purchases table Revision ID: 8fa15c4c3977 -Revises: 4d007819e61a +Revises: 5e27063c3ac9 Create Date: 2024-12-10 06:42:23.319239+00:00 """ @@ -10,7 +10,7 @@ # revision identifiers, used by Alembic. revision = "8fa15c4c3977" -down_revision = "4d007819e61a" +down_revision = "5e27063c3ac9" branch_labels = None depends_on = None 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/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 index 1245ab3f6b4..e8f71dfb97d 100644 --- 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 @@ -57,5 +57,5 @@ async def create_licensed_item_purchase( app: FastAPI, *, data: LicensedItemsPurchasesCreate ) -> LicensedItemPurchaseGet: return await licensed_items_purchases.create_licensed_item_purchase( - db_engine=app.state.engine, data=data + 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/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/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 index 3e106559b9e..f88316e095b 100644 --- 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 @@ -6,20 +6,28 @@ 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 licensed_items_purchases_db +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( @@ -94,27 +102,59 @@ async def get_licensed_item_purchase( 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: - _create_db_data = 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, - ) + 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, data=_create_db_data) + 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( 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/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_licensed_items_purchases.py b/services/resource-usage-tracker/tests/unit/with_dbs/test_api_licensed_items_purchases.py index aad656d1728..e5920728d3c 100644 --- 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 @@ -45,19 +45,22 @@ async def test_rpc_licensed_items_purchases_workflow( 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(result, LicensedItemPurchaseGet) # nosec + assert isinstance(created_item, LicensedItemPurchaseGet) # nosec result = await licensed_items_purchases.get_licensed_item_purchase( rpc_client, 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 index bb024b0423b..a9ee7f1ef0b 100644 --- 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 @@ -1,6 +1,7 @@ # 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 ( @@ -9,11 +10,21 @@ ) 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 . import _licensed_items_db +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 . import _licensed_items_api, _licensed_items_db from ._models import LicensedItemsBodyParams _logger = logging.getLogger(__name__) @@ -74,4 +85,47 @@ async def purchase_licensed_item( licensed_item_id: LicensedItemID, body_params: LicensedItemsBodyParams, ) -> None: - raise NotImplementedError + # 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 _licensed_items_api.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 ValueError("You are lying!") + + 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 ValueError("Not enough 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/licenses/_models.py b/services/web/server/src/simcore_service_webserver/licenses/_models.py index 2d8514e28e9..d5c2ac0947e 100644 --- a/services/web/server/src/simcore_service_webserver/licenses/_models.py +++ b/services/web/server/src/simcore_service_webserver/licenses/_models.py @@ -2,6 +2,7 @@ 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, ) @@ -52,6 +53,8 @@ class LicensedItemsListQueryParams( class LicensedItemsBodyParams(BaseModel): wallet_id: WalletID + pricing_plan_id: PricingPlanId + pricing_unit_id: PricingUnitId num_of_seats: int model_config = ConfigDict(extra="forbid") From 7e9de14334e18e5b8be1395b511f02e74ef4c847 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Wed, 11 Dec 2024 06:17:55 +0100 Subject: [PATCH 19/26] open api specs --- .../simcore_service_webserver/api/v0/openapi.yaml | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 6e40be15b8a..62e9ebe076e 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 @@ -10372,6 +10372,16 @@ components: exclusiveMinimum: true title: Wallet Id minimum: 0 + pricing_plan_id: + type: integer + exclusiveMinimum: true + title: Pricing Plan Id + minimum: 0 + pricing_unit_id: + type: integer + exclusiveMinimum: true + title: Pricing Unit Id + minimum: 0 num_of_seats: type: integer title: Num Of Seats @@ -10379,6 +10389,8 @@ components: type: object required: - wallet_id + - pricing_plan_id + - pricing_unit_id - num_of_seats title: LicensedItemsBodyParams LicensedResourceType: From 4f1c5d6a2b925046ef5003a2b40e054b278e0115 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Wed, 11 Dec 2024 06:31:35 +0100 Subject: [PATCH 20/26] improve error handling --- .../licenses/_exceptions_handlers.py | 11 ++++++++++- .../licenses/_licensed_items_api.py | 11 +++++++++-- .../src/simcore_service_webserver/licenses/errors.py | 4 ++++ .../simcore_service_webserver/wallets/_handlers.py | 9 ++++++++- 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/licenses/_exceptions_handlers.py b/services/web/server/src/simcore_service_webserver/licenses/_exceptions_handlers.py index 720e7611671..d12b95fafa0 100644 --- a/services/web/server/src/simcore_service_webserver/licenses/_exceptions_handlers.py +++ b/services/web/server/src/simcore_service_webserver/licenses/_exceptions_handlers.py @@ -9,7 +9,8 @@ exception_handling_decorator, to_exceptions_handlers_map, ) -from .errors import LicensedItemNotFoundError +from ..wallets.errors import WalletNotEnoughCreditsError +from .errors import LicensedItemNotFoundError, LicensedItemPricingPlanMatchError _logger = logging.getLogger(__name__) @@ -23,6 +24,14 @@ 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.", + ), } 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 index a9ee7f1ef0b..1e196de47d8 100644 --- 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 @@ -24,8 +24,10 @@ 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_api, _licensed_items_db from ._models import LicensedItemsBodyParams +from .errors import LicensedItemPricingPlanMatchError _logger = logging.getLogger(__name__) @@ -95,7 +97,10 @@ async def purchase_licensed_item( ) if licensed_item.pricing_plan_id != body_params.pricing_plan_id: - raise ValueError("You are lying!") + raise LicensedItemPricingPlanMatchError( + pricing_plan_id=body_params.pricing_plan_id, + licensed_item_id=licensed_item_id, + ) pricing_unit = await get_pricing_plan_unit( app, @@ -106,7 +111,9 @@ async def purchase_licensed_item( # Check whether wallet has enough credits if wallet.available_credits - pricing_unit.current_cost_per_unit < 0: - raise ValueError("Not enough credits!") + raise WalletNotEnoughCreditsError( + reason=f"Wallet '{wallet.name}' has {wallet.available_credits} credits." + ) user = await get_user(app, user_id=user_id) diff --git a/services/web/server/src/simcore_service_webserver/licenses/errors.py b/services/web/server/src/simcore_service_webserver/licenses/errors.py index 0313499429e..18c57966123 100644 --- a/services/web/server/src/simcore_service_webserver/licenses/errors.py +++ b/services/web/server/src/simcore_service_webserver/licenses/errors.py @@ -7,3 +7,7 @@ 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/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) From 26f9c57c2c3582712079f97a1fecdf41094312f4 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Wed, 11 Dec 2024 06:46:57 +0100 Subject: [PATCH 21/26] open api specs --- .../api/v0/openapi.yaml | 60 +++++++++++++++++++ .../licenses/_licensed_items_api.py | 4 +- 2 files changed, 62 insertions(+), 2 deletions(-) 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 62e9ebe076e..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 @@ -3031,6 +3031,18 @@ paths: 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: @@ -3065,6 +3077,18 @@ paths: 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: @@ -3101,6 +3125,18 @@ paths: 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: @@ -3159,6 +3195,18 @@ paths: 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: @@ -3192,6 +3240,18 @@ paths: 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: 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 index 1e196de47d8..6feacf24b1d 100644 --- 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 @@ -25,7 +25,7 @@ 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_api, _licensed_items_db +from . import _licensed_items_db from ._models import LicensedItemsBodyParams from .errors import LicensedItemPricingPlanMatchError @@ -92,7 +92,7 @@ async def purchase_licensed_item( app, user_id=user_id, wallet_id=body_params.wallet_id, product_name=product_name ) - licensed_item = await _licensed_items_api.get_licensed_item( + licensed_item = await get_licensed_item( app, licensed_item_id=licensed_item_id, product_name=product_name ) From 583b2b4e4991457a61a315aa180aa7c056cbb82e Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Wed, 11 Dec 2024 07:05:04 +0100 Subject: [PATCH 22/26] fix import --- .../api_schemas_webserver/wallets.py | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) 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..ef5a4d5395d 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,12 +20,40 @@ 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": "pm_0987654321", + "description": "https://example.com/payment-method/form", + "owner": "https://example.com/payment-method/form", + "thumbnail": "https://example.com/payment-method/form", + "status": "https://example.com/payment-method/form", + "created": "https://example.com/payment-method/form", + "modified": "https://example.com/payment-method/form", + } + ] + }, + ) class WalletGetWithAvailableCredits(WalletGet): available_credits: Decimal + model_config = ConfigDict( + json_schema_extra={ + "examples": [ + { + **WalletGet.model_config["json_schema_extra"]["examples"][0], + "available_credits": 10.5, + } + ] + } + ) + class WalletGetPermissions(WalletGet): read: bool From 4a1008f94af0856f68b8edc88267d76580828619 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Wed, 11 Dec 2024 07:22:28 +0100 Subject: [PATCH 23/26] fix import --- .../api_schemas_webserver/wallets.py | 12 +-- .../licensed_items_purchases.py | 2 +- .../licenses/test_licensed_items_handlers.py | 79 ++++++++++++++++++- 3 files changed, 83 insertions(+), 10 deletions(-) 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 ef5a4d5395d..84f1b38d7f3 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 @@ -27,13 +27,13 @@ class WalletGet(OutputSchema): "examples": [ { "wallet_id": 1, - "name": "pm_0987654321", - "description": "https://example.com/payment-method/form", - "owner": "https://example.com/payment-method/form", + "name": "My wallet", + "description": "My description", + "owner": 1, "thumbnail": "https://example.com/payment-method/form", - "status": "https://example.com/payment-method/form", - "created": "https://example.com/payment-method/form", - "modified": "https://example.com/payment-method/form", + "status": "ACTIVE", + "created": "2024-03-25T00:00:00", + "modified": "2024-03-25T00:00:00", } ] }, diff --git a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/licensed_items_purchases.py b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/licensed_items_purchases.py index 8cdeef79d60..a9463271d75 100644 --- a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/licensed_items_purchases.py +++ b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/licensed_items_purchases.py @@ -6,13 +6,13 @@ ) from models_library.api_schemas_resource_usage_tracker.licensed_items_purchases import ( LicensedItemPurchaseGet, - LicensedItemPurchaseID, 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 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 64f433d33dc..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,8 +7,13 @@ 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 @@ -18,7 +23,7 @@ @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_seats": 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) From 776806bb014806e4b247b33ce8ed8e5af6d2a7ab Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Wed, 11 Dec 2024 07:54:16 +0100 Subject: [PATCH 24/26] fix typecheck --- .../src/models_library/api_schemas_webserver/wallets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 84f1b38d7f3..9cb0c3c7374 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 @@ -47,7 +47,7 @@ class WalletGetWithAvailableCredits(WalletGet): json_schema_extra={ "examples": [ { - **WalletGet.model_config["json_schema_extra"]["examples"][0], + **WalletGet.model_config["json_schema_extra"]["examples"][0], # type: ignore "available_credits": 10.5, } ] From fadfa1fd957be2a86abaeb0fa4834f243a6e006a Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Wed, 11 Dec 2024 08:16:25 +0100 Subject: [PATCH 25/26] fix typecheck --- .../models_library/api_schemas_webserver/wallets.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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 9cb0c3c7374..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 @@ -60,6 +60,19 @@ class WalletGetPermissions(WalletGet): 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 From 07663139218612a036ccbe3cb622cca107722bf5 Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Wed, 11 Dec 2024 14:03:22 +0100 Subject: [PATCH 26/26] contract between webserver and api server --- .../rpc_interfaces/webserver/auth/api_keys.py | 6 +- .../webserver/licenses/__init__.py | 0 .../webserver/licenses/licensed_items.py | 104 ++++++++++++++ .../licenses/_rpc.py | 80 +++++++++++ .../licenses/plugin.py | 7 +- .../with_dbs/04/licenses/test_licenses_rpc.py | 127 ++++++++++++++++++ 6 files changed, 320 insertions(+), 4 deletions(-) create mode 100644 packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/licenses/__init__.py create mode 100644 packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/licenses/licensed_items.py create mode 100644 services/web/server/src/simcore_service_webserver/licenses/_rpc.py create mode 100644 services/web/server/tests/unit/with_dbs/04/licenses/test_licenses_rpc.py diff --git a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/auth/api_keys.py b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/auth/api_keys.py index e70889e3de1..2609de81c5e 100644 --- a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/auth/api_keys.py +++ b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/auth/api_keys.py @@ -26,7 +26,7 @@ async def create_api_key( product_name=product_name, api_key=api_key, ) - assert isinstance(result, ApiKeyGet) + assert isinstance(result, ApiKeyGet) # nosec return result @@ -45,7 +45,7 @@ async def get_api_key( product_name=product_name, api_key_id=api_key_id, ) - assert isinstance(result, ApiKeyGet) + assert isinstance(result, ApiKeyGet) # nosec return result @@ -63,4 +63,4 @@ async def delete_api_key( product_name=product_name, api_key_id=api_key_id, ) - assert result is None + assert result is None # nosec diff --git a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/licenses/__init__.py b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/licenses/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/licenses/licensed_items.py b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/licenses/licensed_items.py new file mode 100644 index 00000000000..e212854bae5 --- /dev/null +++ b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/licenses/licensed_items.py @@ -0,0 +1,104 @@ +import logging + +from models_library.api_schemas_webserver import WEBSERVER_RPC_NAMESPACE +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.rabbitmq_basic_types import RPCMethodName +from models_library.resource_tracker import ServiceRunId +from models_library.users import UserID +from models_library.wallets import WalletID +from pydantic import TypeAdapter +from servicelib.logging_utils import log_decorator +from servicelib.rabbitmq import RabbitMQRPCClient + +_logger = logging.getLogger(__name__) + + +@log_decorator(_logger, level=logging.DEBUG) +async def get_licensed_items( + rabbitmq_rpc_client: RabbitMQRPCClient, + *, + product_name: str, + offset: int, + limit: int, +) -> LicensedItemGetPage: + result: LicensedItemGetPage = await rabbitmq_rpc_client.request( + WEBSERVER_RPC_NAMESPACE, + TypeAdapter(RPCMethodName).validate_python("get_licensed_items"), + product_name=product_name, + offset=offset, + limit=limit, + ) + assert isinstance(result, LicensedItemGetPage) + return result + + +@log_decorator(_logger, level=logging.DEBUG) +async def get_licensed_items_for_wallet( + rabbitmq_rpc_client: RabbitMQRPCClient, + *, + user_id: UserID, + product_name: ProductName, + wallet_id: WalletID, +) -> LicensedItemGet: + result: LicensedItemGet = await rabbitmq_rpc_client.request( + WEBSERVER_RPC_NAMESPACE, + TypeAdapter(RPCMethodName).validate_python("get_licensed_items_for_wallet"), + user_id=user_id, + product_name=product_name, + wallet_id=wallet_id, + ) + assert isinstance(result, LicensedItemGet) # nosec + return result + + +@log_decorator(_logger, level=logging.DEBUG) +async def checkout_licensed_item_for_wallet( + rabbitmq_rpc_client: RabbitMQRPCClient, + *, + user_id: UserID, + product_name: ProductName, + wallet_id: WalletID, + licensed_item_id: LicensedItemID, + num_of_seats: int, + service_run_id: ServiceRunId, +) -> None: + result = await rabbitmq_rpc_client.request( + WEBSERVER_RPC_NAMESPACE, + TypeAdapter(RPCMethodName).validate_python("checkout_licensed_item_for_wallet"), + user_id=user_id, + product_name=product_name, + wallet_id=wallet_id, + licensed_item_id=licensed_item_id, + num_of_seats=num_of_seats, + service_run_id=service_run_id, + ) + assert result is None # nosec + + +@log_decorator(_logger, level=logging.DEBUG) +async def release_licensed_item_for_wallet( + rabbitmq_rpc_client: RabbitMQRPCClient, + *, + user_id: UserID, + product_name: ProductName, + wallet_id: WalletID, + licensed_item_id: LicensedItemID, + num_of_seats: int, + service_run_id: ServiceRunId, +) -> None: + result = await rabbitmq_rpc_client.request( + WEBSERVER_RPC_NAMESPACE, + TypeAdapter(RPCMethodName).validate_python("release_licensed_item_for_wallet"), + user_id=user_id, + product_name=product_name, + wallet_id=wallet_id, + licensed_item_id=licensed_item_id, + num_of_seats=num_of_seats, + service_run_id=service_run_id, + ) + assert result is None # nosec diff --git a/services/web/server/src/simcore_service_webserver/licenses/_rpc.py b/services/web/server/src/simcore_service_webserver/licenses/_rpc.py new file mode 100644 index 00000000000..fede0759b0d --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/licenses/_rpc.py @@ -0,0 +1,80 @@ +from aiohttp import web +from models_library.api_schemas_webserver import WEBSERVER_RPC_NAMESPACE +from models_library.api_schemas_webserver.licensed_items import LicensedItemGetPage +from models_library.basic_types import IDStr +from models_library.licensed_items import LicensedItemID +from models_library.products import ProductName +from models_library.resource_tracker import ServiceRunId +from models_library.rest_ordering import OrderBy +from models_library.users import UserID +from models_library.wallets import WalletID +from servicelib.rabbitmq import RPCRouter + +from ..rabbitmq import get_rabbitmq_rpc_server +from . import _licensed_items_api + +router = RPCRouter() + + +@router.expose() +async def get_licensed_items( + app: web.Application, + *, + product_name: ProductName, + offset: int, + limit: int, +) -> LicensedItemGetPage: + licensed_item_get_page: LicensedItemGetPage = ( + await _licensed_items_api.list_licensed_items( + app=app, + product_name=product_name, + offset=offset, + limit=limit, + order_by=OrderBy(field=IDStr("name")), + ) + ) + return licensed_item_get_page + + +@router.expose(reraise_if_error_type=(NotImplementedError,)) +async def get_licensed_items_for_wallet( + app: web.Application, + *, + user_id: UserID, + product_name: ProductName, + wallet_id: WalletID, +) -> None: + raise NotImplementedError + + +@router.expose(reraise_if_error_type=(NotImplementedError,)) +async def checkout_licensed_item_for_wallet( + app: web.Application, + *, + user_id: UserID, + product_name: ProductName, + wallet_id: WalletID, + licensed_item_id: LicensedItemID, + num_of_seats: int, + service_run_id: ServiceRunId, +) -> None: + raise NotImplementedError + + +@router.expose(reraise_if_error_type=(NotImplementedError,)) +async def release_licensed_item_for_wallet( + app: web.Application, + *, + user_id: str, + product_name: str, + wallet_id: WalletID, + licensed_item_id: LicensedItemID, + num_of_seats: int, + service_run_id: ServiceRunId, +) -> None: + raise NotImplementedError + + +async def register_rpc_routes_on_startup(app: web.Application): + rpc_server = get_rabbitmq_rpc_server(app) + await rpc_server.register_router(router, WEBSERVER_RPC_NAMESPACE, app) diff --git a/services/web/server/src/simcore_service_webserver/licenses/plugin.py b/services/web/server/src/simcore_service_webserver/licenses/plugin.py index 6c2ea7ce0d9..137c7b2d1dc 100644 --- a/services/web/server/src/simcore_service_webserver/licenses/plugin.py +++ b/services/web/server/src/simcore_service_webserver/licenses/plugin.py @@ -7,7 +7,8 @@ from servicelib.aiohttp.application_keys import APP_SETTINGS_KEY from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup -from . import _licensed_items_handlers, _licensed_items_purchases_handlers +from ..rabbitmq import setup_rabbitmq +from . import _licensed_items_handlers, _licensed_items_purchases_handlers, _rpc _logger = logging.getLogger(__name__) @@ -25,3 +26,7 @@ def setup_licenses(app: web.Application): # routes app.router.add_routes(_licensed_items_handlers.routes) app.router.add_routes(_licensed_items_purchases_handlers.routes) + + setup_rabbitmq(app) + if app[APP_SETTINGS_KEY].WEBSERVER_RABBITMQ: + app.on_startup.append(_rpc.register_rpc_routes_on_startup) diff --git a/services/web/server/tests/unit/with_dbs/04/licenses/test_licenses_rpc.py b/services/web/server/tests/unit/with_dbs/04/licenses/test_licenses_rpc.py new file mode 100644 index 00000000000..e3ab4f4cb3d --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/04/licenses/test_licenses_rpc.py @@ -0,0 +1,127 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable + + +from collections.abc import Awaitable, Callable + +import pytest +from aiohttp.test_utils import TestClient +from models_library.licensed_items import LicensedResourceType +from models_library.products import ProductName +from pytest_mock import MockerFixture +from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict +from pytest_simcore.helpers.typing_env import EnvVarsDict +from pytest_simcore.helpers.webserver_login import UserInfoDict +from servicelib.rabbitmq import RabbitMQRPCClient +from servicelib.rabbitmq.rpc_interfaces.webserver.licenses.licensed_items import ( + checkout_licensed_item_for_wallet, + get_licensed_items, + get_licensed_items_for_wallet, + release_licensed_item_for_wallet, +) +from settings_library.rabbit import RabbitSettings +from simcore_postgres_database.models.users import UserRole +from simcore_service_webserver.application_settings import ApplicationSettings +from simcore_service_webserver.licenses import _licensed_items_db + +pytest_simcore_core_services_selection = [ + "rabbit", +] + + +@pytest.fixture +def app_environment( + rabbit_service: RabbitSettings, + app_environment: EnvVarsDict, + monkeypatch: pytest.MonkeyPatch, +): + new_envs = setenvs_from_dict( + monkeypatch, + { + **app_environment, + "RABBIT_HOST": rabbit_service.RABBIT_HOST, + "RABBIT_PORT": f"{rabbit_service.RABBIT_PORT}", + "RABBIT_USER": rabbit_service.RABBIT_USER, + "RABBIT_SECURE": f"{rabbit_service.RABBIT_SECURE}", + "RABBIT_PASSWORD": rabbit_service.RABBIT_PASSWORD.get_secret_value(), + }, + ) + + settings = ApplicationSettings.create_from_envs() + assert settings.WEBSERVER_RABBITMQ + + return new_envs + + +@pytest.fixture +def user_role() -> UserRole: + return UserRole.USER + + +@pytest.fixture +async def rpc_client( + rabbitmq_rpc_client: Callable[[str], Awaitable[RabbitMQRPCClient]], + mocker: MockerFixture, +) -> RabbitMQRPCClient: + return await rabbitmq_rpc_client("client") + + +async def test_api_keys_workflow( + client: TestClient, + rpc_client: RabbitMQRPCClient, + osparc_product_name: ProductName, + logged_user: UserInfoDict, + pricing_plan_id: int, +): + assert client.app + + result = await get_licensed_items( + rpc_client, product_name=osparc_product_name, offset=0, limit=20 + ) + assert len(result.items) == 0 + assert result.total == 0 + + 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, + ) + + result = await get_licensed_items( + rpc_client, product_name=osparc_product_name, offset=0, limit=20 + ) + assert len(result.items) == 1 + assert result.total == 1 + + with pytest.raises(NotImplementedError): + await get_licensed_items_for_wallet( + rpc_client, + user_id=logged_user["id"], + product_name=osparc_product_name, + wallet_id=1, + ) + + with pytest.raises(NotImplementedError): + await checkout_licensed_item_for_wallet( + rpc_client, + user_id=logged_user["id"], + product_name=osparc_product_name, + wallet_id=1, + licensed_item_id="c5139a2e-4e1f-4ebe-9bfd-d17f195111ee", + num_of_seats=1, + service_run_id="run_1", + ) + + with pytest.raises(NotImplementedError): + await release_licensed_item_for_wallet( + rpc_client, + user_id=logged_user["id"], + product_name=osparc_product_name, + wallet_id=1, + licensed_item_id="c5139a2e-4e1f-4ebe-9bfd-d17f195111ee", + num_of_seats=1, + service_run_id="run_1", + )