diff --git a/api/specs/web-server/_catalog_licensed_items.py b/api/specs/web-server/_catalog_licensed_items.py new file mode 100644 index 00000000000..29b39853c95 --- /dev/null +++ b/api/specs/web-server/_catalog_licensed_items.py @@ -0,0 +1,66 @@ +""" Helper script to generate OAS automatically +""" + +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments + +from typing import Annotated + +from _common import as_query +from fastapi import APIRouter, Depends, status +from models_library.api_schemas_webserver.licensed_items import LicensedItemGet +from models_library.generics import Envelope +from models_library.rest_error import EnvelopedError +from simcore_service_webserver._meta import API_VTAG +from simcore_service_webserver.catalog.licenses._exceptions_handlers import ( + _TO_HTTP_ERROR_MAP, +) +from simcore_service_webserver.catalog.licenses._models import ( + LicensedItemsBodyParams, + LicensedItemsListQueryParams, + LicensedItemsPathParams, +) + +router = APIRouter( + prefix=f"/{API_VTAG}", + tags=[ + "licenses", + "catalog", + ], + responses={ + i.status_code: {"model": EnvelopedError} for i in _TO_HTTP_ERROR_MAP.values() + }, +) + + +@router.get( + "/catalog/licensed-items", + response_model=Envelope[list[LicensedItemGet]], +) +async def list_licensed_items( + _query: Annotated[as_query(LicensedItemsListQueryParams), Depends()], +): + ... + + +@router.get( + "/catalog/licensed-items/{licensed_item_id}", + response_model=Envelope[LicensedItemGet], +) +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, +) +async def purchase_licensed_item( + _path: Annotated[LicensedItemsPathParams, Depends()], + _body: LicensedItemsBodyParams, +): + ... diff --git a/api/specs/web-server/openapi.py b/api/specs/web-server/openapi.py index 8e6b562c96d..5a679b75713 100644 --- a/api/specs/web-server/openapi.py +++ b/api/specs/web-server/openapi.py @@ -31,6 +31,7 @@ "_announcements", "_catalog", "_catalog_tags", # MUST BE after _catalog + "_catalog_licensed_items", "_computations", "_exporter", "_folders", 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 new file mode 100644 index 00000000000..5c170588856 --- /dev/null +++ b/packages/models-library/src/models_library/api_schemas_webserver/licensed_items.py @@ -0,0 +1,22 @@ +from datetime import datetime +from typing import NamedTuple + +from models_library.licensed_items import LicensedItemID, LicensedResourceType +from models_library.resource_tracker import PricingPlanId +from pydantic import PositiveInt + +from ._base import OutputSchema + + +class LicensedItemGet(OutputSchema): + licensed_item_id: LicensedItemID + name: str + licensed_resource_type: LicensedResourceType + pricing_plan_id: PricingPlanId + created_at: datetime + modified_at: datetime + + +class LicensedItemGetPage(NamedTuple): + items: list[LicensedItemGet] + total: PositiveInt diff --git a/packages/models-library/src/models_library/licensed_items.py b/packages/models-library/src/models_library/licensed_items.py new file mode 100644 index 00000000000..021cf214ce5 --- /dev/null +++ b/packages/models-library/src/models_library/licensed_items.py @@ -0,0 +1,44 @@ +from datetime import datetime +from enum import auto +from typing import TypeAlias +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from .products import ProductName +from .resource_tracker import PricingPlanId +from .utils.enums import StrAutoEnum + +LicensedItemID: TypeAlias = UUID + + +class LicensedResourceType(StrAutoEnum): + VIP_MODEL = auto() + + +# +# DB +# + + +class LicensedItemDB(BaseModel): + licensed_item_id: LicensedItemID + name: str + licensed_resource_type: LicensedResourceType + pricing_plan_id: PricingPlanId + product_name: ProductName + created: datetime = Field( + ..., + description="Timestamp on creation", + ) + modified: datetime = Field( + ..., + description="Timestamp of last modification", + ) + # ---- + model_config = ConfigDict(from_attributes=True) + + +class LicensedItemUpdateDB(BaseModel): + name: str | None = None + pricing_plan_id: PricingPlanId | None = None diff --git a/packages/postgres-database/requirements/_base.in b/packages/postgres-database/requirements/_base.in index 645b7aae0fb..c5aa128b710 100644 --- a/packages/postgres-database/requirements/_base.in +++ b/packages/postgres-database/requirements/_base.in @@ -6,7 +6,7 @@ --requirement ../../../packages/common-library/requirements/_base.in alembic +opentelemetry-instrumentation-asyncpg pydantic sqlalchemy[postgresql_psycopg2binary,postgresql_asyncpg] # SEE extras in https://github.com/sqlalchemy/sqlalchemy/blob/main/setup.cfg#L43 -opentelemetry-instrumentation-asyncpg yarl diff --git a/packages/postgres-database/requirements/_base.txt b/packages/postgres-database/requirements/_base.txt index e14ea9e8ab2..2a6f133725f 100644 --- a/packages/postgres-database/requirements/_base.txt +++ b/packages/postgres-database/requirements/_base.txt @@ -54,7 +54,7 @@ pydantic-core==2.27.1 # via pydantic pydantic-extra-types==2.10.0 # via -r requirements/../../../packages/common-library/requirements/_base.in -setuptools==75.2.0 +setuptools==75.6.0 # via opentelemetry-instrumentation sqlalchemy==1.4.54 # via diff --git a/packages/postgres-database/requirements/_migration.txt b/packages/postgres-database/requirements/_migration.txt index a0dd4d6577f..903b84d3681 100644 --- a/packages/postgres-database/requirements/_migration.txt +++ b/packages/postgres-database/requirements/_migration.txt @@ -6,7 +6,7 @@ certifi==2024.8.30 # via # -c requirements/../../../requirements/constraints.txt # requests -charset-normalizer==3.3.2 +charset-normalizer==3.4.0 # via requests click==8.1.7 # via -r requirements/_migration.in diff --git a/packages/postgres-database/requirements/_test.txt b/packages/postgres-database/requirements/_test.txt index d6059bacd37..49636a365c3 100644 --- a/packages/postgres-database/requirements/_test.txt +++ b/packages/postgres-database/requirements/_test.txt @@ -6,11 +6,11 @@ async-timeout==4.0.3 # aiopg attrs==24.2.0 # via pytest-docker -coverage==7.6.1 +coverage==7.6.8 # via # -r requirements/_test.in # pytest-cov -faker==29.0.0 +faker==33.1.0 # via -r requirements/_test.in greenlet==3.1.1 # via @@ -19,11 +19,11 @@ greenlet==3.1.1 # sqlalchemy iniconfig==2.0.0 # via pytest -mypy==1.12.0 +mypy==1.13.0 # via sqlalchemy mypy-extensions==1.0.0 # via mypy -packaging==24.1 +packaging==24.2 # via pytest pluggy==1.5.0 # via pytest @@ -32,7 +32,7 @@ psycopg2-binary==2.9.9 # -c requirements/_base.txt # aiopg # sqlalchemy -pytest==8.3.3 +pytest==8.3.4 # via # -r requirements/_test.in # pytest-asyncio @@ -43,7 +43,7 @@ pytest-asyncio==0.23.8 # via # -c requirements/../../../requirements/constraints.txt # -r requirements/_test.in -pytest-cov==5.0.0 +pytest-cov==6.0.0 # via -r requirements/_test.in pytest-docker==3.1.1 # via -r requirements/_test.in @@ -70,14 +70,15 @@ sqlalchemy2-stubs==0.0.2a38 # via sqlalchemy types-docker==7.1.0.20240827 # via -r requirements/_test.in -types-psycopg2==2.9.21.20240819 +types-psycopg2==2.9.21.20241019 # via -r requirements/_test.in -types-requests==2.32.0.20240914 +types-requests==2.32.0.20241016 # via types-docker typing-extensions==4.12.2 # via # -c requirements/_base.txt # -c requirements/_migration.txt + # faker # mypy # sqlalchemy2-stubs urllib3==2.2.3 diff --git a/packages/postgres-database/requirements/_tools.txt b/packages/postgres-database/requirements/_tools.txt index 61c9a3ec7e1..98fce79f69a 100644 --- a/packages/postgres-database/requirements/_tools.txt +++ b/packages/postgres-database/requirements/_tools.txt @@ -1,8 +1,8 @@ -astroid==3.3.4 +astroid==3.3.5 # via pylint -black==24.8.0 +black==24.10.0 # via -r requirements/../../../requirements/devenv.txt -build==1.2.2 +build==1.2.2.post1 # via pip-tools bump2version==1.0.1 # via -r requirements/../../../requirements/devenv.txt @@ -12,13 +12,13 @@ click==8.1.7 # via # black # pip-tools -dill==0.3.8 +dill==0.3.9 # via pylint -distlib==0.3.8 +distlib==0.3.9 # via virtualenv filelock==3.16.1 # via virtualenv -identify==2.6.1 +identify==2.6.3 # via pre-commit isort==5.13.2 # via @@ -26,7 +26,7 @@ isort==5.13.2 # pylint mccabe==0.7.0 # via pylint -mypy==1.12.0 +mypy==1.13.0 # via # -c requirements/_test.txt # -r requirements/../../../requirements/devenv.txt @@ -37,14 +37,14 @@ mypy-extensions==1.0.0 # mypy nodeenv==1.9.1 # via pre-commit -packaging==24.1 +packaging==24.2 # via # -c requirements/_test.txt # black # build pathspec==0.12.1 # via black -pip==24.2 +pip==24.3.1 # via pip-tools pip-tools==7.4.1 # via -r requirements/../../../requirements/devenv.txt @@ -53,11 +53,11 @@ platformdirs==4.3.6 # black # pylint # virtualenv -pre-commit==3.8.0 +pre-commit==4.0.1 # via -r requirements/../../../requirements/devenv.txt -pylint==3.3.0 +pylint==3.3.2 # via -r requirements/../../../requirements/devenv.txt -pyproject-hooks==1.1.0 +pyproject-hooks==1.2.0 # via # build # pip-tools @@ -66,9 +66,9 @@ pyyaml==6.0.2 # -c requirements/../../../requirements/constraints.txt # -c requirements/_test.txt # pre-commit -ruff==0.6.7 +ruff==0.8.1 # via -r requirements/../../../requirements/devenv.txt -setuptools==75.2.0 +setuptools==75.6.0 # via # -c requirements/_base.txt # pip-tools @@ -79,7 +79,7 @@ typing-extensions==4.12.2 # -c requirements/_base.txt # -c requirements/_test.txt # mypy -virtualenv==20.26.5 +virtualenv==20.28.0 # via pre-commit -wheel==0.44.0 +wheel==0.45.1 # via pip-tools diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/e5555076ef50_add_license_db_tables.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/e5555076ef50_add_license_db_tables.py new file mode 100644 index 00000000000..abdfafefdf7 --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/e5555076ef50_add_license_db_tables.py @@ -0,0 +1,171 @@ +"""add license db tables + +Revision ID: e5555076ef50 +Revises: e05bdc5b3c7b +Create Date: 2024-12-05 10:57:16.867891+00:00 + +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "e5555076ef50" +down_revision = "e05bdc5b3c7b" +branch_labels = None +depends_on = None + + +def upgrade(): + # CREATE EXTENSION pgcrypto; + op.execute( + """ + DO + $$ + BEGIN + IF EXISTS(SELECT * FROM pg_available_extensions WHERE name = 'pgcrypto') THEN + -- Create the extension + CREATE EXTENSION if not exists pgcrypto; + END IF; + END + $$; + """ + ) + + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "resource_tracker_licensed_items_purchases", + sa.Column( + "licensed_item_purchase_id", + postgresql.UUID(as_uuid=True), + server_default=sa.text("gen_random_uuid()"), + nullable=False, + ), + sa.Column("product_name", sa.String(), nullable=False), + sa.Column("licensed_item_id", sa.BigInteger(), nullable=False), + sa.Column("wallet_id", sa.BigInteger(), nullable=False), + sa.Column( + "start_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "expire_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("purchased_by_user", sa.BigInteger(), nullable=False), + sa.Column( + "purchased_at", + 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.PrimaryKeyConstraint("licensed_item_purchase_id"), + ) + op.create_table( + "resource_tracker_licensed_items_usage", + sa.Column( + "licensed_item_usage_id", + postgresql.UUID(as_uuid=True), + server_default=sa.text("gen_random_uuid()"), + nullable=False, + ), + sa.Column("licensed_item_id", sa.String(), nullable=True), + sa.Column("wallet_id", sa.BigInteger(), nullable=False), + sa.Column("user_id", sa.BigInteger(), nullable=False), + sa.Column("user_email", sa.String(), nullable=True), + sa.Column("product_name", sa.String(), nullable=False), + sa.Column("service_run_id", sa.String(), nullable=True), + sa.Column("started_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("stopped_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("num_of_seats", sa.SmallInteger(), nullable=False), + sa.Column( + "modified", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["product_name", "service_run_id"], + [ + "resource_tracker_service_runs.product_name", + "resource_tracker_service_runs.service_run_id", + ], + name="resource_tracker_license_checkouts_service_run_id_fkey", + onupdate="CASCADE", + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("licensed_item_usage_id"), + ) + op.create_index( + op.f("ix_resource_tracker_licensed_items_usage_wallet_id"), + "resource_tracker_licensed_items_usage", + ["wallet_id"], + unique=False, + ) + op.create_table( + "licensed_items", + sa.Column( + "licensed_item_id", + postgresql.UUID(as_uuid=True), + server_default=sa.text("gen_random_uuid()"), + nullable=False, + ), + sa.Column("name", sa.String(), nullable=False), + sa.Column( + "licensed_resource_type", + sa.Enum("VIP_MODEL", name="licensedresourcetype"), + nullable=False, + ), + sa.Column("pricing_plan_id", sa.BigInteger(), nullable=False), + sa.Column("product_name", sa.String(), 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( + ["pricing_plan_id"], + ["resource_tracker_pricing_plans.pricing_plan_id"], + name="fk_resource_tracker_license_packages_pricing_plan_id", + onupdate="CASCADE", + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["product_name"], + ["products.name"], + name="fk_resource_tracker_license_packages_product_name", + onupdate="CASCADE", + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("licensed_item_id"), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("licensed_items") + op.drop_index( + op.f("ix_resource_tracker_licensed_items_usage_wallet_id"), + table_name="resource_tracker_licensed_items_usage", + ) + op.drop_table("resource_tracker_licensed_items_usage") + op.drop_table("resource_tracker_licensed_items_purchases") + # ### end Alembic commands ### 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 new file mode 100644 index 00000000000..63301eb9c1d --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/models/licensed_items.py @@ -0,0 +1,63 @@ +""" resource_tracker_service_runs table +""" + +import enum + +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + +from ._common import RefActions, column_created_datetime, column_modified_datetime +from .base import metadata + + +class LicensedResourceType(str, enum.Enum): + VIP_MODEL = "VIP_MODEL" + + +licensed_items = sa.Table( + "licensed_items", + metadata, + sa.Column( + "licensed_item_id", + UUID(as_uuid=True), + nullable=False, + primary_key=True, + server_default=sa.text("gen_random_uuid()"), + ), + sa.Column( + "name", + sa.String, + nullable=False, + ), + sa.Column( + "licensed_resource_type", + sa.Enum(LicensedResourceType), + nullable=False, + doc="Item type, ex. VIP_MODEL", + ), + sa.Column( + "pricing_plan_id", + sa.BigInteger, + sa.ForeignKey( + "resource_tracker_pricing_plans.pricing_plan_id", + name="fk_resource_tracker_license_packages_pricing_plan_id", + onupdate=RefActions.CASCADE, + ondelete=RefActions.RESTRICT, + ), + nullable=False, + ), + sa.Column( + "product_name", + sa.String, + sa.ForeignKey( + "products.name", + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, + name="fk_resource_tracker_license_packages_product_name", + ), + nullable=False, + doc="Product name", + ), + column_created_datetime(timezone=True), + column_modified_datetime(timezone=True), +) 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 new file mode 100644 index 00000000000..2a13e3d718e --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_licensed_items_purchases.py @@ -0,0 +1,61 @@ +""" resource_tracker_service_runs table +""" + + +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + +from ._common import column_modified_datetime +from .base import metadata + +resource_tracker_licensed_items_purchases = sa.Table( + "resource_tracker_licensed_items_purchases", + metadata, + sa.Column( + "licensed_item_purchase_id", + UUID(as_uuid=True), + nullable=False, + primary_key=True, + server_default=sa.text("gen_random_uuid()"), + ), + sa.Column( + "product_name", + sa.String, + nullable=False, + doc="Product name", + ), + sa.Column( + "licensed_item_id", + sa.BigInteger, + nullable=False, + ), + sa.Column( + "wallet_id", + sa.BigInteger, + nullable=False, + ), + sa.Column( + "start_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.sql.func.now(), + ), + sa.Column( + "expire_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.sql.func.now(), + ), + sa.Column( + "purchased_by_user", + sa.BigInteger, + nullable=False, + ), + sa.Column( + "purchased_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.sql.func.now(), + ), + column_modified_datetime(timezone=True), +) diff --git a/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_licensed_items_usage.py b/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_licensed_items_usage.py new file mode 100644 index 00000000000..27d6afe8250 --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_licensed_items_usage.py @@ -0,0 +1,76 @@ +""" resource_tracker_service_runs table +""" + +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + +from ._common import RefActions, column_modified_datetime +from .base import metadata + +resource_tracker_licensed_items_usage = sa.Table( + "resource_tracker_licensed_items_usage", + metadata, + sa.Column( + "licensed_item_usage_id", + UUID(as_uuid=True), + nullable=False, + primary_key=True, + server_default=sa.text("gen_random_uuid()"), + ), + sa.Column( + "licensed_item_id", + sa.String, + nullable=True, + ), + sa.Column( + "wallet_id", + sa.BigInteger, + nullable=False, + index=True, + ), + sa.Column( + "user_id", + sa.BigInteger, + nullable=False, + ), + sa.Column( + "user_email", + sa.String, + nullable=True, + ), + sa.Column("product_name", sa.String, nullable=False, doc="Product name"), + sa.Column( + "service_run_id", + sa.String, + nullable=True, + ), + sa.Column( + "started_at", + sa.DateTime(timezone=True), + nullable=False, + doc="Timestamp when the service was started", + ), + sa.Column( + "stopped_at", + sa.DateTime(timezone=True), + nullable=True, + doc="Timestamp when the service was stopped", + ), + sa.Column( + "num_of_seats", + sa.SmallInteger, + nullable=False, + ), + column_modified_datetime(timezone=True), + # --------------------------- + sa.ForeignKeyConstraint( + ["product_name", "service_run_id"], + [ + "resource_tracker_service_runs.product_name", + "resource_tracker_service_runs.service_run_id", + ], + name="resource_tracker_license_checkouts_service_run_id_fkey", + onupdate=RefActions.CASCADE, + ondelete=RefActions.RESTRICT, + ), +) diff --git a/packages/postgres-database/src/simcore_postgres_database/utils_repos.py b/packages/postgres-database/src/simcore_postgres_database/utils_repos.py index f2c96313ea9..e013a09b526 100644 --- a/packages/postgres-database/src/simcore_postgres_database/utils_repos.py +++ b/packages/postgres-database/src/simcore_postgres_database/utils_repos.py @@ -11,6 +11,10 @@ async def pass_or_acquire_connection( engine: AsyncEngine, connection: AsyncConnection | None = None ) -> AsyncIterator[AsyncConnection]: + """ + When to use: For READ operations! + It ensures that a connection is available for use within the context, either by using an existing connection passed as a parameter or by acquiring a new one from the engine. The caller must manage the lifecycle of any connection explicitly passed in, but the function handles the cleanup for connections it creates itself. This function **does not open new transactions** and therefore is recommended only for read-only database operations. + """ # NOTE: When connection is passed, the engine is actually not needed # NOTE: Creator is responsible of closing connection is_connection_created = connection is None @@ -30,6 +34,10 @@ async def pass_or_acquire_connection( async def transaction_context( engine: AsyncEngine, connection: AsyncConnection | None = None ): + """ + When to use: For WRITE operations! + This function manages the database connection and ensures that a transaction context is established for write operations. It supports both outer and nested transactions, providing flexibility for scenarios where transactions may already exist in the calling context. + """ async with pass_or_acquire_connection(engine, connection) as conn: if conn.in_transaction(): async with conn.begin_nested(): # inner transaction (savepoint) 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 29338d83996..829aa2be9c2 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 @@ -2347,6 +2347,108 @@ paths: application/json: schema: $ref: '#/components/schemas/Envelope_CatalogServiceGet_' + /v0/catalog/licensed-items: + get: + tags: + - licenses + - catalog + summary: List Licensed Items + operationId: list_licensed_items + parameters: + - name: order_by + in: query + required: false + schema: + type: string + contentMediaType: application/json + contentSchema: {} + default: '{"field":"modified","direction":"desc"}' + title: Order By + - name: limit + in: query + required: false + schema: + type: integer + default: 20 + title: Limit + - name: offset + in: query + required: false + schema: + type: integer + default: 0 + title: Offset + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope_list_LicensedItemGet__' + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Not Found + /v0/catalog/licensed-items/{licensed_item_id}: + get: + tags: + - licenses + - catalog + summary: Get Licensed Item + operationId: get_licensed_item + parameters: + - name: licensed_item_id + in: path + required: true + schema: + type: string + format: uuid + title: Licensed Item Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope_LicensedItemGet_' + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Not Found + /v0/catalog/licensed-items/{licensed_item_id}:purchase: + post: + tags: + - licenses + - catalog + summary: Purchase Licensed Item + operationId: purchase_licensed_item + parameters: + - name: licensed_item_id + in: path + required: true + schema: + type: string + format: uuid + title: Licensed Item Id + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LicensedItemsBodyParams' + responses: + '204': + description: Successful Response + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Not Found /v0/computations/{project_id}: get: tags: @@ -7710,6 +7812,19 @@ components: title: Error type: object title: Envelope[InvitationInfo] + Envelope_LicensedItemGet_: + properties: + data: + anyOf: + - $ref: '#/components/schemas/LicensedItemGet' + - type: 'null' + error: + anyOf: + - {} + - type: 'null' + title: Error + type: object + title: Envelope[LicensedItemGet] Envelope_Log_: properties: data: @@ -8483,6 +8598,22 @@ components: title: Error type: object title: Envelope[list[GroupUserGet]] + Envelope_list_LicensedItemGet__: + properties: + data: + anyOf: + - items: + $ref: '#/components/schemas/LicensedItemGet' + type: array + - type: 'null' + title: Data + error: + anyOf: + - {} + - type: 'null' + title: Error + type: object + title: Envelope[list[LicensedItemGet]] Envelope_list_OsparcCreditsAggregatedByServiceGet__: properties: data: @@ -9878,6 +10009,61 @@ components: additionalProperties: false type: object title: InvitationInfo + LicensedItemGet: + properties: + licensedItemId: + type: string + format: uuid + title: Licenseditemid + name: + type: string + title: Name + licensedResourceType: + $ref: '#/components/schemas/LicensedResourceType' + pricingPlanId: + type: integer + exclusiveMinimum: true + title: Pricingplanid + minimum: 0 + createdAt: + type: string + format: date-time + title: Createdat + modifiedAt: + type: string + format: date-time + title: Modifiedat + type: object + required: + - licensedItemId + - name + - licensedResourceType + - pricingPlanId + - createdAt + - modifiedAt + title: LicensedItemGet + LicensedItemsBodyParams: + properties: + wallet_id: + type: integer + exclusiveMinimum: true + title: Wallet Id + minimum: 0 + num_of_seeds: + type: integer + title: Num Of Seeds + additionalProperties: false + type: object + required: + - wallet_id + - num_of_seeds + title: LicensedItemsBodyParams + LicensedResourceType: + type: string + enum: + - VIP_MODEL + const: VIP_MODEL + title: LicensedResourceType Limits: properties: cpus: @@ -14034,9 +14220,7 @@ components: required: - walletId - name - - description - owner - - thumbnail - status - created - modified @@ -14085,9 +14269,7 @@ components: required: - walletId - name - - description - owner - - thumbnail - status - created - modified diff --git a/services/web/server/src/simcore_service_webserver/application_settings.py b/services/web/server/src/simcore_service_webserver/application_settings.py index ed4e519141b..b15cd73e1c8 100644 --- a/services/web/server/src/simcore_service_webserver/application_settings.py +++ b/services/web/server/src/simcore_service_webserver/application_settings.py @@ -271,6 +271,7 @@ class ApplicationSettings(BaseCustomSettings, MixinLoggingSettings): WEBSERVER_DB_LISTENER: bool = True WEBSERVER_FOLDERS: bool = True WEBSERVER_GROUPS: bool = True + WEBSERVER_LICENSES: bool = True WEBSERVER_META_MODELING: bool = True WEBSERVER_NOTIFICATIONS: bool = Field(default=True) WEBSERVER_PRODUCTS: bool = True diff --git a/services/web/server/src/simcore_service_webserver/catalog/licenses/__init__.py b/services/web/server/src/simcore_service_webserver/catalog/licenses/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/services/web/server/src/simcore_service_webserver/catalog/licenses/_exceptions_handlers.py b/services/web/server/src/simcore_service_webserver/catalog/licenses/_exceptions_handlers.py new file mode 100644 index 00000000000..0abb7671b16 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/catalog/licenses/_exceptions_handlers.py @@ -0,0 +1,26 @@ +import logging + +from servicelib.aiohttp import status + +from ...exception_handling import ( + ExceptionToHttpErrorMap, + HttpErrorInfo, + exception_handling_decorator, + to_exceptions_handlers_map, +) +from .errors import LicensedItemNotFoundError + +_logger = logging.getLogger(__name__) + + +_TO_HTTP_ERROR_MAP: ExceptionToHttpErrorMap = { + LicensedItemNotFoundError: HttpErrorInfo( + status.HTTP_404_NOT_FOUND, + "Market item {licensed_item_id} not found.", + ) +} + + +handle_plugin_requests_exceptions = exception_handling_decorator( + to_exceptions_handlers_map(_TO_HTTP_ERROR_MAP) +) diff --git a/services/web/server/src/simcore_service_webserver/catalog/licenses/_licensed_items_api.py b/services/web/server/src/simcore_service_webserver/catalog/licenses/_licensed_items_api.py new file mode 100644 index 00000000000..bb024b0423b --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/catalog/licenses/_licensed_items_api.py @@ -0,0 +1,77 @@ +# pylint: disable=unused-argument + +import logging + +from aiohttp import web +from models_library.api_schemas_webserver.licensed_items import ( + LicensedItemGet, + LicensedItemGetPage, +) +from models_library.licensed_items import LicensedItemID +from models_library.products import ProductName +from models_library.rest_ordering import OrderBy +from models_library.users import UserID +from pydantic import NonNegativeInt + +from . import _licensed_items_db +from ._models import LicensedItemsBodyParams + +_logger = logging.getLogger(__name__) + + +async def get_licensed_item( + app: web.Application, + *, + licensed_item_id: LicensedItemID, + product_name: ProductName, +) -> LicensedItemGet: + + licensed_item_db = await _licensed_items_db.get( + app, licensed_item_id=licensed_item_id, product_name=product_name + ) + return LicensedItemGet( + licensed_item_id=licensed_item_db.licensed_item_id, + name=licensed_item_db.name, + licensed_resource_type=licensed_item_db.licensed_resource_type, + pricing_plan_id=licensed_item_db.pricing_plan_id, + created_at=licensed_item_db.created, + modified_at=licensed_item_db.modified, + ) + + +async def list_licensed_items( + app: web.Application, + *, + product_name: ProductName, + offset: NonNegativeInt, + limit: int, + order_by: OrderBy, +) -> LicensedItemGetPage: + total_count, licensed_item_db_list = await _licensed_items_db.list_( + app, product_name=product_name, offset=offset, limit=limit, order_by=order_by + ) + return LicensedItemGetPage( + items=[ + LicensedItemGet( + licensed_item_id=licensed_item_db.licensed_item_id, + name=licensed_item_db.name, + licensed_resource_type=licensed_item_db.licensed_resource_type, + pricing_plan_id=licensed_item_db.pricing_plan_id, + created_at=licensed_item_db.created, + modified_at=licensed_item_db.modified, + ) + for licensed_item_db in licensed_item_db_list + ], + total=total_count, + ) + + +async def purchase_licensed_item( + app: web.Application, + *, + product_name: ProductName, + user_id: UserID, + licensed_item_id: LicensedItemID, + body_params: LicensedItemsBodyParams, +) -> None: + raise NotImplementedError diff --git a/services/web/server/src/simcore_service_webserver/catalog/licenses/_licensed_items_db.py b/services/web/server/src/simcore_service_webserver/catalog/licenses/_licensed_items_db.py new file mode 100644 index 00000000000..fc14221ff91 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/catalog/licenses/_licensed_items_db.py @@ -0,0 +1,181 @@ +""" Database API + + - Adds a layer to the postgres API with a focus on the projects comments + +""" + +import logging +from typing import cast + +from aiohttp import web +from models_library.licensed_items import ( + LicensedItemDB, + LicensedItemID, + LicensedItemUpdateDB, + 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_items import licensed_items +from simcore_postgres_database.utils_repos import ( + pass_or_acquire_connection, + transaction_context, +) +from sqlalchemy import asc, desc, func +from sqlalchemy.ext.asyncio import AsyncConnection +from sqlalchemy.sql import select + +from ...db.plugin import get_asyncpg_engine +from .errors import LicensedItemNotFoundError + +_logger = logging.getLogger(__name__) + + +_SELECTION_ARGS = ( + licensed_items.c.licensed_item_id, + licensed_items.c.name, + licensed_items.c.licensed_resource_type, + licensed_items.c.pricing_plan_id, + licensed_items.c.product_name, + licensed_items.c.created, + licensed_items.c.modified, +) + +assert set(LicensedItemDB.model_fields) == {c.name for c in _SELECTION_ARGS} # nosec + + +async def create( + app: web.Application, + connection: AsyncConnection | None = None, + *, + product_name: ProductName, + name: str, + licensed_resource_type: LicensedResourceType, + pricing_plan_id: PricingPlanId, +) -> LicensedItemDB: + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + result = await conn.stream( + licensed_items.insert() + .values( + name=name, + licensed_resource_type=licensed_resource_type, + pricing_plan_id=pricing_plan_id, + product_name=product_name, + created=func.now(), + modified=func.now(), + ) + .returning(*_SELECTION_ARGS) + ) + row = await result.first() + return LicensedItemDB.model_validate(row) + + +async def list_( + app: web.Application, + connection: AsyncConnection | None = None, + *, + product_name: ProductName, + offset: NonNegativeInt, + limit: NonNegativeInt, + order_by: OrderBy, +) -> tuple[int, list[LicensedItemDB]]: + base_query = ( + select(*_SELECTION_ARGS) + .select_from(licensed_items) + .where(licensed_items.c.product_name == product_name) + ) + + # 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))) + else: + list_query = base_query.order_by( + desc(getattr(licensed_items.c, order_by.field)) + ) + 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[LicensedItemDB] = [ + LicensedItemDB.model_validate(row) async for row in result + ] + + return cast(int, total_count), items + + +async def get( + app: web.Application, + connection: AsyncConnection | None = None, + *, + licensed_item_id: LicensedItemID, + product_name: ProductName, +) -> LicensedItemDB: + base_query = ( + select(*_SELECTION_ARGS) + .select_from(licensed_items) + .where( + (licensed_items.c.licensed_item_id == licensed_item_id) + & (licensed_items.c.product_name == product_name) + ) + ) + + async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: + result = await conn.stream(base_query) + row = await result.first() + if row is None: + raise LicensedItemNotFoundError(licensed_item_id=licensed_item_id) + return LicensedItemDB.model_validate(row) + + +async def update( + app: web.Application, + connection: AsyncConnection | None = None, + *, + product_name: ProductName, + licensed_item_id: LicensedItemID, + updates: LicensedItemUpdateDB, +) -> LicensedItemDB: + # NOTE: at least 'touch' if updated_values is empty + _updates = { + **updates.dict(exclude_unset=True), + "modified": func.now(), + } + + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + result = await conn.stream( + licensed_items.update() + .values(**_updates) + .where( + (licensed_items.c.licensed_item_id == licensed_item_id) + & (licensed_items.c.product_name == product_name) + ) + .returning(*_SELECTION_ARGS) + ) + row = await result.first() + if row is None: + raise LicensedItemNotFoundError(licensed_item_id=licensed_item_id) + return LicensedItemDB.model_validate(row) + + +async def delete( + app: web.Application, + connection: AsyncConnection | None = None, + *, + licensed_item_id: LicensedItemID, + product_name: ProductName, +) -> None: + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + await conn.execute( + licensed_items.delete().where( + (licensed_items.c.licensed_item_id == licensed_item_id) + & (licensed_items.c.product_name == product_name) + ) + ) diff --git a/services/web/server/src/simcore_service_webserver/catalog/licenses/_licensed_items_handlers.py b/services/web/server/src/simcore_service_webserver/catalog/licenses/_licensed_items_handlers.py new file mode 100644 index 00000000000..6ed227500e5 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/catalog/licenses/_licensed_items_handlers.py @@ -0,0 +1,112 @@ +import logging + +from aiohttp import web +from models_library.api_schemas_webserver.licensed_items import ( + LicensedItemGet, + LicensedItemGetPage, +) +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, + parse_request_query_parameters_as, +) +from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON +from servicelib.rest_constants import RESPONSE_MODEL_POLICY + +from ..._meta import API_VTAG as VTAG +from ...login.decorators import login_required +from ...security.decorators import permission_required +from ...utils_aiohttp import envelope_json_response +from . import _licensed_items_api +from ._exceptions_handlers import handle_plugin_requests_exceptions +from ._models import ( + LicensedItemsBodyParams, + LicensedItemsListQueryParams, + LicensedItemsPathParams, + LicensedItemsRequestContext, +) + +_logger = logging.getLogger(__name__) + + +routes = web.RouteTableDef() + + +@routes.get(f"/{VTAG}/catalog/licensed-items", name="list_licensed_items") +@login_required +@permission_required("catalog/licensed-items.*") +@handle_plugin_requests_exceptions +async def list_workspaces(request: web.Request): + req_ctx = LicensedItemsRequestContext.model_validate(request) + query_params: LicensedItemsListQueryParams = parse_request_query_parameters_as( + LicensedItemsListQueryParams, request + ) + + licensed_item_get_page: LicensedItemGetPage = ( + await _licensed_items_api.list_licensed_items( + app=request.app, + product_name=req_ctx.product_name, + offset=query_params.offset, + limit=query_params.limit, + order_by=OrderBy.model_construct(**query_params.order_by.model_dump()), + ) + ) + + page = Page[LicensedItemGet].model_validate( + paginate_data( + chunk=licensed_item_get_page.items, + request_url=request.url, + total=licensed_item_get_page.total, + limit=query_params.limit, + offset=query_params.offset, + ) + ) + return web.Response( + text=page.model_dump_json(**RESPONSE_MODEL_POLICY), + content_type=MIMETYPE_APPLICATION_JSON, + ) + + +@routes.get( + f"/{VTAG}/catalog/licensed-items/{{licensed_item_id}}", name="get_licensed_item" +) +@login_required +@permission_required("catalog/licensed-items.*") +@handle_plugin_requests_exceptions +async def get_workspace(request: web.Request): + req_ctx = LicensedItemsRequestContext.model_validate(request) + path_params = parse_request_path_parameters_as(LicensedItemsPathParams, request) + + licensed_item_get: LicensedItemGet = await _licensed_items_api.get_licensed_item( + app=request.app, + licensed_item_id=path_params.licensed_item_id, + product_name=req_ctx.product_name, + ) + + return envelope_json_response(licensed_item_get) + + +@routes.post( + f"/{VTAG}/catalog/licensed-items/{{licensed_item_id}}:purchase", + name="purchase_licensed_item", +) +@login_required +@permission_required("catalog/licensed-items.*") +@handle_plugin_requests_exceptions +async def purchase_licensed_item(request: web.Request): + req_ctx = LicensedItemsRequestContext.model_validate(request) + path_params = parse_request_path_parameters_as(LicensedItemsPathParams, request) + body_params = await parse_request_body_as(LicensedItemsBodyParams, request) + + await _licensed_items_api.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) diff --git a/services/web/server/src/simcore_service_webserver/catalog/licenses/_models.py b/services/web/server/src/simcore_service_webserver/catalog/licenses/_models.py new file mode 100644 index 00000000000..40d287faa92 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/catalog/licenses/_models.py @@ -0,0 +1,54 @@ +import logging + +from models_library.basic_types import IDStr +from models_library.licensed_items import LicensedItemID +from models_library.rest_base import RequestParameters, StrictRequestParameters +from models_library.rest_ordering import ( + OrderBy, + OrderDirection, + create_ordering_query_model_class, +) +from models_library.rest_pagination import PageQueryParameters +from models_library.users import UserID +from models_library.wallets import WalletID +from pydantic import BaseModel, ConfigDict, Field +from servicelib.request_keys import RQT_USERID_KEY + +from ..._constants import RQ_PRODUCT_KEY + +_logger = logging.getLogger(__name__) + + +class LicensedItemsRequestContext(RequestParameters): + user_id: UserID = Field(..., alias=RQT_USERID_KEY) # type: ignore[literal-required] + product_name: str = Field(..., alias=RQ_PRODUCT_KEY) # type: ignore[literal-required] + + +class LicensedItemsPathParams(StrictRequestParameters): + licensed_item_id: LicensedItemID + + +_LicensedItemsListOrderQueryParams: type[ + RequestParameters +] = create_ordering_query_model_class( + ordering_fields={ + "modified_at", + "name", + }, + default=OrderBy(field=IDStr("modified_at"), direction=OrderDirection.DESC), + ordering_fields_api_to_column_map={"modified_at": "modified"}, +) + + +class LicensedItemsListQueryParams( + PageQueryParameters, + _LicensedItemsListOrderQueryParams, # type: ignore[misc, valid-type] +): + ... + + +class LicensedItemsBodyParams(BaseModel): + wallet_id: WalletID + num_of_seeds: int + + model_config = ConfigDict(extra="forbid") diff --git a/services/web/server/src/simcore_service_webserver/catalog/licenses/api.py b/services/web/server/src/simcore_service_webserver/catalog/licenses/api.py new file mode 100644 index 00000000000..07b8034ea85 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/catalog/licenses/api.py @@ -0,0 +1,3 @@ +# mypy: disable-error-code=truthy-function + +__all__: tuple[str, ...] = () diff --git a/services/web/server/src/simcore_service_webserver/catalog/licenses/errors.py b/services/web/server/src/simcore_service_webserver/catalog/licenses/errors.py new file mode 100644 index 00000000000..0c8bae69b03 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/catalog/licenses/errors.py @@ -0,0 +1,9 @@ +from ...errors import WebServerBaseError + + +class LicensesValueError(WebServerBaseError, ValueError): + ... + + +class LicensedItemNotFoundError(LicensesValueError): + msg_template = "License good {licensed_item_id} not found" diff --git a/services/web/server/src/simcore_service_webserver/catalog/licenses/plugin.py b/services/web/server/src/simcore_service_webserver/catalog/licenses/plugin.py new file mode 100644 index 00000000000..ef124c69fad --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/catalog/licenses/plugin.py @@ -0,0 +1,26 @@ +""" tags management subsystem + +""" +import logging + +from aiohttp import web +from servicelib.aiohttp.application_keys import APP_SETTINGS_KEY +from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup + +from . import _licensed_items_handlers + +_logger = logging.getLogger(__name__) + + +@app_module_setup( + __name__, + ModuleCategory.ADDON, + settings_name="WEBSERVER_LICENSES", + depends=["simcore_service_webserver.rest"], + logger=_logger, +) +def setup_licenses(app: web.Application): + assert app[APP_SETTINGS_KEY].WEBSERVER_LICENSES # nosec + + # routes + app.router.add_routes(_licensed_items_handlers.routes) diff --git a/services/web/server/src/simcore_service_webserver/catalog/plugin.py b/services/web/server/src/simcore_service_webserver/catalog/plugin.py index 2af8da917f0..74c36bcbcb4 100644 --- a/services/web/server/src/simcore_service_webserver/catalog/plugin.py +++ b/services/web/server/src/simcore_service_webserver/catalog/plugin.py @@ -9,6 +9,7 @@ from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup from . import _handlers, _tags_handlers +from .licenses.plugin import setup_licenses _logger = logging.getLogger(__name__) @@ -27,6 +28,8 @@ def setup_catalog(app: web.Application): for route_def in _handlers.routes ) + setup_licenses(app) + app.add_routes(_handlers.routes) app.add_routes(_tags_handlers.routes) diff --git a/services/web/server/src/simcore_service_webserver/security/_authz_access_roles.py b/services/web/server/src/simcore_service_webserver/security/_authz_access_roles.py index 919486962d3..da342e1b996 100644 --- a/services/web/server/src/simcore_service_webserver/security/_authz_access_roles.py +++ b/services/web/server/src/simcore_service_webserver/security/_authz_access_roles.py @@ -59,6 +59,7 @@ class PermissionDict(TypedDict, total=False): "folder.delete", "folder.access_rights.update", "groups.*", + "catalog/licensed-items.*", "product.price.read", "project.folders.*", "project.access_rights.update", 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 new file mode 100644 index 00000000000..5971ed9f168 --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/04/licenses/conftest.py @@ -0,0 +1,44 @@ +from collections.abc import AsyncIterator + +# pylint:disable=unused-variable +# pylint:disable=unused-argument +# pylint:disable=redefined-outer-name +import pytest +from aiohttp.test_utils import TestClient +from simcore_postgres_database.models.licensed_items import licensed_items +from simcore_postgres_database.models.resource_tracker_pricing_plans import ( + resource_tracker_pricing_plans, +) +from simcore_postgres_database.utils_repos import transaction_context +from simcore_service_webserver.db.plugin import get_asyncpg_engine + + +@pytest.fixture +async def pricing_plan_id( + client: TestClient, + osparc_product_name: str, +) -> AsyncIterator[int]: + assert client.app + + async with transaction_context(get_asyncpg_engine(client.app)) as conn: + result = await conn.execute( + resource_tracker_pricing_plans.insert() + .values( + product_name=osparc_product_name, + display_name="ISolve Thermal", + description="", + classification="TIER", + is_active=True, + pricing_plan_key="isolve-thermal", + ) + .returning(resource_tracker_pricing_plans.c.pricing_plan_id) + ) + row = result.first() + + assert row + + yield int(row[0]) + + async with transaction_context(get_asyncpg_engine(client.app)) as conn: + await conn.execute(licensed_items.delete()) + await conn.execute(resource_tracker_pricing_plans.delete()) diff --git a/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_db.py b/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_db.py new file mode 100644 index 00000000000..5455c280cd7 --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_db.py @@ -0,0 +1,94 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments +# pylint: disable=too-many-statements +from http import HTTPStatus + +import pytest +from aiohttp.test_utils import TestClient +from models_library.licensed_items import ( + LicensedItemDB, + LicensedItemUpdateDB, + LicensedResourceType, +) +from models_library.rest_ordering import OrderBy +from pytest_simcore.helpers.webserver_login import UserInfoDict +from servicelib.aiohttp import status +from simcore_service_webserver.catalog.licenses import _licensed_items_db +from simcore_service_webserver.catalog.licenses.errors import LicensedItemNotFoundError +from simcore_service_webserver.db.models import UserRole +from simcore_service_webserver.projects.models import ProjectDict + + +@pytest.mark.parametrize("user_role,expected", [(UserRole.USER, status.HTTP_200_OK)]) +async def test_licensed_items_db_crud( + client: TestClient, + logged_user: UserInfoDict, + user_project: ProjectDict, + osparc_product_name: str, + expected: HTTPStatus, + pricing_plan_id: int, +): + assert client.app + + output: tuple[int, list[LicensedItemDB]] = await _licensed_items_db.list_( + client.app, + product_name=osparc_product_name, + offset=0, + limit=10, + order_by=OrderBy(field="modified"), + ) + assert output[0] == 0 + + licensed_item_db = await _licensed_items_db.create( + client.app, + product_name=osparc_product_name, + name="Model A", + licensed_resource_type=LicensedResourceType.VIP_MODEL, + pricing_plan_id=pricing_plan_id, + ) + _licensed_item_id = licensed_item_db.licensed_item_id + + output: tuple[int, list[LicensedItemDB]] = await _licensed_items_db.list_( + client.app, + product_name=osparc_product_name, + offset=0, + limit=10, + order_by=OrderBy(field="modified"), + ) + assert output[0] == 1 + + licensed_item_db = await _licensed_items_db.get( + client.app, + licensed_item_id=_licensed_item_id, + product_name=osparc_product_name, + ) + assert licensed_item_db.name == "Model A" + + await _licensed_items_db.update( + client.app, + licensed_item_id=_licensed_item_id, + product_name=osparc_product_name, + updates=LicensedItemUpdateDB(name="Model B"), + ) + + licensed_item_db = await _licensed_items_db.get( + client.app, + licensed_item_id=_licensed_item_id, + product_name=osparc_product_name, + ) + assert licensed_item_db.name == "Model B" + + licensed_item_db = await _licensed_items_db.delete( + client.app, + licensed_item_id=_licensed_item_id, + product_name=osparc_product_name, + ) + + with pytest.raises(LicensedItemNotFoundError): + await _licensed_items_db.get( + client.app, + licensed_item_id=_licensed_item_id, + product_name=osparc_product_name, + ) diff --git a/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_handlers.py b/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_handlers.py new file mode 100644 index 00000000000..eb63d9bb75a --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_handlers.py @@ -0,0 +1,66 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments +# pylint: disable=too-many-statements +from http import HTTPStatus + +import pytest +from aiohttp.test_utils import TestClient +from models_library.api_schemas_webserver.licensed_items import LicensedItemGet +from models_library.licensed_items import LicensedResourceType +from pytest_simcore.helpers.assert_checks import assert_status +from pytest_simcore.helpers.webserver_login import UserInfoDict +from servicelib.aiohttp import status +from simcore_service_webserver.catalog.licenses import _licensed_items_db +from simcore_service_webserver.db.models import UserRole +from simcore_service_webserver.projects.models import ProjectDict + + +@pytest.mark.parametrize("user_role,expected", [(UserRole.USER, status.HTTP_200_OK)]) +async def test_licensed_items_db_crud( + client: TestClient, + logged_user: UserInfoDict, + user_project: ProjectDict, + osparc_product_name: str, + expected: HTTPStatus, + pricing_plan_id: int, +): + assert client.app + + # list + url = client.app.router["list_licensed_items"].url_for() + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert data == [] + + licensed_item_db = await _licensed_items_db.create( + client.app, + product_name=osparc_product_name, + name="Model A", + licensed_resource_type=LicensedResourceType.VIP_MODEL, + pricing_plan_id=pricing_plan_id, + ) + _licensed_item_id = licensed_item_db.licensed_item_id + + # list + url = client.app.router["list_licensed_items"].url_for() + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(data) == 1 + assert LicensedItemGet(**data[0]) + + # get + url = client.app.router["get_licensed_item"].url_for( + licensed_item_id=f"{_licensed_item_id}" + ) + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert LicensedItemGet(**data) + + # purchase + url = client.app.router["purchase_licensed_item"].url_for( + licensed_item_id=f"{_licensed_item_id}" + ) + resp = await client.post(f"{url}", json={"wallet_id": 1, "num_of_seeds": 5}) + # NOTE: Not yet implemented diff --git a/tests/swarm-deploy/requirements/_test.txt b/tests/swarm-deploy/requirements/_test.txt index fd400882d27..ddd2910ff59 100644 --- a/tests/swarm-deploy/requirements/_test.txt +++ b/tests/swarm-deploy/requirements/_test.txt @@ -120,7 +120,7 @@ certifi==2024.8.30 # -c requirements/../../../requirements/constraints.txt # -r requirements/../../../packages/postgres-database/requirements/_migration.txt # requests -charset-normalizer==3.3.2 +charset-normalizer==3.4.0 # via # -r requirements/../../../packages/postgres-database/requirements/_migration.txt # requests