diff --git a/api/specs/web-server/_licensed_items.py b/api/specs/web-server/_licensed_items.py index bb06c70d09dc..3385028c1ce6 100644 --- a/api/specs/web-server/_licensed_items.py +++ b/api/specs/web-server/_licensed_items.py @@ -9,9 +9,11 @@ from typing import Annotated from _common import as_query -from fastapi import APIRouter, Depends, status +from fastapi import APIRouter, Depends from models_library.api_schemas_webserver.licensed_items import LicensedItemRestGet -from models_library.generics import Envelope +from models_library.api_schemas_webserver.licensed_items_purchases import ( + LicensedItemPurchaseGet, +) from models_library.rest_error import EnvelopedError from models_library.rest_pagination import Page from simcore_service_webserver._meta import API_VTAG @@ -46,19 +48,9 @@ async def list_licensed_items( ... -@router.get( - "/catalog/licensed-items/{licensed_item_id}", - response_model=Envelope[LicensedItemRestGet], -) -async def get_licensed_item( - _path: Annotated[LicensedItemsPathParams, Depends()], -): - ... - - @router.post( "/catalog/licensed-items/{licensed_item_id}:purchase", - status_code=status.HTTP_204_NO_CONTENT, + response_model=LicensedItemPurchaseGet, ) async def purchase_licensed_item( _path: Annotated[LicensedItemsPathParams, Depends()], diff --git a/packages/models-library/src/models_library/api_schemas_resource_usage_tracker/licensed_items_checkouts.py b/packages/models-library/src/models_library/api_schemas_resource_usage_tracker/licensed_items_checkouts.py index 0fe1df76816f..8257aa35186a 100644 --- a/packages/models-library/src/models_library/api_schemas_resource_usage_tracker/licensed_items_checkouts.py +++ b/packages/models-library/src/models_library/api_schemas_resource_usage_tracker/licensed_items_checkouts.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import NamedTuple -from models_library.licenses import LicensedItemID +from models_library.licenses import LicensedItemID, LicensedItemKey, LicensedItemVersion from models_library.products import ProductName from models_library.resource_tracker_licensed_items_checkouts import ( LicensedItemCheckoutID, @@ -15,6 +15,8 @@ class LicensedItemCheckoutGet(BaseModel): licensed_item_checkout_id: LicensedItemCheckoutID licensed_item_id: LicensedItemID + key: LicensedItemKey + version: LicensedItemVersion wallet_id: WalletID user_id: UserID user_email: str @@ -30,6 +32,8 @@ class LicensedItemCheckoutGet(BaseModel): { "licensed_item_checkout_id": "beb16d18-d57d-44aa-a638-9727fa4a72ef", "licensed_item_id": "303942ef-6d31-4ba8-afbe-dbb1fce2a953", + "key": "Duke", + "version": "1.0.0", "wallet_id": 1, "user_id": 1, "user_email": "test@test.com", 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 5c8d08df5cc1..e9ee9e4ae67e 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 @@ -2,7 +2,7 @@ from decimal import Decimal from typing import NamedTuple -from models_library.licenses import LicensedItemID +from models_library.licenses import LicensedItemID, LicensedItemKey, LicensedItemVersion from models_library.products import ProductName from models_library.resource_tracker import PricingUnitCostId from models_library.resource_tracker_licensed_items_purchases import ( @@ -17,6 +17,8 @@ class LicensedItemPurchaseGet(BaseModel): licensed_item_purchase_id: LicensedItemPurchaseID product_name: ProductName licensed_item_id: LicensedItemID + key: LicensedItemKey + version: LicensedItemVersion wallet_id: WalletID wallet_name: str pricing_unit_cost_id: PricingUnitCostId @@ -36,6 +38,8 @@ class LicensedItemPurchaseGet(BaseModel): "licensed_item_purchase_id": "beb16d18-d57d-44aa-a638-9727fa4a72ef", "product_name": "osparc", "licensed_item_id": "303942ef-6d31-4ba8-afbe-dbb1fce2a953", + "key": "Duke", + "version": "1.0.0", "wallet_id": 1, "wallet_name": "My Wallet", "pricing_unit_cost_id": 1, diff --git a/packages/models-library/src/models_library/api_schemas_webserver/licensed_items.py b/packages/models-library/src/models_library/api_schemas_webserver/licensed_items.py index 4b53737a73f9..2089fda37906 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/licensed_items.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/licensed_items.py @@ -11,6 +11,8 @@ FeaturesDict, LicensedItem, LicensedItemID, + LicensedItemKey, + LicensedItemVersion, LicensedResourceType, ) from ._base import OutputSchema @@ -20,9 +22,11 @@ class LicensedItemRpcGet(BaseModel): licensed_item_id: LicensedItemID + key: LicensedItemKey + version: LicensedItemVersion display_name: str licensed_resource_type: LicensedResourceType - licensed_resource_data: dict[str, Any] + licensed_resources: list[dict[str, Any]] pricing_plan_id: PricingPlanId created_at: datetime modified_at: datetime @@ -32,9 +36,11 @@ class LicensedItemRpcGet(BaseModel): "examples": [ { "licensed_item_id": "0362b88b-91f8-4b41-867c-35544ad1f7a1", + "key": "Duke", + "version": "1.0.0", "display_name": "best-model", "licensed_resource_type": f"{LicensedResourceType.VIP_MODEL}", - "licensed_resource_data": cast(JsonDict, VIP_DETAILS_EXAMPLE), + "licensed_resources": [cast(JsonDict, VIP_DETAILS_EXAMPLE)], "pricing_plan_id": "15", "created_at": "2024-12-12 09:59:26.422140", "modified_at": "2024-12-12 09:59:26.422140", @@ -58,24 +64,28 @@ class _ItisVipRestData(OutputSchema): thumbnail: str features: FeaturesDict # NOTE: here there is a bit of coupling with domain model doi: str | None + license_version: str class _ItisVipResourceRestData(OutputSchema): - category_id: IDStr - category_display: str - category_icon: HttpUrl | None = None # NOTE: Placeholder until provide @odeimaiz source: _ItisVipRestData - terms_of_use_url: HttpUrl | None = None # NOTE: Placeholder until provided @mguidon class LicensedItemRestGet(OutputSchema): licensed_item_id: LicensedItemID + key: LicensedItemKey + version: LicensedItemVersion + display_name: str - # NOTE: to put here a discriminator we have to embed it one more layer licensed_resource_type: LicensedResourceType - licensed_resource_data: _ItisVipResourceRestData + licensed_resources: list[_ItisVipResourceRestData] pricing_plan_id: PricingPlanId + category_id: IDStr + category_display: str + category_icon: HttpUrl | None = None # NOTE: Placeholder until provide @odeimaiz + terms_of_use_url: HttpUrl | None = None # NOTE: Placeholder until provided @mguidon + created_at: datetime modified_at: datetime @@ -86,17 +96,21 @@ def _update_json_schema_extra(schema: JsonDict) -> None: "examples": [ { "licensedItemId": "0362b88b-91f8-4b41-867c-35544ad1f7a1", + "key": "Duke", + "version": "1.0.0", "displayName": "my best model", "licensedResourceType": f"{LicensedResourceType.VIP_MODEL}", - "licensedResourceData": cast( - JsonDict, - { - "categoryId": "HumanWholeBody", - "categoryDisplay": "Humans", - "source": {**VIP_DETAILS_EXAMPLE, "doi": doi}, - }, - ), + "licensedResources": [ + cast( + JsonDict, + { + "source": {**VIP_DETAILS_EXAMPLE, "doi": doi}, + }, + ) + ], "pricingPlanId": "15", + "categoryId": "HumanWholeBody", + "categoryDisplay": "Humans", "createdAt": "2024-12-12 09:59:26.422140", "modifiedAt": "2024-12-12 09:59:26.422140", } @@ -114,6 +128,8 @@ def from_domain_model(cls, item: LicensedItem) -> Self: **item.model_dump( include={ "licensed_item_id", + "key", + "version", "display_name", "licensed_resource_type", "pricing_plan_id", @@ -122,9 +138,18 @@ def from_domain_model(cls, item: LicensedItem) -> Self: }, exclude_unset=True, ), - "licensed_resource_data": { - **item.licensed_resource_data, - }, + "licensed_resources": [ + _ItisVipResourceRestData(**x) + for x in sorted( + item.licensed_resources, + key=lambda x: datetime.strptime( + x["source"]["features"]["date"], "%Y-%m-%d" + ), + reverse=True, + ) + ], + "category_id": item.licensed_resources[0]["category_id"], + "category_display": item.licensed_resources[0]["category_display"], } ) diff --git a/packages/models-library/src/models_library/api_schemas_webserver/licensed_items_checkouts.py b/packages/models-library/src/models_library/api_schemas_webserver/licensed_items_checkouts.py index 38e6a0025f82..38e1f11ba288 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/licensed_items_checkouts.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/licensed_items_checkouts.py @@ -4,7 +4,7 @@ from models_library.emails import LowerCaseEmailStr from pydantic import BaseModel, ConfigDict, PositiveInt -from ..licenses import LicensedItemID +from ..licenses import LicensedItemID, LicensedItemKey, LicensedItemVersion from ..products import ProductName from ..resource_tracker_licensed_items_checkouts import LicensedItemCheckoutID from ..users import UserID @@ -17,6 +17,8 @@ class LicensedItemCheckoutRpcGet(BaseModel): licensed_item_checkout_id: LicensedItemCheckoutID licensed_item_id: LicensedItemID + key: LicensedItemKey + version: LicensedItemVersion wallet_id: WalletID user_id: UserID product_name: ProductName @@ -29,6 +31,8 @@ class LicensedItemCheckoutRpcGet(BaseModel): { "licensed_item_checkout_id": "633ef980-6f3e-4b1a-989a-bd77bf9a5d6b", "licensed_item_id": "0362b88b-91f8-4b41-867c-35544ad1f7a1", + "key": "Duke", + "version": "1.0.0", "wallet_id": 6, "user_id": 27845, "product_name": "osparc", @@ -52,6 +56,8 @@ class LicensedItemCheckoutRpcGetPage(NamedTuple): class LicensedItemCheckoutRestGet(OutputSchema): licensed_item_checkout_id: LicensedItemCheckoutID licensed_item_id: LicensedItemID + key: str + version: str wallet_id: WalletID user_id: UserID user_email: LowerCaseEmailStr 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 67f6cdd67405..139df916b257 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 @@ -5,7 +5,7 @@ from models_library.emails import LowerCaseEmailStr from pydantic import PositiveInt -from ..licenses import LicensedItemID +from ..licenses import LicensedItemID, LicensedItemKey, LicensedItemVersion from ..products import ProductName from ..resource_tracker import PricingUnitCostId from ..resource_tracker_licensed_items_purchases import LicensedItemPurchaseID @@ -18,6 +18,8 @@ class LicensedItemPurchaseGet(OutputSchema): licensed_item_purchase_id: LicensedItemPurchaseID product_name: ProductName licensed_item_id: LicensedItemID + key: LicensedItemKey + version: LicensedItemVersion wallet_id: WalletID pricing_unit_cost_id: PricingUnitCostId pricing_unit_cost: Decimal diff --git a/packages/models-library/src/models_library/licenses.py b/packages/models-library/src/models_library/licenses.py index 40451ad3f719..ec3ac85e189a 100644 --- a/packages/models-library/src/models_library/licenses.py +++ b/packages/models-library/src/models_library/licenses.py @@ -1,10 +1,10 @@ from datetime import datetime from enum import auto -from typing import Any, NamedTuple, NotRequired, TypeAlias, cast +from typing import Annotated, Any, NamedTuple, NewType, NotRequired, TypeAlias, cast from uuid import UUID from models_library.resource_tracker import PricingPlanId -from pydantic import BaseModel, ConfigDict, PositiveInt +from pydantic import BaseModel, ConfigDict, PositiveInt, StringConstraints from pydantic.config import JsonDict from typing_extensions import TypedDict @@ -15,6 +15,12 @@ LicensedItemID: TypeAlias = UUID LicensedResourceID: TypeAlias = UUID +LICENSED_ITEM_VERSION_RE = r"^\d+\.\d+\.\d+$" +LicensedItemKey = NewType("LicensedItemKey", str) +LicensedItemVersion = Annotated[ + str, StringConstraints(pattern=LICENSED_ITEM_VERSION_RE) +] + class LicensedResourceType(StrAutoEnum): VIP_MODEL = auto() @@ -69,26 +75,23 @@ class LicensedItemDB(BaseModel): licensed_item_id: LicensedItemID display_name: str - licensed_resource_name: str + key: LicensedItemKey + version: LicensedItemVersion licensed_resource_type: LicensedResourceType - licensed_resource_data: dict[str, Any] | None - pricing_plan_id: PricingPlanId | None - product_name: ProductName | None + pricing_plan_id: PricingPlanId + product_name: ProductName # states created: datetime modified: datetime - trashed: datetime | None model_config = ConfigDict(from_attributes=True) -class LicensedItemUpdateDB(BaseModel): +class LicensedItemPatchDB(BaseModel): display_name: str | None = None - licensed_resource_name: str | None = None pricing_plan_id: PricingPlanId | None = None - trash: bool | None = None class LicensedResourceDB(BaseModel): @@ -115,10 +118,11 @@ class LicensedResourcePatchDB(BaseModel): class LicensedItem(BaseModel): licensed_item_id: LicensedItemID + key: LicensedItemKey + version: LicensedItemVersion display_name: str - licensed_resource_name: str licensed_resource_type: LicensedResourceType - licensed_resource_data: dict[str, Any] + licensed_resources: list[dict[str, Any]] pricing_plan_id: PricingPlanId created_at: datetime modified_at: datetime @@ -130,17 +134,20 @@ def _update_json_schema_extra(schema: JsonDict) -> None: "examples": [ { "licensed_item_id": "0362b88b-91f8-4b41-867c-35544ad1f7a1", + "key": "Duke", + "version": "1.0.0", "display_name": "my best model", - "licensed_resource_name": "best-model", "licensed_resource_type": f"{LicensedResourceType.VIP_MODEL}", - "licensed_resource_data": cast( - JsonDict, - { - "category_id": "HumanWholeBody", - "category_display": "Humans", - "source": VIP_DETAILS_EXAMPLE, - }, - ), + "licensed_resources": [ + cast( + JsonDict, + { + "category_id": "HumanWholeBody", + "category_display": "Humans", + "source": VIP_DETAILS_EXAMPLE, + }, + ) + ], "pricing_plan_id": "15", "created_at": "2024-12-12 09:59:26.422140", "modified_at": "2024-12-12 09:59:26.422140", 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 bc0eecfa3812..1ea79606965c 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 @@ -5,7 +5,7 @@ from pydantic import BaseModel, ConfigDict -from .licenses import LicensedItemID +from .licenses import LicensedItemID, LicensedItemKey, LicensedItemVersion from .products import ProductName from .resource_tracker import PricingPlanId, PricingUnitCostId, PricingUnitId from .users import UserID @@ -17,6 +17,8 @@ class LicensedItemsPurchasesCreate(BaseModel): product_name: ProductName licensed_item_id: LicensedItemID + key: LicensedItemKey + version: LicensedItemVersion wallet_id: WalletID wallet_name: str pricing_plan_id: PricingPlanId diff --git a/packages/models-library/tests/test_licenses.py b/packages/models-library/tests/test_licenses.py index 19c00efa7152..67e4bff9c11c 100644 --- a/packages/models-library/tests/test_licenses.py +++ b/packages/models-library/tests/test_licenses.py @@ -13,21 +13,21 @@ def test_licensed_item_from_domain_model(): # nullable doi assert ( - got.licensed_resource_data.source.doi - == item.licensed_resource_data["source"]["doi"] + got.licensed_resources[0].source.doi + == item.licensed_resources[0]["source"]["doi"] ) # date is required - assert got.licensed_resource_data.source.features["date"] + assert got.licensed_resources[0].source.features["date"] # id is required assert ( - got.licensed_resource_data.source.id - == item.licensed_resource_data["source"]["id"] + got.licensed_resources[0].source.id + == item.licensed_resources[0]["source"]["id"] ) # checks unset fields - assert "category_icon" not in got.licensed_resource_data.model_fields_set + assert "category_icon" not in got.licensed_resources[0].model_fields_set def test_strict_check_of_examples(): diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/a53c3c153bc8_modify_licensed_items_resources_db.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/a53c3c153bc8_modify_licensed_items_resources_db.py new file mode 100644 index 000000000000..3f07cd80ebaa --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/a53c3c153bc8_modify_licensed_items_resources_db.py @@ -0,0 +1,159 @@ +"""modify licensed items/resources DB + +Revision ID: a53c3c153bc8 +Revises: 78f24aaf3f78 +Create Date: 2025-02-13 10:13:32.817207+00:00 + +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "a53c3c153bc8" +down_revision = "78f24aaf3f78" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "licensed_item_to_resource", + sa.Column("licensed_item_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column( + "licensed_resource_id", postgresql.UUID(as_uuid=True), nullable=False + ), + sa.Column( + "created", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "modified", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["licensed_item_id"], + ["licensed_items.licensed_item_id"], + name="fk_licensed_item_to_resource_licensed_item_id", + onupdate="CASCADE", + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["licensed_resource_id"], + ["licensed_resources.licensed_resource_id"], + name="fk_licensed_item_to_resource_licensed_resource_id", + onupdate="CASCADE", + ondelete="CASCADE", + ), + ) + op.add_column("licensed_items", sa.Column("key", sa.String(), nullable=False)) + op.add_column("licensed_items", sa.Column("version", sa.String(), nullable=False)) + op.alter_column( + "licensed_items", "pricing_plan_id", existing_type=sa.BIGINT(), nullable=False + ) + op.alter_column( + "licensed_items", "product_name", existing_type=sa.VARCHAR(), nullable=False + ) + op.drop_constraint( + "uq_licensed_resource_name_type", "licensed_items", type_="unique" + ) + op.create_index( + "idx_licensed_items_key_version", + "licensed_items", + ["key", "version"], + unique=True, + ) + op.drop_column("licensed_items", "licensed_resource_data") + op.drop_column("licensed_items", "trashed") + op.drop_column("licensed_items", "licensed_resource_name") + op.add_column( + "resource_tracker_licensed_items_checkouts", + sa.Column("key", sa.String(), nullable=False), + ) + op.add_column( + "resource_tracker_licensed_items_checkouts", + sa.Column("version", sa.String(), nullable=False), + ) + op.create_index( + "idx_licensed_items_checkouts_key_version", + "resource_tracker_licensed_items_checkouts", + ["key", "version"], + unique=False, + ) + op.add_column( + "resource_tracker_licensed_items_purchases", + sa.Column("key", sa.String(), nullable=False), + ) + op.add_column( + "resource_tracker_licensed_items_purchases", + sa.Column("version", sa.String(), nullable=False), + ) + op.create_index( + "idx_licensed_items_purchases_key_version", + "resource_tracker_licensed_items_purchases", + ["key", "version"], + unique=False, + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index( + "idx_licensed_items_purchases_key_version", + table_name="resource_tracker_licensed_items_purchases", + ) + op.drop_column("resource_tracker_licensed_items_purchases", "version") + op.drop_column("resource_tracker_licensed_items_purchases", "key") + op.drop_index( + "idx_licensed_items_checkouts_key_version", + table_name="resource_tracker_licensed_items_checkouts", + ) + op.drop_column("resource_tracker_licensed_items_checkouts", "version") + op.drop_column("resource_tracker_licensed_items_checkouts", "key") + op.add_column( + "licensed_items", + sa.Column( + "licensed_resource_name", sa.VARCHAR(), autoincrement=False, nullable=False + ), + ) + op.add_column( + "licensed_items", + sa.Column( + "trashed", + postgresql.TIMESTAMP(timezone=True), + autoincrement=False, + nullable=True, + comment="The date and time when the licensed_item was marked as trashed. Null if the licensed_item has not been trashed [default].", + ), + ) + op.add_column( + "licensed_items", + sa.Column( + "licensed_resource_data", + postgresql.JSONB(astext_type=sa.Text()), + autoincrement=False, + nullable=True, + ), + ) + op.drop_index("idx_licensed_items_key_version", table_name="licensed_items") + op.create_unique_constraint( + "uq_licensed_resource_name_type", + "licensed_items", + ["licensed_resource_name", "licensed_resource_type"], + ) + op.alter_column( + "licensed_items", "product_name", existing_type=sa.VARCHAR(), nullable=True + ) + op.alter_column( + "licensed_items", "pricing_plan_id", existing_type=sa.BIGINT(), nullable=True + ) + op.drop_column("licensed_items", "version") + op.drop_column("licensed_items", "key") + op.drop_table("licensed_item_to_resource") + # ### end Alembic commands ### diff --git a/packages/postgres-database/src/simcore_postgres_database/models/licensed_item_to_resource.py b/packages/postgres-database/src/simcore_postgres_database/models/licensed_item_to_resource.py new file mode 100644 index 000000000000..28eb8ff6955f --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/models/licensed_item_to_resource.py @@ -0,0 +1,34 @@ +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from ._common import RefActions, column_created_datetime, column_modified_datetime +from .base import metadata + +licensed_item_to_resource = sa.Table( + "licensed_item_to_resource", + metadata, + sa.Column( + "licensed_item_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey( + "licensed_items.licensed_item_id", + name="fk_licensed_item_to_resource_licensed_item_id", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, + ), + nullable=False, + ), + sa.Column( + "licensed_resource_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey( + "licensed_resources.licensed_resource_id", + name="fk_licensed_item_to_resource_licensed_resource_id", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, + ), + nullable=False, + ), + column_created_datetime(timezone=True), + column_modified_datetime(timezone=True), +) diff --git a/packages/postgres-database/src/simcore_postgres_database/models/licensed_items.py b/packages/postgres-database/src/simcore_postgres_database/models/licensed_items.py index f625a2d1be6d..6b50e062eaef 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/licensed_items.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/licensed_items.py @@ -6,12 +6,7 @@ import sqlalchemy as sa from sqlalchemy.dialects import postgresql -from ._common import ( - RefActions, - column_created_datetime, - column_modified_datetime, - column_trashed_datetime, -) +from ._common import RefActions, column_created_datetime, column_modified_datetime from .base import metadata @@ -30,16 +25,20 @@ class LicensedResourceType(str, enum.Enum): server_default=sa.text("gen_random_uuid()"), ), sa.Column( - "display_name", + "key", sa.String, nullable=False, - doc="Display name for front-end", ), sa.Column( - "licensed_resource_name", + "version", sa.String, nullable=False, - doc="Resource name identifier", + ), + sa.Column( + "display_name", + sa.String, + nullable=False, + doc="Display name for front-end", ), sa.Column( "licensed_resource_type", @@ -47,12 +46,6 @@ class LicensedResourceType(str, enum.Enum): nullable=False, doc="Resource type, ex. VIP_MODEL", ), - sa.Column( - "licensed_resource_data", - postgresql.JSONB, - nullable=True, - doc="Resource metadata. Used for read-only purposes", - ), sa.Column( "pricing_plan_id", sa.BigInteger, @@ -62,7 +55,7 @@ class LicensedResourceType(str, enum.Enum): onupdate=RefActions.CASCADE, ondelete=RefActions.RESTRICT, ), - nullable=True, + nullable=False, ), sa.Column( "product_name", @@ -73,15 +66,10 @@ class LicensedResourceType(str, enum.Enum): ondelete=RefActions.CASCADE, name="fk_resource_tracker_license_packages_product_name", ), - nullable=True, + nullable=False, doc="Product name identifier. If None, then the item is not exposed", ), column_created_datetime(timezone=True), column_modified_datetime(timezone=True), - column_trashed_datetime("licensed_item"), - sa.UniqueConstraint( - "licensed_resource_name", - "licensed_resource_type", - name="uq_licensed_resource_name_type", - ), + sa.Index("idx_licensed_items_key_version", "key", "version", unique=True), ) diff --git a/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_licensed_items_checkouts.py b/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_licensed_items_checkouts.py index a1d90d3c6ee6..91da15393720 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_licensed_items_checkouts.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_licensed_items_checkouts.py @@ -22,6 +22,16 @@ UUID(as_uuid=True), nullable=True, ), + sa.Column( + "key", + sa.String, + nullable=False, + ), + sa.Column( + "version", + sa.String, + nullable=False, + ), sa.Column( "wallet_id", sa.BigInteger, @@ -73,4 +83,5 @@ onupdate=RefActions.CASCADE, ondelete=RefActions.RESTRICT, ), + sa.Index("idx_licensed_items_checkouts_key_version", "key", "version"), ) 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 451b54046643..8e09f322c737 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 @@ -29,6 +29,16 @@ UUID(as_uuid=True), nullable=False, ), + sa.Column( + "key", + sa.String, + nullable=False, + ), + sa.Column( + "version", + sa.String, + nullable=False, + ), sa.Column( "wallet_id", sa.BigInteger, @@ -84,4 +94,5 @@ server_default=sa.sql.func.now(), ), column_modified_datetime(timezone=True), + sa.Index("idx_licensed_items_purchases_key_version", "key", "version"), ) diff --git a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/licensed_items_checkouts.py b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/licensed_items_checkouts.py index e6d43c47cafa..5203fb9d2d52 100644 --- a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/licensed_items_checkouts.py +++ b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/licensed_items_checkouts.py @@ -85,6 +85,8 @@ async def checkout_licensed_item( rabbitmq_rpc_client: RabbitMQRPCClient, *, licensed_item_id: LicensedItemID, + key: str, + version: str, wallet_id: WalletID, product_name: ProductName, num_of_seats: int, @@ -96,6 +98,8 @@ async def checkout_licensed_item( RESOURCE_USAGE_TRACKER_RPC_NAMESPACE, _RPC_METHOD_NAME_ADAPTER.validate_python("checkout_licensed_item"), licensed_item_id=licensed_item_id, + key=key, + version=version, wallet_id=wallet_id, product_name=product_name, num_of_seats=num_of_seats, diff --git a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/licenses/licensed_items.py b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/licenses/licensed_items.py index fec70de9e8c5..acb367de27b0 100644 --- a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/licenses/licensed_items.py +++ b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/licenses/licensed_items.py @@ -79,10 +79,10 @@ async def checkout_licensed_item_for_wallet( result = await rabbitmq_rpc_client.request( WEBSERVER_RPC_NAMESPACE, TypeAdapter(RPCMethodName).validate_python("checkout_licensed_item_for_wallet"), + licensed_item_id=licensed_item_id, product_name=product_name, user_id=user_id, wallet_id=wallet_id, - licensed_item_id=licensed_item_id, num_of_seats=num_of_seats, service_run_id=service_run_id, ) diff --git a/services/api-server/openapi.json b/services/api-server/openapi.json index 6ff7492b63b0..c5c091362845 100644 --- a/services/api-server/openapi.json +++ b/services/api-server/openapi.json @@ -6618,6 +6618,15 @@ "format": "uuid", "title": "Licensed Item Id" }, + "key": { + "type": "string", + "title": "Key" + }, + "version": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+$", + "title": "Version" + }, "wallet_id": { "type": "integer", "exclusiveMinimum": true, @@ -6660,6 +6669,8 @@ "required": [ "licensed_item_checkout_id", "licensed_item_id", + "key", + "version", "wallet_id", "user_id", "product_name", @@ -6676,6 +6687,15 @@ "format": "uuid", "title": "Licensed Item Id" }, + "key": { + "type": "string", + "title": "Key" + }, + "version": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+$", + "title": "Version" + }, "display_name": { "type": "string", "title": "Display Name" @@ -6683,9 +6703,12 @@ "licensed_resource_type": { "$ref": "#/components/schemas/LicensedResourceType" }, - "licensed_resource_data": { - "type": "object", - "title": "Licensed Resource Data" + "licensed_resources": { + "items": { + "type": "object" + }, + "type": "array", + "title": "Licensed Resources" }, "pricing_plan_id": { "type": "integer", @@ -6707,9 +6730,11 @@ "type": "object", "required": [ "licensed_item_id", + "key", + "version", "display_name", "licensed_resource_type", - "licensed_resource_data", + "licensed_resources", "pricing_plan_id", "created_at", "modified_at" diff --git a/services/api-server/src/simcore_service_api_server/models/schemas/model_adapter.py b/services/api-server/src/simcore_service_api_server/models/schemas/model_adapter.py index aac9886b6e34..28e5b85c3aa8 100644 --- a/services/api-server/src/simcore_service_api_server/models/schemas/model_adapter.py +++ b/services/api-server/src/simcore_service_api_server/models/schemas/model_adapter.py @@ -24,7 +24,12 @@ ) from models_library.basic_types import IDStr, NonNegativeDecimal from models_library.groups import GroupID -from models_library.licenses import LicensedItemID, LicensedResourceType +from models_library.licenses import ( + LicensedItemID, + LicensedItemKey, + LicensedItemVersion, + LicensedResourceType, +) from models_library.products import ProductName from models_library.resource_tracker import ( PricingPlanClassification, @@ -137,9 +142,11 @@ class ServicePricingPlanGetLegacy(BaseModel): class LicensedItemGet(BaseModel): licensed_item_id: LicensedItemID + key: LicensedItemKey + version: LicensedItemVersion display_name: str licensed_resource_type: LicensedResourceType - licensed_resource_data: dict[str, Any] + licensed_resources: list[dict[str, Any]] pricing_plan_id: PricingPlanId created_at: datetime modified_at: datetime @@ -156,6 +163,8 @@ class LicensedItemGet(BaseModel): class LicensedItemCheckoutGet(BaseModel): licensed_item_checkout_id: LicensedItemCheckoutID licensed_item_id: LicensedItemID + key: LicensedItemKey + version: LicensedItemVersion wallet_id: WalletID user_id: UserID product_name: ProductName diff --git a/services/api-server/src/simcore_service_api_server/services_rpc/resource_usage_tracker.py b/services/api-server/src/simcore_service_api_server/services_rpc/resource_usage_tracker.py index 371263a98807..82e3ea1d3692 100644 --- a/services/api-server/src/simcore_service_api_server/services_rpc/resource_usage_tracker.py +++ b/services/api-server/src/simcore_service_api_server/services_rpc/resource_usage_tracker.py @@ -44,6 +44,8 @@ async def get_licensed_item_checkout( return LicensedItemCheckoutGet( licensed_item_checkout_id=_licensed_item_checkout.licensed_item_checkout_id, licensed_item_id=_licensed_item_checkout.licensed_item_id, + key=_licensed_item_checkout.key, + version=_licensed_item_checkout.version, wallet_id=_licensed_item_checkout.wallet_id, user_id=_licensed_item_checkout.user_id, product_name=_licensed_item_checkout.product_name, diff --git a/services/api-server/src/simcore_service_api_server/services_rpc/wb_api_server.py b/services/api-server/src/simcore_service_api_server/services_rpc/wb_api_server.py index 0165a0e01485..b568ea17a430 100644 --- a/services/api-server/src/simcore_service_api_server/services_rpc/wb_api_server.py +++ b/services/api-server/src/simcore_service_api_server/services_rpc/wb_api_server.py @@ -58,9 +58,11 @@ def _create_licensed_items_get_page( [ LicensedItemGet( licensed_item_id=elm.licensed_item_id, + key=elm.key, + version=elm.version, display_name=elm.display_name, licensed_resource_type=elm.licensed_resource_type, - licensed_resource_data=elm.licensed_resource_data, + licensed_resources=elm.licensed_resources, pricing_plan_id=elm.pricing_plan_id, created_at=elm.created_at, modified_at=elm.modified_at, @@ -118,6 +120,7 @@ async def get_available_licensed_items_for_wallet( NotEnoughAvailableSeatsError: InsufficientNumberOfSeatsError, CanNotCheckoutNotEnoughAvailableSeatsError: InsufficientNumberOfSeatsError, _CanNotCheckoutServiceIsNotRunningError: CanNotCheckoutServiceIsNotRunningError, + # NOTE: missing WalletAccessForbiddenError } ) async def checkout_licensed_item_for_wallet( @@ -142,6 +145,8 @@ async def checkout_licensed_item_for_wallet( return LicensedItemCheckoutGet( licensed_item_checkout_id=licensed_item_checkout_get.licensed_item_checkout_id, licensed_item_id=licensed_item_checkout_get.licensed_item_id, + key=licensed_item_checkout_get.key, + version=licensed_item_checkout_get.version, wallet_id=licensed_item_checkout_get.wallet_id, user_id=licensed_item_checkout_get.user_id, product_name=licensed_item_checkout_get.product_name, @@ -171,6 +176,8 @@ async def release_licensed_item_for_wallet( return LicensedItemCheckoutGet( licensed_item_checkout_id=licensed_item_checkout_get.licensed_item_checkout_id, licensed_item_id=licensed_item_checkout_get.licensed_item_id, + key=licensed_item_checkout_get.key, + version=licensed_item_checkout_get.version, wallet_id=licensed_item_checkout_get.wallet_id, user_id=licensed_item_checkout_get.user_id, product_name=licensed_item_checkout_get.product_name, diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_licensed_items_checkouts.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_licensed_items_checkouts.py index 3ab6e5a4e618..859b501d4bdd 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_licensed_items_checkouts.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_licensed_items_checkouts.py @@ -3,7 +3,7 @@ LicensedItemCheckoutGet, LicensedItemsCheckoutsPage, ) -from models_library.licenses import LicensedItemID +from models_library.licenses import LicensedItemID, LicensedItemKey, LicensedItemVersion from models_library.products import ProductName from models_library.resource_tracker_licensed_items_checkouts import ( LicensedItemCheckoutID, @@ -62,6 +62,8 @@ async def checkout_licensed_item( app: FastAPI, *, licensed_item_id: LicensedItemID, + key: LicensedItemKey, + version: LicensedItemVersion, wallet_id: WalletID, product_name: ProductName, num_of_seats: int, @@ -72,6 +74,8 @@ async def checkout_licensed_item( return await licensed_items_checkouts.checkout_licensed_item( db_engine=app.state.engine, licensed_item_id=licensed_item_id, + key=key, + version=version, wallet_id=wallet_id, product_name=product_name, num_of_seats=num_of_seats, diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/models/licensed_items_checkouts.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/models/licensed_items_checkouts.py index 8f2d2c1371ae..8dd1ff5e929d 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/models/licensed_items_checkouts.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/models/licensed_items_checkouts.py @@ -1,6 +1,6 @@ from datetime import datetime -from models_library.licenses import LicensedItemID +from models_library.licenses import LicensedItemID, LicensedItemKey, LicensedItemVersion from models_library.products import ProductName from models_library.resource_tracker_licensed_items_checkouts import ( LicensedItemCheckoutID, @@ -14,6 +14,8 @@ class LicensedItemCheckoutDB(BaseModel): licensed_item_checkout_id: LicensedItemCheckoutID licensed_item_id: LicensedItemID + key: LicensedItemKey + version: LicensedItemVersion wallet_id: WalletID user_id: UserID user_email: str @@ -29,6 +31,8 @@ class LicensedItemCheckoutDB(BaseModel): class CreateLicensedItemCheckoutDB(BaseModel): licensed_item_id: LicensedItemID + key: LicensedItemKey + version: LicensedItemVersion wallet_id: WalletID user_id: UserID user_email: str 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 c5a752dafb92..dd23b87e4e8c 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 @@ -2,7 +2,7 @@ from decimal import Decimal from models_library.emails import LowerCaseEmailStr -from models_library.licenses import LicensedItemID +from models_library.licenses import LicensedItemID, LicensedItemKey, LicensedItemVersion from models_library.products import ProductName from models_library.resource_tracker import PricingUnitCostId from models_library.resource_tracker_licensed_items_purchases import ( @@ -17,6 +17,8 @@ class LicensedItemsPurchasesDB(BaseModel): licensed_item_purchase_id: LicensedItemPurchaseID product_name: ProductName licensed_item_id: LicensedItemID + key: LicensedItemKey + version: LicensedItemVersion wallet_id: WalletID wallet_name: str pricing_unit_cost_id: PricingUnitCostId @@ -35,6 +37,8 @@ class LicensedItemsPurchasesDB(BaseModel): class CreateLicensedItemsPurchasesDB(BaseModel): product_name: ProductName licensed_item_id: LicensedItemID + key: LicensedItemKey + version: LicensedItemVersion wallet_id: WalletID wallet_name: str pricing_unit_cost_id: PricingUnitCostId diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/licensed_items_checkouts.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/licensed_items_checkouts.py index 8ccc4d8c2374..549118884a9b 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/licensed_items_checkouts.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/licensed_items_checkouts.py @@ -6,7 +6,7 @@ LicensedItemCheckoutGet, LicensedItemsCheckoutsPage, ) -from models_library.licenses import LicensedItemID +from models_library.licenses import LicensedItemID, LicensedItemKey, LicensedItemVersion from models_library.products import ProductName from models_library.resource_tracker import ServiceRunStatus from models_library.resource_tracker_licensed_items_checkouts import ( @@ -58,6 +58,8 @@ async def list_licensed_items_checkouts( LicensedItemCheckoutGet( licensed_item_checkout_id=licensed_item_checkout_db.licensed_item_checkout_id, licensed_item_id=licensed_item_checkout_db.licensed_item_id, + key=licensed_item_checkout_db.key, + version=licensed_item_checkout_db.version, wallet_id=licensed_item_checkout_db.wallet_id, user_id=licensed_item_checkout_db.user_id, user_email=licensed_item_checkout_db.user_email, @@ -89,6 +91,8 @@ async def get_licensed_item_checkout( return LicensedItemCheckoutGet( licensed_item_checkout_id=licensed_item_checkout_db.licensed_item_checkout_id, licensed_item_id=licensed_item_checkout_db.licensed_item_id, + key=licensed_item_checkout_db.key, + version=licensed_item_checkout_db.version, wallet_id=licensed_item_checkout_db.wallet_id, user_id=licensed_item_checkout_db.user_id, user_email=licensed_item_checkout_db.user_email, @@ -104,6 +108,8 @@ async def checkout_licensed_item( db_engine: Annotated[AsyncEngine, Depends(get_resource_tracker_db_engine)], *, licensed_item_id: LicensedItemID, + key: LicensedItemKey, + version: LicensedItemVersion, wallet_id: WalletID, product_name: ProductName, num_of_seats: int, @@ -112,20 +118,20 @@ async def checkout_licensed_item( user_email: str, ) -> LicensedItemCheckoutGet: - _active_purchased_seats: int = await licensed_items_purchases_db.get_active_purchased_seats_for_item_and_wallet( + _active_purchased_seats: int = await licensed_items_purchases_db.get_active_purchased_seats_for_key_version_wallet( db_engine, - licensed_item_id=licensed_item_id, + key=key, + version=version, wallet_id=wallet_id, product_name=product_name, ) - _currently_used_seats = ( - await licensed_items_checkouts_db.get_currently_used_seats_for_item_and_wallet( - db_engine, - licensed_item_id=licensed_item_id, - wallet_id=wallet_id, - product_name=product_name, - ) + _currently_used_seats = await licensed_items_checkouts_db.get_currently_used_seats_for_key_version_wallet( + db_engine, + key=key, + version=version, + wallet_id=wallet_id, + product_name=product_name, ) available_seats = _active_purchased_seats - _currently_used_seats @@ -155,6 +161,8 @@ async def checkout_licensed_item( _create_item_checkout = CreateLicensedItemCheckoutDB( licensed_item_id=licensed_item_id, + key=key, + version=version, wallet_id=wallet_id, user_id=user_id, user_email=user_email, @@ -171,6 +179,8 @@ async def checkout_licensed_item( return LicensedItemCheckoutGet( licensed_item_checkout_id=licensed_item_checkout_db.licensed_item_checkout_id, licensed_item_id=licensed_item_checkout_db.licensed_item_id, + key=licensed_item_checkout_db.key, + version=licensed_item_checkout_db.version, wallet_id=licensed_item_checkout_db.wallet_id, user_id=licensed_item_checkout_db.user_id, user_email=licensed_item_checkout_db.user_email, @@ -201,6 +211,8 @@ async def release_licensed_item( return LicensedItemCheckoutGet( licensed_item_checkout_id=licensed_item_checkout_db.licensed_item_checkout_id, licensed_item_id=licensed_item_checkout_db.licensed_item_id, + key=licensed_item_checkout_db.key, + version=licensed_item_checkout_db.version, wallet_id=licensed_item_checkout_db.wallet_id, user_id=licensed_item_checkout_db.user_id, user_email=licensed_item_checkout_db.user_email, 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 f085de184060..0e5c7abef81f 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 @@ -54,6 +54,8 @@ async def list_licensed_items_purchases( 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, + key=licensed_item_purchase_db.key, + version=licensed_item_purchase_db.version, 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, @@ -89,6 +91,8 @@ async def get_licensed_item_purchase( 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, + key=licensed_item_purchase_db.key, + version=licensed_item_purchase_db.version, 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, @@ -114,6 +118,8 @@ async def create_licensed_item_purchase( item_purchase_create = CreateLicensedItemsPurchasesDB( product_name=data.product_name, licensed_item_id=data.licensed_item_id, + key=data.key, + version=data.version, wallet_id=data.wallet_id, wallet_name=data.wallet_name, pricing_unit_cost_id=data.pricing_unit_cost_id, @@ -167,6 +173,8 @@ async def create_licensed_item_purchase( 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, + key=licensed_item_purchase_db.key, + version=licensed_item_purchase_db.version, 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, diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/licensed_items_checkouts_db.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/licensed_items_checkouts_db.py index 244ecb892300..96d98359cb6b 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/licensed_items_checkouts_db.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/licensed_items_checkouts_db.py @@ -3,7 +3,6 @@ from typing import cast import sqlalchemy as sa -from models_library.licenses import LicensedItemID from models_library.products import ProductName from models_library.resource_tracker_licensed_items_checkouts import ( LicensedItemCheckoutID, @@ -28,6 +27,7 @@ CreateLicensedItemCheckoutDB, LicensedItemCheckoutDB, ) +from . import utils as db_utils _logger = logging.getLogger(__name__) @@ -35,6 +35,8 @@ _SELECTION_ARGS = ( resource_tracker_licensed_items_checkouts.c.licensed_item_checkout_id, resource_tracker_licensed_items_checkouts.c.licensed_item_id, + resource_tracker_licensed_items_checkouts.c.key, + resource_tracker_licensed_items_checkouts.c.version, resource_tracker_licensed_items_checkouts.c.wallet_id, resource_tracker_licensed_items_checkouts.c.user_id, resource_tracker_licensed_items_checkouts.c.user_email, @@ -62,6 +64,8 @@ async def create( resource_tracker_licensed_items_checkouts.insert() .values( licensed_item_id=data.licensed_item_id, + key=data.key, + version=data.version, wallet_id=data.wallet_id, user_id=data.user_id, user_email=data.user_email, @@ -107,12 +111,19 @@ async def list_( # Ordering and pagination if order_by.direction == OrderDirection.ASC: list_query = base_query.order_by( - sa.asc(getattr(resource_tracker_licensed_items_checkouts.c, order_by.field)) + sa.asc( + getattr(resource_tracker_licensed_items_checkouts.c, order_by.field) + ), + resource_tracker_licensed_items_checkouts.c.licensed_item_checkout_id, ) else: list_query = base_query.order_by( sa.desc( - getattr(resource_tracker_licensed_items_checkouts.c, order_by.field) + getattr( + resource_tracker_licensed_items_checkouts.c, + order_by.field, + resource_tracker_licensed_items_checkouts.c.licensed_item_checkout_id, + ) ) ) list_query = list_query.offset(offset).limit(limit) @@ -194,21 +205,25 @@ async def update( return LicensedItemCheckoutDB.model_validate(row) -async def get_currently_used_seats_for_item_and_wallet( +async def get_currently_used_seats_for_key_version_wallet( engine: AsyncEngine, connection: AsyncConnection | None = None, *, - licensed_item_id: LicensedItemID, + key: str, + version: str, wallet_id: WalletID, product_name: ProductName, ) -> int: + sum_stmt = sa.select( sa.func.sum(resource_tracker_licensed_items_checkouts.c.num_of_seats) ).where( (resource_tracker_licensed_items_checkouts.c.wallet_id == wallet_id) + & (resource_tracker_licensed_items_checkouts.c.key == key) + # If purchased version >= requested version, it covers that version & ( - resource_tracker_licensed_items_checkouts.c.licensed_item_id - == licensed_item_id + db_utils.version(resource_tracker_licensed_items_checkouts.c.version) + >= db_utils.version(version) ) & (resource_tracker_licensed_items_checkouts.c.product_name == product_name) & (resource_tracker_licensed_items_checkouts.c.stopped_at.is_(None)) 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 4c1daf581dbd..36cffd230a0f 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,7 +2,6 @@ from typing import cast import sqlalchemy as sa -from models_library.licenses import LicensedItemID from models_library.products import ProductName from models_library.resource_tracker_licensed_items_purchases import ( LicensedItemPurchaseID, @@ -24,11 +23,14 @@ CreateLicensedItemsPurchasesDB, LicensedItemsPurchasesDB, ) +from . import utils as db_utils _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.key, + resource_tracker_licensed_items_purchases.c.version, 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, @@ -59,6 +61,8 @@ async def create( .values( product_name=data.product_name, licensed_item_id=data.licensed_item_id, + key=data.key, + version=data.version, wallet_id=data.wallet_id, wallet_name=data.wallet_name, pricing_unit_cost_id=data.pricing_unit_cost_id, @@ -106,13 +110,17 @@ async def list_( # 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)) + sa.asc( + getattr(resource_tracker_licensed_items_purchases.c, order_by.field) + ), + resource_tracker_licensed_items_purchases.c.licensed_item_purchase_id, ) else: list_query = base_query.order_by( sa.desc( getattr(resource_tracker_licensed_items_purchases.c, order_by.field) - ) + ), + resource_tracker_licensed_items_purchases.c.licensed_item_purchase_id, ) list_query = list_query.offset(offset).limit(limit) @@ -158,11 +166,12 @@ async def get( return LicensedItemsPurchasesDB.model_validate(row) -async def get_active_purchased_seats_for_item_and_wallet( +async def get_active_purchased_seats_for_key_version_wallet( engine: AsyncEngine, connection: AsyncConnection | None = None, *, - licensed_item_id: LicensedItemID, + key: str, + version: str, wallet_id: WalletID, product_name: ProductName, ) -> int: @@ -175,9 +184,11 @@ async def get_active_purchased_seats_for_item_and_wallet( sa.func.sum(resource_tracker_licensed_items_purchases.c.num_of_seats) ).where( (resource_tracker_licensed_items_purchases.c.wallet_id == wallet_id) + & (resource_tracker_licensed_items_purchases.c.key == key) + # If purchased version >= requested version, it covers that version & ( - resource_tracker_licensed_items_purchases.c.licensed_item_id - == licensed_item_id + db_utils.version(resource_tracker_licensed_items_purchases.c.version) + >= db_utils.version(version) ) & (resource_tracker_licensed_items_purchases.c.product_name == product_name) & (resource_tracker_licensed_items_purchases.c.start_at <= _current_time) diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/pricing_plans_db.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/pricing_plans_db.py index b205fb039975..dca262a1fe89 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/pricing_plans_db.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/pricing_plans_db.py @@ -208,7 +208,10 @@ async def list_pricing_plans_by_product( count_query = sa.select(sa.func.count()).select_from(subquery) # Default ordering - list_query = base_query.order_by(resource_tracker_pricing_plans.c.created.asc()) + list_query = base_query.order_by( + resource_tracker_pricing_plans.c.created.asc(), + resource_tracker_pricing_plans.c.pricing_plan_id, + ) total_count = await conn.scalar(count_query) if total_count is None: diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/utils.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/utils.py new file mode 100644 index 000000000000..aa2c5d799268 --- /dev/null +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/utils.py @@ -0,0 +1,7 @@ +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import ARRAY, INTEGER + + +def version(column_or_value): + # converts version value string to array[integer] that can be compared + return sa.func.string_to_array(column_or_value, ".").cast(ARRAY(INTEGER)) diff --git a/services/resource-usage-tracker/tests/unit/with_dbs/test_api_licensed_items_checkouts.py b/services/resource-usage-tracker/tests/unit/with_dbs/test_api_licensed_items_checkouts.py index b1036c49aefd..95c15a38652b 100644 --- a/services/resource-usage-tracker/tests/unit/with_dbs/test_api_licensed_items_checkouts.py +++ b/services/resource-usage-tracker/tests/unit/with_dbs/test_api_licensed_items_checkouts.py @@ -22,9 +22,15 @@ licensed_items_checkouts, licensed_items_purchases, ) +from servicelib.rabbitmq.rpc_interfaces.resource_usage_tracker.errors import ( + NotEnoughAvailableSeatsError, +) from simcore_postgres_database.models.resource_tracker_licensed_items_checkouts import ( resource_tracker_licensed_items_checkouts, ) +from simcore_postgres_database.models.resource_tracker_licensed_items_purchases import ( + resource_tracker_licensed_items_purchases, +) from simcore_postgres_database.models.resource_tracker_service_runs import ( resource_tracker_service_runs, ) @@ -62,6 +68,7 @@ def resource_tracker_service_run_id( yield row[0] con.execute(resource_tracker_licensed_items_checkouts.delete()) + con.execute(resource_tracker_licensed_items_purchases.delete()) con.execute(resource_tracker_service_runs.delete()) @@ -83,6 +90,8 @@ async def test_rpc_licensed_items_checkouts_workflow( _create_data = LicensedItemsPurchasesCreate( product_name="osparc", licensed_item_id="beb16d18-d57d-44aa-a638-9727fa4a72ef", + key="Duke", + version="1.0.0", wallet_id=_WALLET_ID, wallet_name="My Wallet", pricing_plan_id=1, @@ -104,6 +113,8 @@ async def test_rpc_licensed_items_checkouts_workflow( checkout = await licensed_items_checkouts.checkout_licensed_item( rpc_client, licensed_item_id=created_item.licensed_item_id, + key=created_item.key, + version=created_item.version, wallet_id=_WALLET_ID, product_name="osparc", num_of_seats=3, @@ -137,3 +148,134 @@ async def test_rpc_licensed_items_checkouts_workflow( ) assert license_item_checkout assert isinstance(license_item_checkout.stopped_at, datetime) + + +async def test_rpc_licensed_items_checkouts_can_checkout_older_version( + mocked_redis_server: None, + resource_tracker_service_run_id: str, + rpc_client: RabbitMQRPCClient, +): + # List licensed items checkouts + output = await licensed_items_checkouts.get_licensed_items_checkouts_page( + rpc_client, + product_name="osparc", + filter_wallet_id=_WALLET_ID, + ) + assert output.total == 0 + assert output.items == [] + + # Purchase license item + _create_data = LicensedItemsPurchasesCreate( + product_name="osparc", + licensed_item_id="beb16d18-d57d-44aa-a638-9727fa4a72ef", + key="Duke", + version="2.0.0", + wallet_id=_WALLET_ID, + wallet_name="My Wallet", + pricing_plan_id=1, + pricing_unit_id=1, + pricing_unit_cost_id=1, + pricing_unit_cost=Decimal(10), + start_at=datetime.now(tz=UTC), + expire_at=datetime.now(tz=UTC) + timedelta(days=1), + num_of_seats=5, + purchased_by_user=_USER_ID_1, + user_email="test@test.com", + purchased_at=datetime.now(tz=UTC), + ) + created_item = await licensed_items_purchases.create_licensed_item_purchase( + rpc_client, data=_create_data + ) + + # Checkout with num of seats + checkout = await licensed_items_checkouts.checkout_licensed_item( + rpc_client, + licensed_item_id=created_item.licensed_item_id, + key="Duke", + version="1.0.0", # <-- Older version + wallet_id=_WALLET_ID, + product_name="osparc", + num_of_seats=3, + service_run_id=resource_tracker_service_run_id, + user_id=_USER_ID_1, + user_email="test@test.com", + ) + + # List licensed items checkouts + output = await licensed_items_checkouts.get_licensed_items_checkouts_page( + rpc_client, + product_name="osparc", + filter_wallet_id=_WALLET_ID, + ) + assert output.total == 1 + assert isinstance(output, LicensedItemsCheckoutsPage) + + # Get licensed items checkouts + output = await licensed_items_checkouts.get_licensed_item_checkout( + rpc_client, + product_name="osparc", + licensed_item_checkout_id=output.items[0].licensed_item_checkout_id, + ) + assert isinstance(output, LicensedItemCheckoutGet) + + # Release num of seats + license_item_checkout = await licensed_items_checkouts.release_licensed_item( + rpc_client, + licensed_item_checkout_id=checkout.licensed_item_checkout_id, + product_name="osparc", + ) + assert license_item_checkout + assert isinstance(license_item_checkout.stopped_at, datetime) + + +async def test_rpc_licensed_items_checkouts_can_not_checkout_newer_version( + mocked_redis_server: None, + resource_tracker_service_run_id: str, + rpc_client: RabbitMQRPCClient, +): + # List licensed items checkouts + output = await licensed_items_checkouts.get_licensed_items_checkouts_page( + rpc_client, + product_name="osparc", + filter_wallet_id=_WALLET_ID, + ) + assert output.total == 0 + assert output.items == [] + + # Purchase license item + _create_data = LicensedItemsPurchasesCreate( + product_name="osparc", + licensed_item_id="beb16d18-d57d-44aa-a638-9727fa4a72ef", + key="Duke", + version="2.0.0", # <-- Older version + wallet_id=_WALLET_ID, + wallet_name="My Wallet", + pricing_plan_id=1, + pricing_unit_id=1, + pricing_unit_cost_id=1, + pricing_unit_cost=Decimal(10), + start_at=datetime.now(tz=UTC), + expire_at=datetime.now(tz=UTC) + timedelta(days=1), + num_of_seats=5, + purchased_by_user=_USER_ID_1, + user_email="test@test.com", + purchased_at=datetime.now(tz=UTC), + ) + created_item = await licensed_items_purchases.create_licensed_item_purchase( + rpc_client, data=_create_data + ) + + # Checkout with num of seats + with pytest.raises(NotEnoughAvailableSeatsError): + await licensed_items_checkouts.checkout_licensed_item( + rpc_client, + licensed_item_id=created_item.licensed_item_id, + key="Duke", + version="3.0.0", # <-- Newer version + wallet_id=_WALLET_ID, + product_name="osparc", + num_of_seats=3, + service_run_id=resource_tracker_service_run_id, + user_id=_USER_ID_1, + user_email="test@test.com", + ) 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 e5920728d3c2..bead8f804b3f 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 @@ -43,6 +43,8 @@ async def test_rpc_licensed_items_purchases_workflow( _create_data = LicensedItemsPurchasesCreate( product_name="osparc", licensed_item_id="beb16d18-d57d-44aa-a638-9727fa4a72ef", + key="Duke", + version="1.0.0", wallet_id=1, wallet_name="My Wallet", pricing_plan_id=1, diff --git a/services/resource-usage-tracker/tests/unit/with_dbs/test_licensed_items_checkouts_db.py b/services/resource-usage-tracker/tests/unit/with_dbs/test_licensed_items_checkouts_db.py index 5f0fc5a1f5bf..11a7015e490c 100644 --- a/services/resource-usage-tracker/tests/unit/with_dbs/test_licensed_items_checkouts_db.py +++ b/services/resource-usage-tracker/tests/unit/with_dbs/test_licensed_items_checkouts_db.py @@ -71,6 +71,8 @@ async def test_licensed_items_checkouts_db__force_release_license_seats_by_run_i # SETUP _create_license_item_checkout_db_1 = CreateLicensedItemCheckoutDB( licensed_item_id="beb16d18-d57d-44aa-a638-9727fa4a72ef", + key="Duke", + version="1.0.0", wallet_id=_WALLET_ID, user_id=_USER_ID_1, user_email="test@test.com", 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 d00d90068a1b..12f2a2334a12 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 @@ -3141,52 +3141,6 @@ paths: schema: $ref: '#/components/schemas/EnvelopedError' description: Bad Request - /v0/catalog/licensed-items/{licensed_item_id}: - get: - tags: - - licenses - - catalog - summary: Get Licensed Item - operationId: get_licensed_item - parameters: - - name: licensed_item_id - in: path - required: true - schema: - type: string - format: uuid - title: Licensed Item Id - responses: - '200': - description: Successful Response - content: - application/json: - schema: - $ref: '#/components/schemas/Envelope_LicensedItemRestGet_' - '404': - content: - application/json: - schema: - $ref: '#/components/schemas/EnvelopedError' - description: Not Found - '403': - content: - application/json: - schema: - $ref: '#/components/schemas/EnvelopedError' - description: Forbidden - '402': - content: - application/json: - schema: - $ref: '#/components/schemas/EnvelopedError' - description: Payment Required - '400': - content: - application/json: - schema: - $ref: '#/components/schemas/EnvelopedError' - description: Bad Request /v0/catalog/licensed-items/{licensed_item_id}:purchase: post: tags: @@ -3209,8 +3163,12 @@ paths: schema: $ref: '#/components/schemas/LicensedItemsBodyParams' responses: - '204': + '200': description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/LicensedItemPurchaseGet' '404': content: application/json: @@ -8028,19 +7986,6 @@ components: title: Error type: object title: Envelope[LicensedItemPurchaseGet] - Envelope_LicensedItemRestGet_: - properties: - data: - anyOf: - - $ref: '#/components/schemas/LicensedItemRestGet' - - type: 'null' - error: - anyOf: - - {} - - type: 'null' - title: Error - type: object - title: Envelope[LicensedItemRestGet] Envelope_Log_: properties: data: @@ -10162,6 +10107,13 @@ components: type: string format: uuid title: Licenseditemid + key: + type: string + title: Key + version: + type: string + pattern: ^\d+\.\d+\.\d+$ + title: Version walletId: type: integer exclusiveMinimum: true @@ -10208,6 +10160,8 @@ components: - licensedItemPurchaseId - productName - licensedItemId + - key + - version - walletId - pricingUnitCostId - pricingUnitCost @@ -10225,18 +10179,46 @@ components: type: string format: uuid title: Licenseditemid + key: + type: string + title: Key + version: + type: string + pattern: ^\d+\.\d+\.\d+$ + title: Version displayName: type: string title: Displayname licensedResourceType: $ref: '#/components/schemas/LicensedResourceType' - licensedResourceData: - $ref: '#/components/schemas/_ItisVipResourceRestData' + licensedResources: + items: + $ref: '#/components/schemas/_ItisVipResourceRestData' + type: array + title: Licensedresources pricingPlanId: type: integer exclusiveMinimum: true title: Pricingplanid minimum: 0 + categoryId: + type: string + maxLength: 100 + minLength: 1 + title: Categoryid + categoryDisplay: + type: string + title: Categorydisplay + categoryIcon: + anyOf: + - type: string + - type: 'null' + title: Categoryicon + termsOfUseUrl: + anyOf: + - type: string + - type: 'null' + title: Termsofuseurl createdAt: type: string format: date-time @@ -10248,10 +10230,14 @@ components: type: object required: - licensedItemId + - key + - version - displayName - licensedResourceType - - licensedResourceData + - licensedResources - pricingPlanId + - categoryId + - categoryDisplay - createdAt - modifiedAt title: LicensedItemRestGet @@ -14899,30 +14885,10 @@ components: title: _ComputationStarted _ItisVipResourceRestData: properties: - categoryId: - type: string - maxLength: 100 - minLength: 1 - title: Categoryid - categoryDisplay: - type: string - title: Categorydisplay - categoryIcon: - anyOf: - - type: string - - type: 'null' - title: Categoryicon source: $ref: '#/components/schemas/_ItisVipRestData' - termsOfUseUrl: - anyOf: - - type: string - - type: 'null' - title: Termsofuseurl type: object required: - - categoryId - - categoryDisplay - source title: _ItisVipResourceRestData _ItisVipRestData: @@ -14943,6 +14909,9 @@ components: - type: string - type: 'null' title: Doi + licenseVersion: + type: string + title: Licenseversion type: object required: - id @@ -14950,6 +14919,7 @@ components: - thumbnail - features - doi + - licenseVersion title: _ItisVipRestData _PageParams: properties: diff --git a/services/web/server/src/simcore_service_webserver/licenses/_itis_vip_syncer_service.py b/services/web/server/src/simcore_service_webserver/licenses/_itis_vip_syncer_service.py index 45b188098b6d..36033b13069f 100644 --- a/services/web/server/src/simcore_service_webserver/licenses/_itis_vip_syncer_service.py +++ b/services/web/server/src/simcore_service_webserver/licenses/_itis_vip_syncer_service.py @@ -11,17 +11,17 @@ from servicelib.logging_utils import log_catch, log_context from simcore_service_webserver.licenses import ( _itis_vip_service, - _licensed_items_service, + _licensed_resources_service, ) from ..redis import get_redis_lock_manager_client_sdk, setup_redis from ._itis_vip_models import CategoryTuple, ItisVipData, ItisVipResourceData -from ._licensed_items_service import RegistrationState +from ._licensed_resources_service import RegistrationState _logger = logging.getLogger(__name__) -async def sync_resources_with_licensed_items( +async def sync_licensed_resources( app: web.Application, categories: list[CategoryTuple] ): async with AsyncClient() as http_client: @@ -46,7 +46,7 @@ async def sync_resources_with_licensed_items( with log_context( _logger, logging.INFO, "Registering %s", licensed_resource_name ), log_catch(_logger, reraise=False): - result = await _licensed_items_service.register_licensed_resource( + result = await _licensed_resources_service.register_licensed_resource( app, licensed_item_display_name=f"{vip_data.features.get('name', 'UNNAMED!!')} " f"{vip_data.features.get('version', 'UNVERSIONED!!')}", @@ -75,9 +75,9 @@ async def sync_resources_with_licensed_items( ) # nosec # NOTE: inform since needs curation _logger.info( - "%s . New licensed_item_id=%s pending for activation.", + "%s . New licensed_resource_id=%s pending for activation.", result.message, - result.registered.licensed_item_id, + result.registered.licensed_resource_id, ) @@ -107,7 +107,7 @@ async def _lifespan(app_: web.Application): retry_after=timedelta(minutes=2), ) async def _periodic_sync() -> None: - await sync_resources_with_licensed_items(app_, categories=categories) + await sync_licensed_resources(app_, categories=categories) background_task = asyncio.create_task( _periodic_sync(), name=_BACKGROUND_TASK_NAME diff --git a/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_checkouts_models.py b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_checkouts_models.py index 19b72835d0e2..a19c9a221355 100644 --- a/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_checkouts_models.py +++ b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_checkouts_models.py @@ -22,6 +22,8 @@ class LicensedItemCheckoutGet(BaseModel): licensed_item_checkout_id: LicensedItemCheckoutID licensed_item_id: LicensedItemID + key: str + version: str wallet_id: WalletID user_id: UserID user_email: str diff --git a/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_checkouts_rest.py b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_checkouts_rest.py index 5565ec27be6f..31369d7f0f15 100644 --- a/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_checkouts_rest.py +++ b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_checkouts_rest.py @@ -61,6 +61,8 @@ async def get_licensed_item_checkout(request: web.Request): output = LicensedItemCheckoutRestGet.model_construct( licensed_item_checkout_id=checkout_item.licensed_item_checkout_id, licensed_item_id=checkout_item.licensed_item_id, + key=checkout_item.key, + version=checkout_item.version, wallet_id=checkout_item.wallet_id, user_id=checkout_item.user_id, user_email=checkout_item.user_email, @@ -105,6 +107,8 @@ async def list_licensed_item_checkouts_for_wallet(request: web.Request): LicensedItemCheckoutRestGet.model_construct( licensed_item_checkout_id=checkout_item.licensed_item_checkout_id, licensed_item_id=checkout_item.licensed_item_id, + key=checkout_item.key, + version=checkout_item.version, wallet_id=checkout_item.wallet_id, user_id=checkout_item.user_id, user_email=checkout_item.user_email, diff --git a/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_checkouts_service.py b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_checkouts_service.py index 14959d241d1c..ed70c51bc8f2 100644 --- a/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_checkouts_service.py +++ b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_checkouts_service.py @@ -18,6 +18,7 @@ from ..rabbitmq import get_rabbitmq_rpc_client from ..users.api import get_user from ..wallets.api import get_wallet_by_user +from . import _licensed_items_repository from ._licensed_items_checkouts_models import ( LicensedItemCheckoutGet, LicensedItemCheckoutGetPage, @@ -60,6 +61,8 @@ async def list_licensed_items_checkouts_for_wallet( LicensedItemCheckoutGet.model_construct( licensed_item_checkout_id=checkout_item.licensed_item_checkout_id, licensed_item_id=checkout_item.licensed_item_id, + key=checkout_item.key, + version=checkout_item.version, wallet_id=checkout_item.wallet_id, user_id=checkout_item.user_id, user_email=checkout_item.user_email, @@ -100,6 +103,8 @@ async def get_licensed_item_checkout( return LicensedItemCheckoutGet.model_construct( licensed_item_checkout_id=checkout_item.licensed_item_checkout_id, licensed_item_id=checkout_item.licensed_item_id, + key=checkout_item.key, + version=checkout_item.version, wallet_id=checkout_item.wallet_id, user_id=checkout_item.user_id, user_email=checkout_item.user_email, @@ -132,11 +137,17 @@ async def checkout_licensed_item_for_wallet( user = await get_user(app, user_id=user_id) + licensed_item_db = await _licensed_items_repository.get( + app, licensed_item_id=licensed_item_id, product_name=product_name + ) + rpc_client = get_rabbitmq_rpc_client(app) licensed_item_get: rut_licensed_items_checkouts.LicensedItemCheckoutGet = ( await licensed_items_checkouts.checkout_licensed_item( rpc_client, - licensed_item_id=licensed_item_id, + licensed_item_id=licensed_item_db.licensed_item_id, + key=licensed_item_db.key, + version=licensed_item_db.version, wallet_id=wallet_id, product_name=product_name, num_of_seats=num_of_seats, @@ -149,6 +160,8 @@ async def checkout_licensed_item_for_wallet( return LicensedItemCheckoutGet.model_construct( licensed_item_checkout_id=licensed_item_get.licensed_item_checkout_id, licensed_item_id=licensed_item_get.licensed_item_id, + key=licensed_item_get.key, + version=licensed_item_get.version, wallet_id=licensed_item_get.wallet_id, user_id=licensed_item_get.user_id, user_email=licensed_item_get.user_email, @@ -195,6 +208,8 @@ async def release_licensed_item_for_wallet( return LicensedItemCheckoutGet.model_construct( licensed_item_checkout_id=licensed_item_get.licensed_item_checkout_id, licensed_item_id=licensed_item_get.licensed_item_id, + key=licensed_item_get.key, + version=licensed_item_get.version, wallet_id=licensed_item_get.wallet_id, user_id=licensed_item_get.user_id, user_email=licensed_item_get.user_email, diff --git a/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_purchases_service.py b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_purchases_service.py index d42ad904851a..aaff40c9af08 100644 --- a/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_purchases_service.py +++ b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_purchases_service.py @@ -58,6 +58,8 @@ async def list_licensed_items_purchases( licensed_item_purchase_id=item.licensed_item_purchase_id, product_name=item.product_name, licensed_item_id=item.licensed_item_id, + key=item.key, + version=item.version, wallet_id=item.wallet_id, pricing_unit_cost_id=item.pricing_unit_cost_id, pricing_unit_cost=item.pricing_unit_cost, @@ -102,6 +104,8 @@ async def get_licensed_item_purchase( 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, + key=licensed_item_get.key, + version=licensed_item_get.version, 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, diff --git a/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_repository.py b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_repository.py index ef69d6566e48..da7926729088 100644 --- a/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_repository.py +++ b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_repository.py @@ -1,18 +1,25 @@ import logging -from typing import Any, Literal, cast +from typing import cast from aiohttp import web from models_library.licenses import ( + LicensedItem, LicensedItemDB, LicensedItemID, - LicensedItemUpdateDB, + LicensedItemKey, + LicensedItemPatchDB, + LicensedItemVersion, LicensedResourceType, ) from models_library.products import ProductName from models_library.resource_tracker import PricingPlanId from models_library.rest_ordering import OrderBy, OrderDirection from pydantic import NonNegativeInt +from simcore_postgres_database.models.licensed_item_to_resource import ( + licensed_item_to_resource, +) from simcore_postgres_database.models.licensed_items import licensed_items +from simcore_postgres_database.models.licensed_resources import licensed_resources from simcore_postgres_database.utils_repos import ( get_columns_from_db_model, pass_or_acquire_connection, @@ -24,7 +31,7 @@ from sqlalchemy.sql import select from ..db.plugin import get_asyncpg_engine -from .errors import LicensedItemNotFoundError +from .errors import LicensedItemNotFoundError, LicensedKeyVersionNotFoundError _logger = logging.getLogger(__name__) @@ -34,19 +41,19 @@ def _create_insert_query( display_name: str, - licensed_resource_name: str, + key: LicensedItemKey, + version: LicensedItemVersion, licensed_resource_type: LicensedResourceType, - licensed_resource_data: dict[str, Any] | None, - product_name: ProductName | None, - pricing_plan_id: PricingPlanId | None, + product_name: ProductName, + pricing_plan_id: PricingPlanId, ): return ( postgresql.insert(licensed_items) .values( - licensed_resource_name=licensed_resource_name, licensed_resource_type=licensed_resource_type, - licensed_resource_data=licensed_resource_data, display_name=display_name, + key=key, + version=version, pricing_plan_id=pricing_plan_id, product_name=product_name, created=func.now(), @@ -60,19 +67,19 @@ async def create( app: web.Application, connection: AsyncConnection | None = None, *, + key: LicensedItemKey, + version: LicensedItemVersion, display_name: str, - licensed_resource_name: str, licensed_resource_type: LicensedResourceType, - licensed_resource_data: dict[str, Any] | None = None, - product_name: ProductName | None = None, - pricing_plan_id: PricingPlanId | None = None, + product_name: ProductName, + pricing_plan_id: PricingPlanId, ) -> LicensedItemDB: query = _create_insert_query( display_name, - licensed_resource_name, + key, + version, licensed_resource_type, - licensed_resource_data, product_name, pricing_plan_id, ) @@ -82,44 +89,6 @@ async def create( return LicensedItemDB.model_validate(row) -async def create_if_not_exists( - app: web.Application, - connection: AsyncConnection | None = None, - *, - display_name: str, - licensed_resource_name: str, - licensed_resource_type: LicensedResourceType, - licensed_resource_data: dict[str, Any] | None = None, - product_name: ProductName | None = None, - pricing_plan_id: PricingPlanId | None = None, -) -> LicensedItemDB: - - insert_or_none_query = _create_insert_query( - display_name, - licensed_resource_name, - licensed_resource_type, - licensed_resource_data, - product_name, - pricing_plan_id, - ).on_conflict_do_nothing() - - async with transaction_context(get_asyncpg_engine(app), connection) as conn: - result = await conn.execute(insert_or_none_query) - row = result.one_or_none() - - if row is None: - select_query = select(*_SELECTION_ARGS).where( - (licensed_items.c.licensed_resource_name == licensed_resource_name) - & (licensed_items.c.licensed_resource_type == licensed_resource_type) - ) - - result = await conn.execute(select_query) - row = result.one() - - assert row is not None # nosec - return LicensedItemDB.model_validate(row) - - async def list_( app: web.Application, connection: AsyncConnection | None = None, @@ -129,8 +98,7 @@ async def list_( limit: NonNegativeInt, order_by: OrderBy, # filters - trashed: Literal["exclude", "only", "include"] = "exclude", - inactive: Literal["exclude", "only", "include"] = "exclude", + filter_by_licensed_resource_type: LicensedResourceType | None = None, ) -> tuple[int, list[LicensedItemDB]]: base_query = ( @@ -139,21 +107,9 @@ async def list_( .where(licensed_items.c.product_name == product_name) ) - # Apply trashed filter - if trashed == "exclude": - base_query = base_query.where(licensed_items.c.trashed.is_(None)) - elif trashed == "only": - base_query = base_query.where(licensed_items.c.trashed.is_not(None)) - - if inactive == "only": - base_query = base_query.where( - licensed_items.c.product_name.is_(None) - | licensed_items.c.licensed_item_id.is_(None) - ) - elif inactive == "exclude": - base_query = base_query.where( - licensed_items.c.product_name.is_not(None) - & licensed_items.c.licensed_item_id.is_not(None) + if filter_by_licensed_resource_type: + base_query.where( + licensed_items.c.licensed_resource_type == filter_by_licensed_resource_type ) # Select total count from base_query @@ -204,37 +160,13 @@ async def get( return LicensedItemDB.model_validate(row) -async def get_by_resource_identifier( - app: web.Application, - connection: AsyncConnection | None = None, - *, - licensed_resource_name: str, - licensed_resource_type: LicensedResourceType, -) -> LicensedItemDB: - select_query = select(*_SELECTION_ARGS).where( - (licensed_items.c.licensed_resource_name == licensed_resource_name) - & (licensed_items.c.licensed_resource_type == licensed_resource_type) - ) - - async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: - result = await conn.execute(select_query) - row = result.one_or_none() - if row is None: - raise LicensedItemNotFoundError( - licensed_item_id="Unkown", - licensed_resource_name=licensed_resource_name, - licensed_resource_type=licensed_resource_type, - ) - return LicensedItemDB.model_validate(row) - - async def update( app: web.Application, connection: AsyncConnection | None = None, *, product_name: ProductName, licensed_item_id: LicensedItemID, - updates: LicensedItemUpdateDB, + updates: LicensedItemPatchDB, ) -> LicensedItemDB: # NOTE: at least 'touch' if updated_values is empty _updates = { @@ -242,11 +174,6 @@ async def update( licensed_items.c.modified.name: func.now(), } - # trashing - assert "trash" in dict(LicensedItemUpdateDB.model_fields) # nosec - if trash := _updates.pop("trash", None): - _updates[licensed_items.c.trashed.name] = func.now() if trash else None - async with transaction_context(get_asyncpg_engine(app), connection) as conn: result = await conn.execute( licensed_items.update() @@ -277,3 +204,136 @@ async def delete( & (licensed_items.c.product_name == product_name) ) ) + + +### LICENSED ITEMS DOMAIN + + +_licensed_resource_subquery = ( + select( + licensed_item_to_resource.c.licensed_item_id, + func.array_agg(licensed_resources.c.licensed_resource_data).label( + "licensed_resources" + ), + ) + .select_from( + licensed_item_to_resource.join( + licensed_resources, + licensed_resources.c.licensed_resource_id + == licensed_item_to_resource.c.licensed_resource_id, + ) + ) + .group_by( + licensed_item_to_resource.c.licensed_item_id, + ) +).subquery("licensed_resource_subquery") + + +async def get_licensed_item_by_key_version( + app: web.Application, + connection: AsyncConnection | None = None, + *, + key: LicensedItemKey, + version: LicensedItemVersion, + product_name: ProductName, +) -> LicensedItem: + + select_query = ( + select( + licensed_items.c.licensed_item_id, + licensed_items.c.key, + licensed_items.c.version, + licensed_items.c.display_name, + licensed_items.c.licensed_resource_type, + _licensed_resource_subquery.c.licensed_resources, + licensed_items.c.pricing_plan_id, + licensed_items.c.created.label("created_at"), + licensed_items.c.modified.label("modified_at"), + ) + .select_from( + licensed_items.join( + _licensed_resource_subquery, + licensed_items.c.licensed_item_id + == _licensed_resource_subquery.c.licensed_item_id, + ) + ) + .where( + (licensed_items.c.key == key) + & (licensed_items.c.version == version) + & (licensed_items.c.product_name == product_name) + ) + ) + + async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: + result = await conn.execute(select_query) + row = result.one_or_none() + if row is None: + raise LicensedKeyVersionNotFoundError(key=key, version=version) + return LicensedItem.model_validate(dict(row)) + + +async def list_licensed_items( + app: web.Application, + connection: AsyncConnection | None = None, + *, + product_name: ProductName, + offset: NonNegativeInt, + limit: NonNegativeInt, + order_by: OrderBy, + # filters + filter_by_licensed_resource_type: LicensedResourceType | None = None, +) -> tuple[int, list[LicensedItem]]: + + base_query = ( + select( + licensed_items.c.licensed_item_id, + licensed_items.c.key, + licensed_items.c.version, + licensed_items.c.display_name, + licensed_items.c.licensed_resource_type, + _licensed_resource_subquery.c.licensed_resources, + licensed_items.c.pricing_plan_id, + licensed_items.c.created.label("created_at"), + licensed_items.c.modified.label("modified_at"), + ) + .select_from( + licensed_items.join( + _licensed_resource_subquery, + licensed_items.c.licensed_item_id + == _licensed_resource_subquery.c.licensed_item_id, + ) + ) + .where(licensed_items.c.product_name == product_name) + ) + + if filter_by_licensed_resource_type: + base_query.where( + licensed_items.c.licensed_resource_type == filter_by_licensed_resource_type + ) + + # Select total count from base_query + subquery = base_query.subquery() + count_query = select(func.count()).select_from(subquery) + + # Ordering and pagination + if order_by.direction == OrderDirection.ASC: + list_query = base_query.order_by( + asc(getattr(licensed_items.c, order_by.field)), + licensed_items.c.licensed_item_id, + ) + else: + list_query = base_query.order_by( + desc(getattr(licensed_items.c, order_by.field)), + licensed_items.c.licensed_item_id, + ) + list_query = list_query.offset(offset).limit(limit) + + async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: + total_count = await conn.scalar(count_query) + + result = await conn.stream(list_query) + items: list[LicensedItem] = [ + LicensedItem.model_validate(dict(row)) async for row in result + ] + + return cast(int, total_count), items diff --git a/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_rest.py b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_rest.py index 9e794bf03314..8c5b1246ce39 100644 --- a/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_rest.py +++ b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_rest.py @@ -2,11 +2,13 @@ from aiohttp import web from models_library.api_schemas_webserver.licensed_items import LicensedItemRestGet -from models_library.licenses import LicensedItem, LicensedItemPage +from models_library.api_schemas_webserver.licensed_items_purchases import ( + LicensedItemPurchaseGet, +) +from models_library.licenses import LicensedItemPage 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 import status from servicelib.aiohttp.requests_validation import ( parse_request_body_as, parse_request_path_parameters_as, @@ -72,24 +74,6 @@ async def list_licensed_items(request: web.Request): ) -@routes.get( - f"/{VTAG}/catalog/licensed-items/{{licensed_item_id}}", name="get_licensed_item" -) -@login_required -@permission_required("catalog/licensed-items.*") -@handle_plugin_requests_exceptions -async def get_licensed_item(request: web.Request): - req_ctx = LicensedItemsRequestContext.model_validate(request) - path_params = parse_request_path_parameters_as(LicensedItemsPathParams, request) - - licensed_item: LicensedItem = await _licensed_items_service.get_licensed_item( - app=request.app, - licensed_item_id=path_params.licensed_item_id, - product_name=req_ctx.product_name, - ) - return envelope_json_response(LicensedItemRestGet.from_domain_model(licensed_item)) - - @routes.post( f"/{VTAG}/catalog/licensed-items/{{licensed_item_id}}:purchase", name="purchase_licensed_item", @@ -102,11 +86,30 @@ async def purchase_licensed_item(request: web.Request): path_params = parse_request_path_parameters_as(LicensedItemsPathParams, request) body_params = await parse_request_body_as(LicensedItemsBodyParams, request) - await _licensed_items_service.purchase_licensed_item( + purchased_item = await _licensed_items_service.purchase_licensed_item( app=request.app, user_id=req_ctx.user_id, licensed_item_id=path_params.licensed_item_id, product_name=req_ctx.product_name, body_params=body_params, ) - return web.json_response(status=status.HTTP_204_NO_CONTENT) + + output = LicensedItemPurchaseGet( + licensed_item_purchase_id=purchased_item.licensed_item_purchase_id, + product_name=purchased_item.product_name, + licensed_item_id=purchased_item.licensed_item_id, + key=purchased_item.key, + version=purchased_item.version, + wallet_id=purchased_item.wallet_id, + pricing_unit_cost_id=purchased_item.pricing_unit_cost_id, + pricing_unit_cost=purchased_item.pricing_unit_cost, + start_at=purchased_item.start_at, + expire_at=purchased_item.expire_at, + num_of_seats=purchased_item.num_of_seats, + purchased_by_user=purchased_item.purchased_by_user, + user_email=purchased_item.user_email, + purchased_at=purchased_item.purchased_at, + modified_at=purchased_item.modified, + ) + + return envelope_json_response(output) diff --git a/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_service.py b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_service.py index c40abacec15f..a2891a0ddf73 100644 --- a/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_service.py +++ b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_service.py @@ -2,19 +2,17 @@ import logging from datetime import UTC, datetime, timedelta -from enum import Enum, auto -from pprint import pformat -from typing import NamedTuple from aiohttp import web -from deepdiff import DeepDiff # type: ignore[attr-defined] +from models_library.api_schemas_resource_usage_tracker.licensed_items_purchases import ( + LicensedItemPurchaseGet, +) from models_library.licenses import ( LicensedItem, - LicensedItemDB, LicensedItemID, + LicensedItemKey, LicensedItemPage, - LicensedItemUpdateDB, - LicensedResourceType, + LicensedItemVersion, ) from models_library.products import ProductName from models_library.resource_tracker_licensed_items_purchases import ( @@ -22,7 +20,7 @@ ) from models_library.rest_ordering import OrderBy from models_library.users import UserID -from pydantic import BaseModel, NonNegativeInt +from pydantic import NonNegativeInt from servicelib.rabbitmq.rpc_interfaces.resource_usage_tracker import ( licensed_items_purchases, ) @@ -32,143 +30,23 @@ 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_repository, _licensed_resources_repository +from . import _licensed_items_repository from ._common.models import LicensedItemsBodyParams -from .errors import LicensedItemNotFoundError, LicensedItemPricingPlanMatchError +from .errors import LicensedItemPricingPlanMatchError _logger = logging.getLogger(__name__) -class RegistrationState(Enum): - ALREADY_REGISTERED = auto() - DIFFERENT_RESOURCE = auto() - NEWLY_REGISTERED = auto() - - -class RegistrationResult(NamedTuple): - registered: LicensedItemDB - state: RegistrationState - message: str | None - - -async def register_licensed_resource( - app: web.Application, - *, - licensed_resource_name: str, - licensed_resource_type: LicensedResourceType, - licensed_resource_data: BaseModel, - licensed_item_display_name: str, -) -> RegistrationResult: - # NOTE about the implementation choice: - # Using `create_if_not_exists` (INSERT with IGNORE_ON_CONFLICT) would have been an option, - # but it generates excessive error logs due to conflicts. - # - # To avoid this, we first attempt to retrieve the resource using `get_by_resource_identifier` (GET). - # If the resource does not exist, we proceed with `create_if_not_exists` (INSERT with IGNORE_ON_CONFLICT). - # - # This approach not only reduces unnecessary error logs but also helps prevent race conditions - # when multiple concurrent calls attempt to register the same resource. - - resource_key = f"{licensed_resource_type}, {licensed_resource_name}" - new_licensed_resource_data = licensed_resource_data.model_dump( - mode="json", - exclude_unset=True, - ) - - try: - licensed_item = await _licensed_items_repository.get_by_resource_identifier( - app, - licensed_resource_name=licensed_resource_name, - licensed_resource_type=licensed_resource_type, - ) - licensed_resource = ( - await _licensed_resources_repository.get_by_resource_identifier( - app, - licensed_resource_name=licensed_resource_name, - licensed_resource_type=licensed_resource_type, - ) - ) - # NOTE: MD: This is temporaty, we are splitting the licensed_item and licensed_resource - assert ( - licensed_resource.licensed_resource_name - == licensed_item.licensed_resource_name - ) # nosec - assert ( - licensed_resource.licensed_resource_type - == licensed_item.licensed_resource_type - ) # nosec - - if licensed_item.licensed_resource_data != new_licensed_resource_data: - ddiff = DeepDiff( - licensed_item.licensed_resource_data, new_licensed_resource_data - ) - msg = ( - f"DIFFERENT_RESOURCE: {resource_key=} found in licensed_item_id={licensed_item.licensed_item_id} with different data. " - f"Diff:\n\t{pformat(ddiff, indent=2, width=200)}" - ) - return RegistrationResult( - licensed_item, RegistrationState.DIFFERENT_RESOURCE, msg - ) - - return RegistrationResult( - licensed_item, - RegistrationState.ALREADY_REGISTERED, - f"ALREADY_REGISTERED: {resource_key=} found in licensed_item_id={licensed_item.licensed_item_id}", - ) - - except LicensedItemNotFoundError: - licensed_item = await _licensed_items_repository.create_if_not_exists( - app, - display_name=licensed_item_display_name, - licensed_resource_name=licensed_resource_name, - licensed_resource_type=licensed_resource_type, - licensed_resource_data=new_licensed_resource_data, - product_name=None, - pricing_plan_id=None, - ) - licensed_resource = await _licensed_resources_repository.create_if_not_exists( - app, - display_name=licensed_item_display_name, - licensed_resource_name=licensed_resource_name, - licensed_resource_type=licensed_resource_type, - licensed_resource_data=new_licensed_resource_data, - ) - # NOTE: MD: This is temporaty, we are splitting the licensed_item and licensed_resource - assert ( - licensed_resource.licensed_resource_name - == licensed_item.licensed_resource_name - ) # nosec - assert ( - licensed_resource.licensed_resource_type - == licensed_item.licensed_resource_type - ) # nosec - - return RegistrationResult( - licensed_item, - RegistrationState.NEWLY_REGISTERED, - f"NEWLY_REGISTERED: {resource_key=} registered with licensed_item_id={licensed_item.licensed_item_id}", - ) - - async def get_licensed_item( app: web.Application, *, - licensed_item_id: LicensedItemID, + key: LicensedItemKey, + version: LicensedItemVersion, product_name: ProductName, ) -> LicensedItem: - licensed_item_db = await _licensed_items_repository.get( - app, licensed_item_id=licensed_item_id, product_name=product_name - ) - return LicensedItem.model_construct( - licensed_item_id=licensed_item_db.licensed_item_id, - display_name=licensed_item_db.display_name, - licensed_resource_name=licensed_item_db.licensed_resource_name, - licensed_resource_type=licensed_item_db.licensed_resource_type, - licensed_resource_data=licensed_item_db.licensed_resource_data, - pricing_plan_id=licensed_item_db.pricing_plan_id, - created_at=licensed_item_db.created, - modified_at=licensed_item_db.modified, + return await _licensed_items_repository.get_licensed_item_by_key_version( + app, key=key, version=version, product_name=product_name ) @@ -180,61 +58,19 @@ async def list_licensed_items( limit: int, order_by: OrderBy, ) -> LicensedItemPage: - total_count, items = await _licensed_items_repository.list_( + total_count, items = await _licensed_items_repository.list_licensed_items( app, product_name=product_name, offset=offset, limit=limit, order_by=order_by, - trashed="exclude", - inactive="exclude", ) return LicensedItemPage( - items=[ - LicensedItem.model_construct( - licensed_item_id=licensed_item_db.licensed_item_id, - display_name=licensed_item_db.display_name, - licensed_resource_name=licensed_item_db.licensed_resource_name, - licensed_resource_type=licensed_item_db.licensed_resource_type, - licensed_resource_data=licensed_item_db.licensed_resource_data, - pricing_plan_id=licensed_item_db.pricing_plan_id, - created_at=licensed_item_db.created, - modified_at=licensed_item_db.modified, - ) - for licensed_item_db in items - ], + items=items, total=total_count, ) -async def trash_licensed_item( - app: web.Application, - *, - product_name: ProductName, - licensed_item_id: LicensedItemID, -): - await _licensed_items_repository.update( - app, - product_name=product_name, - licensed_item_id=licensed_item_id, - updates=LicensedItemUpdateDB(trash=True), - ) - - -async def untrash_licensed_item( - app: web.Application, - *, - product_name: ProductName, - licensed_item_id: LicensedItemID, -): - await _licensed_items_repository.update( - app, - product_name=product_name, - licensed_item_id=licensed_item_id, - updates=LicensedItemUpdateDB(trash=True), - ) - - async def purchase_licensed_item( app: web.Application, *, @@ -242,20 +78,26 @@ async def purchase_licensed_item( user_id: UserID, licensed_item_id: LicensedItemID, body_params: LicensedItemsBodyParams, -) -> None: +) -> LicensedItemPurchaseGet: # Check user wallet permissions wallet = await get_wallet_with_available_credits_by_user_and_wallet( app, user_id=user_id, wallet_id=body_params.wallet_id, product_name=product_name ) - licensed_item = await get_licensed_item( + licensed_item_db = await _licensed_items_repository.get( app, licensed_item_id=licensed_item_id, product_name=product_name ) + licensed_item = await get_licensed_item( + app, + key=licensed_item_db.key, + version=licensed_item_db.version, + product_name=product_name, + ) if licensed_item.pricing_plan_id != body_params.pricing_plan_id: raise LicensedItemPricingPlanMatchError( pricing_plan_id=body_params.pricing_plan_id, - licensed_item_id=licensed_item_id, + licensed_item_id=licensed_item.licensed_item_id, ) pricing_unit = await get_pricing_plan_unit( @@ -275,7 +117,9 @@ async def purchase_licensed_item( _data = LicensedItemsPurchasesCreate( product_name=product_name, - licensed_item_id=licensed_item_id, + licensed_item_id=licensed_item.licensed_item_id, + key=licensed_item_db.key, + version=licensed_item_db.version, wallet_id=wallet.wallet_id, wallet_name=wallet.name, pricing_plan_id=body_params.pricing_plan_id, @@ -283,12 +127,13 @@ async def purchase_licensed_item( 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, + expire_at=datetime.now(tz=UTC) + timedelta(days=365), + num_of_seats=body_params.num_of_seats, # <-- NOTE: MD: this needs to be taken from the Pricing UNIT 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) + return await licensed_items_purchases.create_licensed_item_purchase( + rpc_client, data=_data + ) diff --git a/services/web/server/src/simcore_service_webserver/licenses/_licensed_resources_repository.py b/services/web/server/src/simcore_service_webserver/licenses/_licensed_resources_repository.py index db90319faa9f..c735eb253bcc 100644 --- a/services/web/server/src/simcore_service_webserver/licenses/_licensed_resources_repository.py +++ b/services/web/server/src/simcore_service_webserver/licenses/_licensed_resources_repository.py @@ -2,7 +2,12 @@ from typing import Any from aiohttp import web -from models_library.licenses import LicensedResourceDB, LicensedResourceType +from models_library.licenses import ( + LicensedResourceDB, + LicensedResourceID, + LicensedResourcePatchDB, + LicensedResourceType, +) from simcore_postgres_database.models.licensed_resources import licensed_resources from simcore_postgres_database.utils_repos import ( get_columns_from_db_model, @@ -97,8 +102,41 @@ async def get_by_resource_identifier( row = result.one_or_none() if row is None: raise LicensedResourceNotFoundError( - licensed_item_id="Unkown", # <-- NOTE: will be changed for licensed_resource_id + licensed_resource_id="Unknown", licensed_resource_name=licensed_resource_name, licensed_resource_type=licensed_resource_type, ) return LicensedResourceDB.model_validate(row) + + +async def update( + app: web.Application, + connection: AsyncConnection | None = None, + *, + licensed_resource_id: LicensedResourceID, + updates: LicensedResourcePatchDB, +) -> LicensedResourceDB: + # NOTE: at least 'touch' if updated_values is empty + _updates = { + **updates.model_dump(exclude_unset=True), + licensed_resources.c.modified.name: func.now(), + } + + # trashing + assert "trash" in dict(LicensedResourcePatchDB.model_fields) # nosec + if trash := _updates.pop("trash", None): + _updates[licensed_resources.c.trashed.name] = func.now() if trash else None + + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + result = await conn.execute( + licensed_resources.update() + .values(**_updates) + .where(licensed_resources.c.licensed_resource_id == licensed_resource_id) + .returning(*_SELECTION_ARGS) + ) + row = result.one_or_none() + if row is None: + raise LicensedResourceNotFoundError( + licensed_resource_id=licensed_resource_id + ) + return LicensedResourceDB.model_validate(row) diff --git a/services/web/server/src/simcore_service_webserver/licenses/_licensed_resources_service.py b/services/web/server/src/simcore_service_webserver/licenses/_licensed_resources_service.py new file mode 100644 index 000000000000..11cd0495e685 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/licenses/_licensed_resources_service.py @@ -0,0 +1,124 @@ +# pylint: disable=unused-argument + +import logging +from enum import Enum, auto +from pprint import pformat +from typing import NamedTuple + +from aiohttp import web +from deepdiff import DeepDiff # type: ignore[attr-defined] +from models_library.licenses import ( + LicensedResourceDB, + LicensedResourceID, + LicensedResourcePatchDB, + LicensedResourceType, +) +from pydantic import BaseModel + +from . import _licensed_resources_repository +from .errors import LicensedResourceNotFoundError + +_logger = logging.getLogger(__name__) + + +class RegistrationState(Enum): + ALREADY_REGISTERED = auto() + DIFFERENT_RESOURCE = auto() + NEWLY_REGISTERED = auto() + + +class RegistrationResult(NamedTuple): + registered: LicensedResourceDB + state: RegistrationState + message: str | None + + +async def register_licensed_resource( + app: web.Application, + *, + licensed_resource_name: str, + licensed_resource_type: LicensedResourceType, + licensed_resource_data: BaseModel, + licensed_item_display_name: str, +) -> RegistrationResult: + # NOTE about the implementation choice: + # Using `create_if_not_exists` (INSERT with IGNORE_ON_CONFLICT) would have been an option, + # but it generates excessive error logs due to conflicts. + # + # To avoid this, we first attempt to retrieve the resource using `get_by_resource_identifier` (GET). + # If the resource does not exist, we proceed with `create_if_not_exists` (INSERT with IGNORE_ON_CONFLICT). + # + # This approach not only reduces unnecessary error logs but also helps prevent race conditions + # when multiple concurrent calls attempt to register the same resource. + + resource_key = f"{licensed_resource_type}, {licensed_resource_name}" + new_licensed_resource_data = licensed_resource_data.model_dump( + mode="json", + exclude_unset=True, + ) + + try: + licensed_resource = ( + await _licensed_resources_repository.get_by_resource_identifier( + app, + licensed_resource_name=licensed_resource_name, + licensed_resource_type=licensed_resource_type, + ) + ) + + if licensed_resource.licensed_resource_data != new_licensed_resource_data: + ddiff = DeepDiff( + licensed_resource.licensed_resource_data, new_licensed_resource_data + ) + msg = ( + f"DIFFERENT_RESOURCE: {resource_key=} found in licensed_resource_id={licensed_resource.licensed_resource_id} with different data. " + f"Diff:\n\t{pformat(ddiff, indent=2, width=200)}" + ) + return RegistrationResult( + licensed_resource, RegistrationState.DIFFERENT_RESOURCE, msg + ) + + return RegistrationResult( + licensed_resource, + RegistrationState.ALREADY_REGISTERED, + f"ALREADY_REGISTERED: {resource_key=} found in licensed_resource_id={licensed_resource.licensed_resource_id}", + ) + + except LicensedResourceNotFoundError: + licensed_resource = await _licensed_resources_repository.create_if_not_exists( + app, + display_name=licensed_item_display_name, + licensed_resource_name=licensed_resource_name, + licensed_resource_type=licensed_resource_type, + licensed_resource_data=new_licensed_resource_data, + ) + + return RegistrationResult( + licensed_resource, + RegistrationState.NEWLY_REGISTERED, + f"NEWLY_REGISTERED: {resource_key=} registered with licensed_resource_id={licensed_resource.licensed_resource_id}", + ) + + +async def trash_licensed_resource( + app: web.Application, + *, + licensed_resource_id: LicensedResourceID, +) -> None: + await _licensed_resources_repository.update( + app, + licensed_resource_id=licensed_resource_id, + updates=LicensedResourcePatchDB(trash=True), + ) + + +async def untrash_licensed_resource( + app: web.Application, + *, + licensed_resource_id: LicensedResourceID, +) -> None: + await _licensed_resources_repository.update( + app, + licensed_resource_id=licensed_resource_id, + updates=LicensedResourcePatchDB(trash=True), + ) diff --git a/services/web/server/src/simcore_service_webserver/licenses/_rpc.py b/services/web/server/src/simcore_service_webserver/licenses/_rpc.py index 2646420bd491..988b2266e478 100644 --- a/services/web/server/src/simcore_service_webserver/licenses/_rpc.py +++ b/services/web/server/src/simcore_service_webserver/licenses/_rpc.py @@ -46,7 +46,7 @@ async def get_licensed_items( product_name=product_name, offset=offset, limit=limit, - order_by=OrderBy(field=IDStr("licensed_resource_name")), + order_by=OrderBy(field=IDStr("display_name")), ) ) @@ -54,9 +54,11 @@ async def get_licensed_items( items=[ LicensedItemRpcGet.model_construct( licensed_item_id=licensed_item.licensed_item_id, + key=licensed_item.key, + version=licensed_item.version, display_name=licensed_item.display_name, licensed_resource_type=licensed_item.licensed_resource_type, - licensed_resource_data=licensed_item.licensed_resource_data, + licensed_resources=licensed_item.licensed_resources, pricing_plan_id=licensed_item.pricing_plan_id, created_at=licensed_item.created_at, modified_at=licensed_item.modified_at, @@ -101,9 +103,9 @@ async def checkout_licensed_item_for_wallet( licensed_item_get = ( await _licensed_items_checkouts_service.checkout_licensed_item_for_wallet( app, - licensed_item_id=licensed_item_id, wallet_id=wallet_id, product_name=product_name, + licensed_item_id=licensed_item_id, num_of_seats=num_of_seats, service_run_id=service_run_id, user_id=user_id, @@ -112,6 +114,8 @@ async def checkout_licensed_item_for_wallet( return LicensedItemCheckoutRpcGet.model_construct( licensed_item_checkout_id=licensed_item_get.licensed_item_checkout_id, licensed_item_id=licensed_item_get.licensed_item_id, + key=licensed_item_get.key, + version=licensed_item_get.version, wallet_id=licensed_item_get.wallet_id, user_id=licensed_item_get.user_id, product_name=licensed_item_get.product_name, @@ -140,6 +144,8 @@ async def release_licensed_item_for_wallet( return LicensedItemCheckoutRpcGet.model_construct( licensed_item_checkout_id=licensed_item_get.licensed_item_checkout_id, licensed_item_id=licensed_item_get.licensed_item_id, + key=licensed_item_get.key, + version=licensed_item_get.version, wallet_id=licensed_item_get.wallet_id, user_id=licensed_item_get.user_id, product_name=licensed_item_get.product_name, 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 540c9b752506..7c3613d1bdb1 100644 --- a/services/web/server/src/simcore_service_webserver/licenses/errors.py +++ b/services/web/server/src/simcore_service_webserver/licenses/errors.py @@ -9,6 +9,10 @@ class LicensedItemNotFoundError(LicensesValueError): msg_template = "License item {licensed_item_id} not found" +class LicensedKeyVersionNotFoundError(LicensesValueError): + msg_template = "License key {key} version {version} not found" + + class LicensedResourceNotFoundError(LicensesValueError): msg_template = "License resource {licensed_resource_id} not found" diff --git a/services/web/server/tests/unit/with_dbs/04/licenses/conftest.py b/services/web/server/tests/unit/with_dbs/04/licenses/conftest.py index ffe1a6f338c2..9d8fad3a4d70 100644 --- a/services/web/server/tests/unit/with_dbs/04/licenses/conftest.py +++ b/services/web/server/tests/unit/with_dbs/04/licenses/conftest.py @@ -6,6 +6,7 @@ import pytest from aiohttp.test_utils import TestClient from simcore_postgres_database.models.licensed_items import licensed_items +from simcore_postgres_database.models.licensed_resources import licensed_resources from simcore_postgres_database.models.resource_tracker_pricing_plans import ( resource_tracker_pricing_plans, ) @@ -45,11 +46,11 @@ async def pricing_plan_id( @pytest.fixture -async def ensure_empty_licensed_items(client: TestClient): +async def ensure_empty_licensed_resources(client: TestClient): async def _cleanup(): assert client.app async with transaction_context(get_asyncpg_engine(client.app)) as conn: - await conn.execute(licensed_items.delete()) + await conn.execute(licensed_resources.delete()) await _cleanup() diff --git a/services/web/server/tests/unit/with_dbs/04/licenses/test_itis_vip_service.py b/services/web/server/tests/unit/with_dbs/04/licenses/test_itis_vip_service.py index eb4c5a738689..5923a30f9e22 100644 --- a/services/web/server/tests/unit/with_dbs/04/licenses/test_itis_vip_service.py +++ b/services/web/server/tests/unit/with_dbs/04/licenses/test_itis_vip_service.py @@ -23,12 +23,14 @@ from simcore_service_webserver.licenses import ( _itis_vip_service, _itis_vip_syncer_service, - _licensed_items_service, + _licensed_resources_service, ) from simcore_service_webserver.licenses._itis_vip_models import ItisVipData from simcore_service_webserver.licenses._itis_vip_service import _ItisVipApiResponse from simcore_service_webserver.licenses._itis_vip_settings import ItisVipSettings -from simcore_service_webserver.licenses._licensed_items_service import RegistrationState +from simcore_service_webserver.licenses._licensed_resources_service import ( + RegistrationState, +) @pytest.fixture(scope="session") @@ -117,11 +119,11 @@ async def test_get_category_items( assert items[0].features.get("functionality") == "Posable" -async def test_sync_itis_vip_as_licensed_items( +async def test_sync_itis_vip_as_licensed_resources( mock_itis_vip_downloadables_api: respx.MockRouter, app_environment: EnvVarsDict, client: TestClient, - ensure_empty_licensed_items: None, + ensure_empty_licensed_resources: None, ): assert client.app @@ -143,10 +145,10 @@ async def test_sync_itis_vip_as_licensed_items( # register a NEW resource ( - licensed_item1, + licensed_resource1, state1, _, - ) = await _licensed_items_service.register_licensed_resource( + ) = await _licensed_resources_service.register_licensed_resource( client.app, licensed_resource_name=f"{category}/{vip.id}", licensed_resource_type=LicensedResourceType.VIP_MODEL, @@ -157,10 +159,10 @@ async def test_sync_itis_vip_as_licensed_items( # register the SAME resource ( - licensed_item2, + licensed_resource2, state2, _, - ) = await _licensed_items_service.register_licensed_resource( + ) = await _licensed_resources_service.register_licensed_resource( client.app, licensed_resource_name=f"{category}/{vip.id}", licensed_resource_type=LicensedResourceType.VIP_MODEL, @@ -169,14 +171,14 @@ async def test_sync_itis_vip_as_licensed_items( ) assert state2 == RegistrationState.ALREADY_REGISTERED - assert licensed_item1 == licensed_item2 + assert licensed_resource1 == licensed_resource2 # register a MODIFIED version of the same resource ( licensed_item3, state3, msg, - ) = await _licensed_items_service.register_licensed_resource( + ) = await _licensed_resources_service.register_licensed_resource( client.app, licensed_resource_name=f"{category}/{vip.id}", licensed_resource_type=LicensedResourceType.VIP_MODEL, @@ -191,7 +193,7 @@ async def test_sync_itis_vip_as_licensed_items( licensed_item_display_name="foo", ) assert state3 == RegistrationState.DIFFERENT_RESOURCE - assert licensed_item2 == licensed_item3 + assert licensed_resource2 == licensed_item3 # {'values_changed': {"root['features']['functionality']": {'new_value': 'Non-Posable', 'old_value': 'Posable'}}} assert "functionality" in msg @@ -200,7 +202,7 @@ async def test_itis_vip_syncer_service( mock_itis_vip_downloadables_api: respx.MockRouter, app_environment: EnvVarsDict, client: TestClient, - ensure_empty_licensed_items: None, + ensure_empty_licensed_resources: None, ): assert client.app @@ -210,11 +212,7 @@ async def test_itis_vip_syncer_service( categories = settings.to_categories() # one round - await _itis_vip_syncer_service.sync_resources_with_licensed_items( - client.app, categories - ) + await _itis_vip_syncer_service.sync_licensed_resources(client.app, categories) # second round - await _itis_vip_syncer_service.sync_resources_with_licensed_items( - client.app, categories - ) + await _itis_vip_syncer_service.sync_licensed_resources(client.app, categories) diff --git a/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_purchases_rest.py b/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_purchases_rest.py index a164c1b64068..7abbd37b2961 100644 --- a/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_purchases_rest.py +++ b/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_purchases_rest.py @@ -26,6 +26,8 @@ "licensed_item_purchase_id": "beb16d18-d57d-44aa-a638-9727fa4a72ef", "product_name": "osparc", "licensed_item_id": "303942ef-6d31-4ba8-afbe-dbb1fce2a953", + "key": "Duke", + "version": "1.0.0", "wallet_id": 1, "wallet_name": "My Wallet", "pricing_unit_cost_id": 1, diff --git a/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_repository.py b/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_repository.py index ed91deede04f..4129819f874e 100644 --- a/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_repository.py +++ b/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_repository.py @@ -4,19 +4,31 @@ # pylint: disable=too-many-arguments # pylint: disable=too-many-statements -import arrow +import copy + import pytest from aiohttp.test_utils import TestClient from models_library.licenses import ( VIP_DETAILS_EXAMPLE, - LicensedItemUpdateDB, + LicensedItemPatchDB, LicensedResourceType, ) from models_library.rest_ordering import OrderBy from pytest_simcore.helpers.webserver_login import UserInfoDict +from simcore_postgres_database.models.licensed_item_to_resource import ( + licensed_item_to_resource, +) +from simcore_postgres_database.utils_repos import transaction_context from simcore_service_webserver.db.models import UserRole -from simcore_service_webserver.licenses import _licensed_items_repository -from simcore_service_webserver.licenses.errors import LicensedItemNotFoundError +from simcore_service_webserver.db.plugin import get_asyncpg_engine +from simcore_service_webserver.licenses import ( + _licensed_items_repository, + _licensed_resources_repository, +) +from simcore_service_webserver.licenses.errors import ( + LicensedItemNotFoundError, + LicensedKeyVersionNotFoundError, +) from simcore_service_webserver.projects.models import ProjectDict @@ -25,7 +37,7 @@ def user_role() -> UserRole: return UserRole.USER -async def test_licensed_items_db_crud( +async def test_licensed_items_db_domain_crud( client: TestClient, logged_user: UserInfoDict, user_project: ProjectDict, @@ -46,10 +58,10 @@ async def test_licensed_items_db_crud( got = await _licensed_items_repository.create( client.app, product_name=osparc_product_name, - display_name="Model A Display Name", - licensed_resource_name="Model A", + display_name="Renting A Display Name", + key="Duke", + version="1.0.0", licensed_resource_type=LicensedResourceType.VIP_MODEL, - licensed_resource_data=VIP_DETAILS_EXAMPLE, pricing_plan_id=pricing_plan_id, ) licensed_item_id = got.licensed_item_id @@ -69,13 +81,13 @@ async def test_licensed_items_db_crud( licensed_item_id=licensed_item_id, product_name=osparc_product_name, ) - assert got.licensed_resource_name == "Model A" + assert got.display_name == "Renting A Display Name" await _licensed_items_repository.update( client.app, licensed_item_id=licensed_item_id, product_name=osparc_product_name, - updates=LicensedItemUpdateDB(licensed_resource_name="Model B"), + updates=LicensedItemPatchDB(display_name="Renting B Display Name"), ) got = await _licensed_items_repository.get( @@ -83,7 +95,7 @@ async def test_licensed_items_db_crud( licensed_item_id=licensed_item_id, product_name=osparc_product_name, ) - assert got.licensed_resource_name == "Model B" + assert got.display_name == "Renting B Display Name" got = await _licensed_items_repository.delete( client.app, @@ -99,7 +111,7 @@ async def test_licensed_items_db_crud( ) -async def test_licensed_items_db_trash( +async def test_licensed_items_domain_listing( client: TestClient, logged_user: UserInfoDict, user_project: ProjectDict, @@ -107,77 +119,131 @@ async def test_licensed_items_db_trash( pricing_plan_id: int, ): assert client.app - - # Create two licensed items - licensed_item_ids = [] - for name in ["Model A", "Model B"]: - licensed_item_db = await _licensed_items_repository.create( - client.app, - product_name=osparc_product_name, - display_name="Model A Display Name", - licensed_resource_name=name, - licensed_resource_type=LicensedResourceType.VIP_MODEL, - licensed_resource_data=VIP_DETAILS_EXAMPLE, - pricing_plan_id=pricing_plan_id, - ) - licensed_item_ids.append(licensed_item_db.licensed_item_id) - - # Trash one licensed item - trashing_at = arrow.now().datetime - trashed_item = await _licensed_items_repository.update( + total_count, items = await _licensed_items_repository.list_licensed_items( client.app, - licensed_item_id=licensed_item_ids[0], product_name=osparc_product_name, - updates=LicensedItemUpdateDB(trash=True), + offset=0, + limit=10, + order_by=OrderBy(field="modified"), ) + assert total_count == 0 + assert not items - assert trashed_item.licensed_item_id == licensed_item_ids[0] - assert trashed_item.trashed - assert trashing_at < trashed_item.trashed - assert trashed_item.trashed < arrow.now().datetime - - # List with filter_trashed include - total_count, items = await _licensed_items_repository.list_( + got_duke1 = await _licensed_items_repository.create( client.app, product_name=osparc_product_name, - offset=0, - limit=10, - order_by=OrderBy(field="display_name"), - trashed="include", + display_name="Renting Duke 1.0.0 Display Name", + key="Duke", + version="1.0.0", + licensed_resource_type=LicensedResourceType.VIP_MODEL, + pricing_plan_id=pricing_plan_id, ) - assert total_count == 2 - assert {i.licensed_item_id for i in items} == set(licensed_item_ids) - # List with filter_trashed exclude - total_count, items = await _licensed_items_repository.list_( + got_duke2 = await _licensed_items_repository.create( client.app, product_name=osparc_product_name, - offset=0, - limit=10, - order_by=OrderBy(field="display_name"), - trashed="exclude", + display_name="Renting Duke 2.0.0 Display Name", + key="Duke", + version="2.0.0", + licensed_resource_type=LicensedResourceType.VIP_MODEL, + pricing_plan_id=pricing_plan_id, ) - assert total_count == 1 - assert items[0].licensed_item_id == licensed_item_ids[1] - assert items[0].trashed is None - # List with filter_trashed all - total_count, items = await _licensed_items_repository.list_( + # Create Licensed Resource with licensed key and version (Duke V1) + example_duke1 = copy.deepcopy(VIP_DETAILS_EXAMPLE) + example_duke1["license_key"] = "ABC" + example_duke1["license_version"] = "1.0.0" + example_duke1["id"] = 1 + + got_licensed_resource_duke1 = ( + await _licensed_resources_repository.create_if_not_exists( + client.app, + display_name="Duke 1", + licensed_resource_name="Duke 1", + licensed_resource_type=LicensedResourceType.VIP_MODEL, + licensed_resource_data=example_duke1, + ) + ) + + example_duke1_different_id = copy.deepcopy(VIP_DETAILS_EXAMPLE) + example_duke1_different_id["license_key"] = "ABC" + example_duke1_different_id["license_version"] = "1.0.0" + example_duke1_different_id["id"] = 2 + + # Create Licensed Resource with the same licensed key and version (Duke V1) but different external ID + got_licensed_resource_duke1_different_id = ( + await _licensed_resources_repository.create_if_not_exists( + client.app, + display_name="Duke 1 (different external ID)", + licensed_resource_name="Duke 1 different external ID", + licensed_resource_type=LicensedResourceType.VIP_MODEL, + licensed_resource_data=example_duke1_different_id, + ) + ) + + example_duke2 = copy.deepcopy(VIP_DETAILS_EXAMPLE) + example_duke2["license_key"] = "ABC" + example_duke2["license_version"] = "2.0.0" + example_duke2["id"] = 3 + + # Create Licensed Resource with the same licensed key but different version (Duke V2) + got_licensed_resource_duke2 = ( + await _licensed_resources_repository.create_if_not_exists( + client.app, + display_name="Duke 2", + licensed_resource_name="Duke 2", + licensed_resource_type=LicensedResourceType.VIP_MODEL, + licensed_resource_data=example_duke2, + ) + ) + + # Connect them via licensed_item_to_resorce DB table + async with transaction_context(get_asyncpg_engine(client.app)) as conn: + await conn.execute( + licensed_item_to_resource.insert(), + [ + { + "licensed_item_id": got_duke1.licensed_item_id, + "licensed_resource_id": got_licensed_resource_duke1.licensed_resource_id, + }, + { + "licensed_item_id": got_duke1.licensed_item_id, + "licensed_resource_id": got_licensed_resource_duke1_different_id.licensed_resource_id, + }, + { + "licensed_item_id": got_duke2.licensed_item_id, + "licensed_resource_id": got_licensed_resource_duke2.licensed_resource_id, + }, + ], + ) + + total_count, items = await _licensed_items_repository.list_licensed_items( client.app, product_name=osparc_product_name, offset=0, limit=10, order_by=OrderBy(field="display_name"), - trashed="only", ) - assert total_count == 1 - assert items[0].licensed_item_id == trashed_item.licensed_item_id - assert items[0].trashed + assert total_count == 2 + assert items[0].licensed_item_id == got_duke1.licensed_item_id + assert len(items[0].licensed_resources) == 2 + assert items[1].licensed_item_id == got_duke2.licensed_item_id + assert len(items[1].licensed_resources) == 1 - # Get the trashed licensed item - got = await _licensed_items_repository.get( - client.app, - licensed_item_id=trashed_item.licensed_item_id, - product_name=osparc_product_name, + got = await _licensed_items_repository.get_licensed_item_by_key_version( + client.app, key="Duke", version="1.0.0", product_name=osparc_product_name + ) + assert got.display_name == "Renting Duke 1.0.0 Display Name" + + got = await _licensed_items_repository.get_licensed_item_by_key_version( + client.app, key="Duke", version="2.0.0", product_name=osparc_product_name ) - assert got == trashed_item + assert got.display_name == "Renting Duke 2.0.0 Display Name" + + with pytest.raises(LicensedKeyVersionNotFoundError): + await _licensed_items_repository.get_licensed_item_by_key_version( + client.app, + key="Non-Existing", + version="2.0.0", + product_name=osparc_product_name, + ) diff --git a/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_rest.py b/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_rest.py index 32430596f108..93ca67feb3df 100644 --- a/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_rest.py +++ b/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_rest.py @@ -3,22 +3,37 @@ # 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_resource_usage_tracker.pricing_plans import ( PricingUnitGet, ) from models_library.api_schemas_webserver.licensed_items import LicensedItemRestGet +from models_library.api_schemas_webserver.licensed_items_purchases import ( + LicensedItemPurchaseGet, +) from models_library.api_schemas_webserver.wallets import WalletGetWithAvailableCredits from models_library.licenses import VIP_DETAILS_EXAMPLE, LicensedResourceType from pytest_mock.plugin import MockerFixture from pytest_simcore.helpers.assert_checks import assert_status from pytest_simcore.helpers.webserver_login import UserInfoDict from servicelib.aiohttp import status +from simcore_postgres_database.models.licensed_item_to_resource import ( + licensed_item_to_resource, +) +from simcore_postgres_database.utils_repos import transaction_context from simcore_service_webserver.db.models import UserRole -from simcore_service_webserver.licenses import _licensed_items_repository +from simcore_service_webserver.db.plugin import get_asyncpg_engine +from simcore_service_webserver.licenses import ( + _licensed_items_repository, + _licensed_resources_repository, +) from simcore_service_webserver.projects.models import ProjectDict @@ -41,20 +56,38 @@ async def test_licensed_items_listing( licensed_item_db = await _licensed_items_repository.create( client.app, + key="Duke", + version="1.0.0", product_name=osparc_product_name, display_name="Model A display name", - licensed_resource_name="Model A", licensed_resource_type=LicensedResourceType.VIP_MODEL, pricing_plan_id=pricing_plan_id, - licensed_resource_data={ - "categoryId": "HumanWholeBody", - "categoryDisplay": "Humans", - "source": VIP_DETAILS_EXAMPLE, - }, ) - _licensed_item_id = licensed_item_db.licensed_item_id + got_licensed_resource_duke = ( + await _licensed_resources_repository.create_if_not_exists( + client.app, + display_name="Duke", + licensed_resource_name="Duke", + licensed_resource_type=LicensedResourceType.VIP_MODEL, + licensed_resource_data={ + "category_id": "HumanWholeBody", + "category_display": "Humans", + "source": VIP_DETAILS_EXAMPLE, + }, + ) + ) + + # Connect them via licensed_item_to_resorce DB table + async with transaction_context(get_asyncpg_engine(client.app)) as conn: + await conn.execute( + licensed_item_to_resource.insert().values( + licensed_item_id=_licensed_item_id, + licensed_resource_id=got_licensed_resource_duke.licensed_resource_id, + ) + ) + # list url = client.app.router["list_licensed_items"].url_for() resp = await client.get(f"{url}") @@ -63,20 +96,36 @@ async def test_licensed_items_listing( assert LicensedItemRestGet(**data[0]) # <-- Testing nested camel case - source = data[0]["licensedResourceData"]["source"] + source = data[0]["licensedResources"][0]["source"] assert all("_" not in key for key in source), f"got {source=}" # Testing trimmed assert "additionalField" not in source assert "additional_field" not in source - # get - url = client.app.router["get_licensed_item"].url_for( - licensed_item_id=f"{_licensed_item_id}" + +_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", + "key": "Duke", + "version": "1.0.0", + "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, + "user_email": "test@test.com", + "purchased_at": "2023-01-11 13:11:47.293595", + "modified": "2023-01-11 13:11:47.293595", + } ) - resp = await client.get(f"{url}") - data, _ = await assert_status(resp, status.HTTP_200_OK) - assert LicensedItemRestGet(**data) +) @pytest.fixture @@ -97,7 +146,7 @@ def mock_licensed_items_purchase_functions(mocker: MockerFixture) -> tuple: ) mock_create_licensed_item_purchase = mocker.patch( "simcore_service_webserver.licenses._licensed_items_service.licensed_items_purchases.create_licensed_item_purchase", - spec=True, + return_value=_LICENSED_ITEM_PURCHASE_GET, ) return ( @@ -121,26 +170,37 @@ async def test_licensed_items_purchase( licensed_item_db = await _licensed_items_repository.create( client.app, + key="Duke", + version="1.0.0", product_name=osparc_product_name, display_name="Model A display name", - licensed_resource_name="Model A", licensed_resource_type=LicensedResourceType.VIP_MODEL, pricing_plan_id=pricing_plan_id, - licensed_resource_data={ - "categoryId": "HumanWholeBody", - "categoryDisplay": "Humans", - "source": VIP_DETAILS_EXAMPLE, - }, ) _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}" + got_licensed_resource_duke = ( + await _licensed_resources_repository.create_if_not_exists( + client.app, + display_name="Duke", + licensed_resource_name="Duke", + licensed_resource_type=LicensedResourceType.VIP_MODEL, + licensed_resource_data={ + "category_id": "HumanWholeBody", + "category_display": "Humans", + "source": VIP_DETAILS_EXAMPLE, + }, + ) ) - resp = await client.get(f"{url}") - data, _ = await assert_status(resp, status.HTTP_200_OK) - assert LicensedItemRestGet(**data) + + # Connect them via licensed_item_to_resorce DB table + async with transaction_context(get_asyncpg_engine(client.app)) as conn: + await conn.execute( + licensed_item_to_resource.insert().values( + licensed_item_id=_licensed_item_id, + licensed_resource_id=got_licensed_resource_duke.licensed_resource_id, + ) + ) # purchase url = client.app.router["purchase_licensed_item"].url_for( @@ -155,4 +215,5 @@ async def test_licensed_items_purchase( "pricing_unit_id": 1, }, ) - await assert_status(resp, status.HTTP_204_NO_CONTENT) + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert LicensedItemPurchaseGet(**data) diff --git a/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_resources_repository.py b/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_resources_repository.py new file mode 100644 index 000000000000..22069d929149 --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_resources_repository.py @@ -0,0 +1,59 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments +# pylint: disable=too-many-statements +import arrow +import pytest +from aiohttp.test_utils import TestClient +from models_library.licenses import ( + VIP_DETAILS_EXAMPLE, + LicensedResourcePatchDB, + LicensedResourceType, +) +from pytest_simcore.helpers.webserver_login import UserInfoDict +from simcore_service_webserver.db.models import UserRole +from simcore_service_webserver.licenses import _licensed_resources_repository +from simcore_service_webserver.projects.models import ProjectDict + + +@pytest.fixture +def user_role() -> UserRole: + return UserRole.USER + + +async def test_licensed_items_db_trash( + client: TestClient, + logged_user: UserInfoDict, + user_project: ProjectDict, + osparc_product_name: str, + pricing_plan_id: int, +): + assert client.app + + # Create two licensed items + licensed_resource_ids = [] + for name in ["Model A", "Model B"]: + licensed_resource_db = ( + await _licensed_resources_repository.create_if_not_exists( + client.app, + display_name="Model A Display Name", + licensed_resource_name=name, + licensed_resource_type=LicensedResourceType.VIP_MODEL, + licensed_resource_data=VIP_DETAILS_EXAMPLE, + ) + ) + licensed_resource_ids.append(licensed_resource_db.licensed_resource_id) + + # Trash one licensed item + trashing_at = arrow.now().datetime + trashed_item = await _licensed_resources_repository.update( + client.app, + licensed_resource_id=licensed_resource_ids[0], + updates=LicensedResourcePatchDB(trash=True), + ) + + assert trashed_item.licensed_resource_id == licensed_resource_ids[0] + assert trashed_item.trashed + assert trashing_at < trashed_item.trashed + assert trashed_item.trashed < arrow.now().datetime diff --git a/services/web/server/tests/unit/with_dbs/04/licenses/test_licenses_rpc.py b/services/web/server/tests/unit/with_dbs/04/licenses/test_licenses_rpc.py index 1d19f2b31baa..1aef27179b4e 100644 --- a/services/web/server/tests/unit/with_dbs/04/licenses/test_licenses_rpc.py +++ b/services/web/server/tests/unit/with_dbs/04/licenses/test_licenses_rpc.py @@ -25,9 +25,17 @@ release_licensed_item_for_wallet, ) from settings_library.rabbit import RabbitSettings +from simcore_postgres_database.models.licensed_item_to_resource import ( + licensed_item_to_resource, +) from simcore_postgres_database.models.users import UserRole +from simcore_postgres_database.utils_repos import transaction_context from simcore_service_webserver.application_settings import ApplicationSettings -from simcore_service_webserver.licenses import _licensed_items_repository +from simcore_service_webserver.db.plugin import get_asyncpg_engine +from simcore_service_webserver.licenses import ( + _licensed_items_repository, + _licensed_resources_repository, +) pytest_simcore_core_services_selection = [ "rabbit", @@ -133,15 +141,39 @@ async def test_license_checkout_workflow( assert len(result.items) == 0 assert result.total == 0 - license_item_db = await _licensed_items_repository.create( + licensed_item_db = await _licensed_items_repository.create( client.app, + key="Duke", + version="1.0.0", product_name=osparc_product_name, display_name="Model A display name", - licensed_resource_name="Model A", licensed_resource_type=LicensedResourceType.VIP_MODEL, pricing_plan_id=pricing_plan_id, - licensed_resource_data=VIP_DETAILS_EXAMPLE, ) + _licensed_item_id = licensed_item_db.licensed_item_id + + got_licensed_resource_duke = ( + await _licensed_resources_repository.create_if_not_exists( + client.app, + display_name="Duke", + licensed_resource_name="Duke", + licensed_resource_type=LicensedResourceType.VIP_MODEL, + licensed_resource_data={ + "category_id": "HumanWholeBody", + "category_display": "Humans", + "source": VIP_DETAILS_EXAMPLE, + }, + ) + ) + + # Connect them via licensed_item_to_resorce DB table + async with transaction_context(get_asyncpg_engine(client.app)) as conn: + await conn.execute( + licensed_item_to_resource.insert().values( + licensed_item_id=_licensed_item_id, + licensed_resource_id=got_licensed_resource_duke.licensed_resource_id, + ) + ) result = await get_licensed_items( rpc_client, product_name=osparc_product_name, offset=0, limit=20 @@ -163,7 +195,7 @@ async def test_license_checkout_workflow( product_name=osparc_product_name, user_id=logged_user["id"], wallet_id=1, - licensed_item_id=license_item_db.licensed_item_id, + licensed_item_id=licensed_item_db.licensed_item_id, num_of_seats=1, service_run_id="run_1", ) diff --git a/tests/e2e/tests/title.test.js b/tests/e2e/tests/title.test.js index dc5c5e0e9f9f..947db9523456 100644 --- a/tests/e2e/tests/title.test.js +++ b/tests/e2e/tests/title.test.js @@ -6,8 +6,8 @@ beforeAll(async () => { test('Check site title', async () => { const title = await page.title(); - expect(title).toBe("oSPARC"); - + expect(title).toContain("PARC"); + // oSPARC ([0]) is the product served by default const replacements = appMetadata["applications"][0]["replacements"];