diff --git a/api/specs/web-server/_resource_usage.py b/api/specs/web-server/_resource_usage.py index 2f9b1213b043..e48e8030d5ef 100644 --- a/api/specs/web-server/_resource_usage.py +++ b/api/specs/web-server/_resource_usage.py @@ -21,6 +21,7 @@ CreatePricingPlanBodyParams, CreatePricingUnitBodyParams, PricingPlanAdminGet, + PricingPlanGet, PricingPlanToServiceAdminGet, PricingUnitAdminGet, PricingUnitGet, @@ -29,15 +30,16 @@ UpdatePricingUnitBodyParams, ) from models_library.generics import Envelope +from models_library.rest_pagination import Page, PageQueryParameters from simcore_service_webserver._meta import API_VTAG -from simcore_service_webserver.resource_usage._pricing_plans_admin_handlers import ( +from simcore_service_webserver.resource_usage._pricing_plans_admin_rest import ( PricingPlanGetPathParams, PricingUnitGetPathParams, ) -from simcore_service_webserver.resource_usage._pricing_plans_handlers import ( +from simcore_service_webserver.resource_usage._pricing_plans_rest import ( PricingPlanUnitGetPathParams, ) -from simcore_service_webserver.resource_usage._service_runs_handlers import ( +from simcore_service_webserver.resource_usage._service_runs_rest import ( ServicesAggregatedUsagesListQueryParams, ServicesResourceUsagesListQueryParams, ServicesResourceUsagesReportQueryParams, @@ -48,7 +50,7 @@ @router.get( "/services/-/resource-usages", - response_model=Envelope[list[ServiceRunGet]], + response_model=Page[ServiceRunGet], summary="Retrieve finished and currently running user services" " (user and product are taken from context, optionally wallet_id parameter might be provided).", tags=["usage"], @@ -61,7 +63,7 @@ async def list_resource_usage_services( @router.get( "/services/-/aggregated-usages", - response_model=Envelope[list[OsparcCreditsAggregatedByServiceGet]], + response_model=Page[OsparcCreditsAggregatedByServiceGet], summary="Used credits based on aggregate by type, currently supported `services`" ". (user and product are taken from context, optionally wallet_id parameter might be provided).", tags=["usage"], @@ -93,7 +95,6 @@ async def export_resource_usage_services( @router.get( "/pricing-plans/{pricing_plan_id}/pricing-units/{pricing_unit_id}", response_model=Envelope[PricingUnitGet], - summary="Retrieve detail information about pricing unit", tags=["pricing-plans"], ) async def get_pricing_plan_unit( @@ -102,27 +103,50 @@ async def get_pricing_plan_unit( ... +@router.get( + "/pricing-plans", + response_model=Page[PricingPlanGet], + tags=["pricing-plans"], + description="To keep the listing lightweight, the pricingUnits field is None.", +) +async def list_pricing_plans( + _query: Annotated[as_query(PageQueryParameters), Depends()] +): + ... + + +@router.get( + "/pricing-plans/{pricing_plan_id}", + response_model=Envelope[PricingPlanGet], + tags=["pricing-plans"], +) +async def get_pricing_plan( + _path: Annotated[PricingPlanGetPathParams, Depends()], +): + ... + + ## Pricing plans for Admin panel @router.get( "/admin/pricing-plans", - response_model=Envelope[list[PricingPlanAdminGet]], - summary="List pricing plans", + response_model=Page[PricingPlanAdminGet], tags=["admin"], description="To keep the listing lightweight, the pricingUnits field is None.", ) -async def list_pricing_plans(): +async def list_pricing_plans_for_admin_user( + _query: Annotated[as_query(PageQueryParameters), Depends()] +): ... @router.get( "/admin/pricing-plans/{pricing_plan_id}", response_model=Envelope[PricingPlanAdminGet], - summary="Retrieve detail information about pricing plan", tags=["admin"], ) -async def get_pricing_plan( +async def get_pricing_plan_for_admin_user( _path: Annotated[PricingPlanGetPathParams, Depends()], ): ... @@ -131,7 +155,6 @@ async def get_pricing_plan( @router.post( "/admin/pricing-plans", response_model=Envelope[PricingPlanAdminGet], - summary="Create pricing plan", tags=["admin"], ) async def create_pricing_plan( @@ -143,7 +166,6 @@ async def create_pricing_plan( @router.put( "/admin/pricing-plans/{pricing_plan_id}", response_model=Envelope[PricingPlanAdminGet], - summary="Update detail information about pricing plan", tags=["admin"], ) async def update_pricing_plan( @@ -159,7 +181,6 @@ async def update_pricing_plan( @router.get( "/admin/pricing-plans/{pricing_plan_id}/pricing-units/{pricing_unit_id}", response_model=Envelope[PricingUnitAdminGet], - summary="Retrieve detail information about pricing unit", tags=["admin"], ) async def get_pricing_unit( @@ -171,7 +192,6 @@ async def get_pricing_unit( @router.post( "/admin/pricing-plans/{pricing_plan_id}/pricing-units", response_model=Envelope[PricingUnitAdminGet], - summary="Create pricing unit", tags=["admin"], ) async def create_pricing_unit( @@ -184,7 +204,6 @@ async def create_pricing_unit( @router.put( "/admin/pricing-plans/{pricing_plan_id}/pricing-units/{pricing_unit_id}", response_model=Envelope[PricingUnitAdminGet], - summary="Update detail information about pricing plan", tags=["admin"], ) async def update_pricing_unit( @@ -200,7 +219,6 @@ async def update_pricing_unit( @router.get( "/admin/pricing-plans/{pricing_plan_id}/billable-services", response_model=Envelope[list[PricingPlanToServiceAdminGet]], - summary="List services that are connected to the provided pricing plan", tags=["admin"], ) async def list_connected_services_to_pricing_plan( @@ -212,7 +230,6 @@ async def list_connected_services_to_pricing_plan( @router.post( "/admin/pricing-plans/{pricing_plan_id}/billable-services", response_model=Envelope[PricingPlanToServiceAdminGet], - summary="Connect service with pricing plan", tags=["admin"], ) async def connect_service_to_pricing_plan( 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 f14117b34391..bdf578bfe82a 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 @@ -17,6 +17,7 @@ class LicensedItemCheckoutGet(BaseModel): licensed_item_id: LicensedItemID wallet_id: WalletID user_id: UserID + user_email: str product_name: ProductName service_run_id: ServiceRunID started_at: datetime @@ -31,6 +32,7 @@ class LicensedItemCheckoutGet(BaseModel): "licensed_item_id": "303942ef-6d31-4ba8-afbe-dbb1fce2a953", "wallet_id": 1, "user_id": 1, + "user_email": "test@test.com", "product_name": "osparc", "service_run_id": "run_1", "started_at": "2023-01-11 13:11:47.293595", 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 e75965a1b53e..f6a288cb126c 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 @@ -25,6 +25,7 @@ class LicensedItemPurchaseGet(BaseModel): expire_at: datetime num_of_seats: int purchased_by_user: UserID + user_email: str purchased_at: datetime modified: datetime @@ -43,6 +44,7 @@ class LicensedItemPurchaseGet(BaseModel): "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", } diff --git a/packages/models-library/src/models_library/api_schemas_resource_usage_tracker/pricing_plans.py b/packages/models-library/src/models_library/api_schemas_resource_usage_tracker/pricing_plans.py index bbb5d52f9066..0fd494dc998c 100644 --- a/packages/models-library/src/models_library/api_schemas_resource_usage_tracker/pricing_plans.py +++ b/packages/models-library/src/models_library/api_schemas_resource_usage_tracker/pricing_plans.py @@ -1,7 +1,8 @@ from datetime import datetime from decimal import Decimal +from typing import NamedTuple -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, PositiveInt from ..resource_tracker import ( HardwareInfo, @@ -76,6 +77,11 @@ class PricingPlanGet(BaseModel): ) +class PricingPlanPage(NamedTuple): + items: list[PricingPlanGet] + total: PositiveInt + + class PricingPlanToServiceGet(BaseModel): pricing_plan_id: PricingPlanId service_key: ServiceKey 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 9e032a487fb5..5b2c5e6464e3 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 @@ -1,6 +1,7 @@ from datetime import datetime from typing import NamedTuple +from models_library.emails import LowerCaseEmailStr from pydantic import BaseModel, ConfigDict, PositiveInt from ..licensed_items import LicensedItemID @@ -53,6 +54,7 @@ class LicensedItemCheckoutRestGet(OutputSchema): licensed_item_id: LicensedItemID wallet_id: WalletID user_id: UserID + user_email: LowerCaseEmailStr product_name: ProductName started_at: datetime stopped_at: datetime | None 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 2f413f3d10fc..69e65577c901 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/licensed_items_purchases.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/licensed_items_purchases.py @@ -2,6 +2,7 @@ from decimal import Decimal from typing import NamedTuple +from models_library.emails import LowerCaseEmailStr from pydantic import PositiveInt from ..licensed_items import LicensedItemID @@ -24,6 +25,7 @@ class LicensedItemPurchaseGet(OutputSchema): expire_at: datetime num_of_seats: int purchased_by_user: UserID + user_email: LowerCaseEmailStr purchased_at: datetime modified_at: datetime diff --git a/packages/models-library/src/models_library/api_schemas_webserver/resource_usage.py b/packages/models-library/src/models_library/api_schemas_webserver/resource_usage.py index 506db2aee3c6..253fce9f4bba 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/resource_usage.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/resource_usage.py @@ -60,7 +60,7 @@ class PricingPlanGet(OutputSchema): classification: PricingPlanClassification created_at: datetime pricing_plan_key: str - pricing_units: list[PricingUnitGet] + pricing_units: list[PricingUnitGet] | None is_active: bool @@ -78,7 +78,7 @@ class PricingPlanAdminGet(OutputSchema): classification: PricingPlanClassification created_at: datetime pricing_plan_key: str - pricing_units: list[PricingUnitGet] | None + pricing_units: list[PricingUnitAdminGet] | None is_active: bool diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/5f88b513cd4c_add_user_email_col_to_purchases.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/5f88b513cd4c_add_user_email_col_to_purchases.py new file mode 100644 index 000000000000..0ab593a294e0 --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/5f88b513cd4c_add_user_email_col_to_purchases.py @@ -0,0 +1,30 @@ +"""add user email col to purchases + +Revision ID: 5f88b513cd4c +Revises: ecd4eadaa781 +Create Date: 2025-01-22 15:08:17.729337+00:00 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "5f88b513cd4c" +down_revision = "ecd4eadaa781" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "resource_tracker_licensed_items_purchases", + sa.Column("user_email", sa.String(), nullable=True), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("resource_tracker_licensed_items_purchases", "user_email") + # ### end Alembic commands ### diff --git a/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_licensed_items_purchases.py b/packages/postgres-database/src/simcore_postgres_database/models/resource_tracker_licensed_items_purchases.py index bfcca3b52e88..51944f7089eb 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 @@ -72,6 +72,11 @@ sa.BigInteger, nullable=False, ), + sa.Column( + "user_email", + sa.String, + nullable=True, + ), sa.Column( "purchased_at", sa.DateTime(timezone=True), diff --git a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/pricing_plans.py b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/pricing_plans.py index 218cd139fb4d..45107bfa0774 100644 --- a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/pricing_plans.py +++ b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/resource_usage_tracker/pricing_plans.py @@ -6,6 +6,7 @@ ) from models_library.api_schemas_resource_usage_tracker.pricing_plans import ( PricingPlanGet, + PricingPlanPage, PricingPlanToServiceGet, ) from models_library.products import ProductName @@ -52,14 +53,21 @@ async def list_pricing_plans( rabbitmq_rpc_client: RabbitMQRPCClient, *, product_name: ProductName, -) -> list[PricingPlanGet]: - result: PricingPlanGet = await rabbitmq_rpc_client.request( + exclude_inactive: bool = True, + # pagination + offset: int = 0, + limit: int = 20, +) -> PricingPlanPage: + result = await rabbitmq_rpc_client.request( RESOURCE_USAGE_TRACKER_RPC_NAMESPACE, _RPC_METHOD_NAME_ADAPTER.validate_python("list_pricing_plans"), product_name=product_name, + exclude_inactive=exclude_inactive, + offset=offset, + limit=limit, timeout_s=_DEFAULT_TIMEOUT_S, ) - assert isinstance(result, list) # nosec + assert isinstance(result, PricingPlanPage) # nosec return result diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_pricing_plans.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_pricing_plans.py index eb5fea480a7f..963ea4b7fd93 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_pricing_plans.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/api/rpc/_pricing_plans.py @@ -1,6 +1,7 @@ from fastapi import FastAPI from models_library.api_schemas_resource_usage_tracker.pricing_plans import ( PricingPlanGet, + PricingPlanPage, PricingPlanToServiceGet, PricingUnitGet, ) @@ -43,10 +44,17 @@ async def list_pricing_plans( app: FastAPI, *, product_name: ProductName, -) -> list[PricingPlanGet]: + exclude_inactive: bool, + # pagination + offset: int, + limit: int, +) -> PricingPlanPage: return await pricing_plans.list_pricing_plans_by_product( - product_name=product_name, db_engine=app.state.engine, + product_name=product_name, + exclude_inactive=exclude_inactive, + offset=offset, + limit=limit, ) 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 4458bd2c2586..32630844fe7b 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 @@ -1,6 +1,7 @@ from datetime import datetime from decimal import Decimal +from models_library.emails import LowerCaseEmailStr from models_library.licensed_items import LicensedItemID from models_library.products import ProductName from models_library.resource_tracker import PricingUnitCostId @@ -24,6 +25,7 @@ class LicensedItemsPurchasesDB(BaseModel): expire_at: datetime num_of_seats: int purchased_by_user: UserID + user_email: LowerCaseEmailStr purchased_at: datetime modified: datetime @@ -41,6 +43,7 @@ class CreateLicensedItemsPurchasesDB(BaseModel): expire_at: datetime num_of_seats: int purchased_by_user: UserID + user_email: LowerCaseEmailStr purchased_at: datetime model_config = ConfigDict(from_attributes=True) diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/background_task_periodic_heartbeat_check.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/background_task_periodic_heartbeat_check.py index e295f8a03eaa..4f61fda98795 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/background_task_periodic_heartbeat_check.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/background_task_periodic_heartbeat_check.py @@ -81,6 +81,7 @@ async def _close_unhealthy_service( service_run_id: ServiceRunID, base_start_timestamp: datetime, ): + # 1. Close the service_run update_service_run_stopped_at = ServiceRunStoppedAtUpdate( service_run_id=service_run_id, @@ -106,21 +107,48 @@ async def _close_unhealthy_service( running_service.last_heartbeat_at, running_service.pricing_unit_cost, ) + # NOTE: I have decided that in the case of an error on our side, we will + # close the Dynamic service as BILLED -> since the user was effectively using it until + # the issue occurred. + # NOTE: Update Jan 2025 - With the introduction of the IN_DEBT state, + # when closing the transaction for the dynamic service as BILLED, it is possible + # that the wallet may show a negative balance during this period, which would typically + # be considered as IN_DEBT. However, I have decided to still close it as BILLED. + # This ensures that the user does not have to explicitly pay the DEBT, as the closure + # was caused by an issue on our side. + _transaction_status = ( + CreditTransactionStatus.NOT_BILLED + if running_service.service_type + == ResourceTrackerServiceType.COMPUTATIONAL_SERVICE + else CreditTransactionStatus.BILLED + ) update_credit_transaction = CreditTransactionCreditsAndStatusUpdate( service_run_id=service_run_id, osparc_credits=make_negative(computed_credits), - transaction_status=( - CreditTransactionStatus.NOT_BILLED - if running_service.service_type - == ResourceTrackerServiceType.COMPUTATIONAL_SERVICE - else CreditTransactionStatus.BILLED - ), + transaction_status=_transaction_status, ) await credit_transactions_db.update_credit_transaction_credits_and_status( db_engine, data=update_credit_transaction ) - # 3. Release license seats in case some were checked out but not properly released. + # 3. If the credit transaction status is considered "NOT_BILLED", this might return + # the wallet to positive numbers. If, in the meantime, some transactions were marked as DEBT, + # we need to update them back to the BILLED state. + if _transaction_status == CreditTransactionStatus.NOT_BILLED: + wallet_total_credits = await credit_transactions_db.sum_wallet_credits( + db_engine, + product_name=running_service.product_name, + wallet_id=running_service.wallet_id, + ) + if wallet_total_credits.available_osparc_credits >= 0: + await credit_transactions_db.batch_update_credit_transaction_status_for_in_debt_transactions( + db_engine, + project_id=None, + wallet_id=running_service.wallet_id, + transaction_status=CreditTransactionStatus.BILLED, + ) + + # 4. Release license seats in case some were checked out but not properly released. await licensed_items_checkouts_db.force_release_license_seats_by_run_id( db_engine, service_run_id=service_run_id ) diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/credit_transactions.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/credit_transactions.py index b13d8bfa7f79..3cd60cbce9a1 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/credit_transactions.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/credit_transactions.py @@ -61,9 +61,10 @@ async def create_credit_transaction( wallet_total_credits = await sum_credit_transactions_and_publish_to_rabbitmq( db_engine, - rabbitmq_client, - credit_transaction_create_body.product_name, - credit_transaction_create_body.wallet_id, + connection=conn, + rabbitmq_client=rabbitmq_client, + product_name=credit_transaction_create_body.product_name, + wallet_id=credit_transaction_create_body.wallet_id, ) if wallet_total_credits.available_osparc_credits >= 0: # Change status from `IN_DEBT` to `BILLED` @@ -196,9 +197,9 @@ async def pay_project_debt( fire_and_forget_task( sum_credit_transactions_and_publish_to_rabbitmq( db_engine, - rabbitmq_client, - new_wallet_transaction_create.product_name, - new_wallet_transaction_create.wallet_id, # <-- New wallet + rabbitmq_client=rabbitmq_client, + product_name=new_wallet_transaction_create.product_name, + wallet_id=new_wallet_transaction_create.wallet_id, # <-- New wallet ), task_suffix_name=f"sum_and_publish_credits_wallet_id{new_wallet_transaction_create.wallet_id}", fire_and_forget_tasks_collection=rut_fire_and_forget_tasks, @@ -206,9 +207,9 @@ async def pay_project_debt( fire_and_forget_task( sum_credit_transactions_and_publish_to_rabbitmq( db_engine, - rabbitmq_client, - current_wallet_transaction_create.product_name, - current_wallet_transaction_create.wallet_id, # <-- Current wallet + rabbitmq_client=rabbitmq_client, + product_name=current_wallet_transaction_create.product_name, + wallet_id=current_wallet_transaction_create.wallet_id, # <-- Current wallet ), task_suffix_name=f"sum_and_publish_credits_wallet_id{current_wallet_transaction_create.wallet_id}", fire_and_forget_tasks_collection=rut_fire_and_forget_tasks, 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 753ea3f638fe..a973fd95ea59 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 @@ -60,6 +60,7 @@ async def list_licensed_items_checkouts( licensed_item_id=licensed_item_checkout_db.licensed_item_id, wallet_id=licensed_item_checkout_db.wallet_id, user_id=licensed_item_checkout_db.user_id, + user_email=licensed_item_checkout_db.user_email, product_name=licensed_item_checkout_db.product_name, service_run_id=licensed_item_checkout_db.service_run_id, started_at=licensed_item_checkout_db.started_at, @@ -90,6 +91,7 @@ async def get_licensed_item_checkout( licensed_item_id=licensed_item_checkout_db.licensed_item_id, wallet_id=licensed_item_checkout_db.wallet_id, user_id=licensed_item_checkout_db.user_id, + user_email=licensed_item_checkout_db.user_email, product_name=licensed_item_checkout_db.product_name, service_run_id=licensed_item_checkout_db.service_run_id, started_at=licensed_item_checkout_db.started_at, @@ -171,6 +173,7 @@ async def checkout_licensed_item( licensed_item_id=licensed_item_checkout_db.licensed_item_id, wallet_id=licensed_item_checkout_db.wallet_id, user_id=licensed_item_checkout_db.user_id, + user_email=licensed_item_checkout_db.user_email, product_name=licensed_item_checkout_db.product_name, service_run_id=licensed_item_checkout_db.service_run_id, started_at=licensed_item_checkout_db.started_at, @@ -200,6 +203,7 @@ async def release_licensed_item( licensed_item_id=licensed_item_checkout_db.licensed_item_id, wallet_id=licensed_item_checkout_db.wallet_id, user_id=licensed_item_checkout_db.user_id, + user_email=licensed_item_checkout_db.user_email, product_name=licensed_item_checkout_db.product_name, service_run_id=licensed_item_checkout_db.service_run_id, started_at=licensed_item_checkout_db.started_at, 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 f88316e095b9..f085de184060 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 @@ -62,6 +62,7 @@ async def list_licensed_items_purchases( expire_at=licensed_item_purchase_db.expire_at, num_of_seats=licensed_item_purchase_db.num_of_seats, purchased_by_user=licensed_item_purchase_db.purchased_by_user, + user_email=licensed_item_purchase_db.user_email, purchased_at=licensed_item_purchase_db.purchased_at, modified=licensed_item_purchase_db.modified, ) @@ -96,6 +97,7 @@ async def get_licensed_item_purchase( expire_at=licensed_item_purchase_db.expire_at, num_of_seats=licensed_item_purchase_db.num_of_seats, purchased_by_user=licensed_item_purchase_db.purchased_by_user, + user_email=licensed_item_purchase_db.user_email, purchased_at=licensed_item_purchase_db.purchased_at, modified=licensed_item_purchase_db.modified, ) @@ -120,6 +122,7 @@ async def create_licensed_item_purchase( expire_at=data.expire_at, num_of_seats=data.num_of_seats, purchased_by_user=data.purchased_by_user, + user_email=data.user_email, purchased_at=data.purchased_at, ) @@ -154,7 +157,10 @@ async def create_licensed_item_purchase( # Publish wallet total credits to RabbitMQ await sum_credit_transactions_and_publish_to_rabbitmq( - db_engine, rabbitmq_client, data.product_name, data.wallet_id + db_engine, + rabbitmq_client=rabbitmq_client, + product_name=data.product_name, + wallet_id=data.wallet_id, ) return LicensedItemPurchaseGet( @@ -169,6 +175,7 @@ async def create_licensed_item_purchase( expire_at=licensed_item_purchase_db.expire_at, num_of_seats=licensed_item_purchase_db.num_of_seats, purchased_by_user=licensed_item_purchase_db.purchased_by_user, + user_email=licensed_item_purchase_db.user_email, purchased_at=licensed_item_purchase_db.purchased_at, modified=licensed_item_purchase_db.modified, ) diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/credit_transactions_db.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/credit_transactions_db.py index 5a6574ce7ac6..c0030898e453 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/credit_transactions_db.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/modules/db/credit_transactions_db.py @@ -165,7 +165,13 @@ async def batch_update_credit_transaction_status_for_in_debt_transactions( ) async with transaction_context(engine, connection) as conn: result = await conn.execute(update_stmt) - print(result) + if result.rowcount: + _logger.info( + "Wallet %s and project %s transactions in DEBT were changed to BILLED. Num. of transaction %s", + wallet_id, + project_id, + result.rowcount, + ) async def sum_wallet_credits( 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 2fd8718784e2..fab4391628ae 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 @@ -37,6 +37,7 @@ resource_tracker_licensed_items_purchases.c.expire_at, resource_tracker_licensed_items_purchases.c.num_of_seats, resource_tracker_licensed_items_purchases.c.purchased_by_user, + resource_tracker_licensed_items_purchases.c.user_email, resource_tracker_licensed_items_purchases.c.purchased_at, resource_tracker_licensed_items_purchases.c.modified, ) @@ -66,6 +67,7 @@ async def create( expire_at=data.expire_at, num_of_seats=data.num_of_seats, purchased_by_user=data.purchased_by_user, + user_email=data.user_email, purchased_at=data.purchased_at, modified=sa.func.now(), ) 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 ea6376cc15b6..b205fb039975 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 @@ -1,4 +1,5 @@ import logging +from typing import cast import sqlalchemy as sa from models_library.products import ProductName @@ -181,9 +182,13 @@ async def list_pricing_plans_by_product( connection: AsyncConnection | None = None, *, product_name: ProductName, -) -> list[PricingPlansDB]: + exclude_inactive: bool, + # pagination + offset: int, + limit: int, +) -> tuple[int, list[PricingPlansDB]]: async with transaction_context(engine, connection) as conn: - select_stmt = sa.select( + base_query = sa.select( resource_tracker_pricing_plans.c.pricing_plan_id, resource_tracker_pricing_plans.c.display_name, resource_tracker_pricing_plans.c.description, @@ -192,9 +197,27 @@ async def list_pricing_plans_by_product( resource_tracker_pricing_plans.c.created, resource_tracker_pricing_plans.c.pricing_plan_key, ).where(resource_tracker_pricing_plans.c.product_name == product_name) - result = await conn.execute(select_stmt) - return [PricingPlansDB.model_validate(row) for row in result.fetchall()] + if exclude_inactive is True: + base_query = base_query.where( + resource_tracker_pricing_plans.c.is_active.is_(True) + ) + + # Select total count from base_query + subquery = base_query.subquery() + 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()) + + total_count = await conn.scalar(count_query) + if total_count is None: + total_count = 0 + + result = await conn.execute(list_query.offset(offset).limit(limit)) + + items = [PricingPlansDB.model_validate(row) for row in result.fetchall()] + return cast(int, total_count), items async def create_pricing_plan( diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/pricing_plans.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/pricing_plans.py index ed34c334187f..65493d7f0b4b 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/pricing_plans.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/pricing_plans.py @@ -3,6 +3,7 @@ from fastapi import Depends from models_library.api_schemas_resource_usage_tracker.pricing_plans import ( PricingPlanGet, + PricingPlanPage, PricingPlanToServiceGet, PricingUnitGet, ) @@ -118,27 +119,36 @@ async def connect_service_to_pricing_plan( async def list_pricing_plans_by_product( - product_name: ProductName, db_engine: Annotated[AsyncEngine, Depends(get_resource_tracker_db_engine)], -) -> list[PricingPlanGet]: - pricing_plans_list_db: list[ - PricingPlansDB - ] = await pricing_plans_db.list_pricing_plans_by_product( - db_engine, product_name=product_name + product_name: ProductName, + exclude_inactive: bool, + # pagination + offset: int, + limit: int, +) -> PricingPlanPage: + total, pricing_plans_list_db = await pricing_plans_db.list_pricing_plans_by_product( + db_engine, + product_name=product_name, + exclude_inactive=exclude_inactive, + offset=offset, + limit=limit, + ) + return PricingPlanPage( + items=[ + PricingPlanGet( + pricing_plan_id=pricing_plan_db.pricing_plan_id, + display_name=pricing_plan_db.display_name, + description=pricing_plan_db.description, + classification=pricing_plan_db.classification, + created_at=pricing_plan_db.created, + pricing_plan_key=pricing_plan_db.pricing_plan_key, + pricing_units=None, + is_active=pricing_plan_db.is_active, + ) + for pricing_plan_db in pricing_plans_list_db + ], + total=total, ) - return [ - PricingPlanGet( - pricing_plan_id=pricing_plan_db.pricing_plan_id, - display_name=pricing_plan_db.display_name, - description=pricing_plan_db.description, - classification=pricing_plan_db.classification, - created_at=pricing_plan_db.created, - pricing_plan_key=pricing_plan_db.pricing_plan_key, - pricing_units=None, - is_active=pricing_plan_db.is_active, - ) - for pricing_plan_db in pricing_plans_list_db - ] async def get_pricing_plan( diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/process_message_running_service.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/process_message_running_service.py index 877729330743..b1d82d825b89 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/process_message_running_service.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/process_message_running_service.py @@ -158,7 +158,10 @@ async def _process_start_event( # Publish wallet total credits to RabbitMQ await sum_credit_transactions_and_publish_to_rabbitmq( - db_engine, rabbitmq_client, msg.product_name, msg.wallet_id + db_engine, + rabbitmq_client=rabbitmq_client, + product_name=msg.product_name, + wallet_id=msg.wallet_id, ) @@ -216,9 +219,9 @@ async def _process_heartbeat_event( # Publish wallet total credits to RabbitMQ wallet_total_credits = await sum_credit_transactions_and_publish_to_rabbitmq( db_engine, - rabbitmq_client, - running_service.product_name, - running_service.wallet_id, + rabbitmq_client=rabbitmq_client, + product_name=running_service.product_name, + wallet_id=running_service.wallet_id, ) if wallet_total_credits.available_osparc_credits < CreditsLimit.OUT_OF_CREDITS: await publish_to_rabbitmq_wallet_credits_limit_reached( @@ -319,9 +322,9 @@ async def _process_stop_event( # Publish wallet total credits to RabbitMQ await sum_credit_transactions_and_publish_to_rabbitmq( db_engine, - rabbitmq_client, - running_service.product_name, - running_service.wallet_id, + rabbitmq_client=rabbitmq_client, + product_name=running_service.product_name, + wallet_id=running_service.wallet_id, ) diff --git a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/utils.py b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/utils.py index d8f9463d7fdf..ae831a8d6152 100644 --- a/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/utils.py +++ b/services/resource-usage-tracker/src/simcore_service_resource_usage_tracker/services/utils.py @@ -20,7 +20,7 @@ from models_library.wallets import WalletID from pydantic import PositiveInt from servicelib.rabbitmq import RabbitMQClient -from sqlalchemy.ext.asyncio import AsyncEngine +from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine from .modules.db import credit_transactions_db, service_runs_db @@ -33,12 +33,15 @@ def make_negative(n): async def sum_credit_transactions_and_publish_to_rabbitmq( db_engine: AsyncEngine, + connection: AsyncConnection | None = None, + *, rabbitmq_client: RabbitMQClient, product_name: ProductName, wallet_id: WalletID, ) -> WalletTotalCredits: wallet_total_credits = await credit_transactions_db.sum_wallet_credits( db_engine, + connection=connection, product_name=product_name, wallet_id=wallet_id, ) diff --git a/services/resource-usage-tracker/tests/unit/with_dbs/test_api_pricing_plans_rpc.py b/services/resource-usage-tracker/tests/unit/with_dbs/test_api_pricing_plans_rpc.py index 721a17e05c75..ee342c0081cf 100644 --- a/services/resource-usage-tracker/tests/unit/with_dbs/test_api_pricing_plans_rpc.py +++ b/services/resource-usage-tracker/tests/unit/with_dbs/test_api_pricing_plans_rpc.py @@ -6,6 +6,7 @@ from faker import Faker from models_library.api_schemas_resource_usage_tracker.pricing_plans import ( PricingPlanGet, + PricingPlanPage, PricingPlanToServiceGet, PricingUnitGet, ) @@ -147,10 +148,11 @@ async def test_rpc_pricing_plans_workflow( rpc_client, product_name="osparc", ) - assert isinstance(result, list) - assert len(result) == 1 - assert isinstance(result[0], PricingPlanGet) - assert result[0].pricing_units is None + assert isinstance(result, PricingPlanPage) + assert result.total == 1 + assert len(result.items) == 1 + assert isinstance(result.items[0], PricingPlanGet) + assert result.items[0].pricing_units is None # Now I will deactivate the pricing plan result = await pricing_plans.update_pricing_plan( diff --git a/services/static-webserver/client/source/class/osparc/data/Resources.js b/services/static-webserver/client/source/class/osparc/data/Resources.js index 40786ae0050b..86bff1eb5501 100644 --- a/services/static-webserver/client/source/class/osparc/data/Resources.js +++ b/services/static-webserver/client/source/class/osparc/data/Resources.js @@ -633,14 +633,14 @@ qx.Class.define("osparc.data.Resources", { }, /* - * PRICING PLANS + * ADMIN PRICING PLANS */ - "pricingPlans": { + "adminPricingPlans": { useCache: false, // handled in osparc.store.Pricing endpoints: { - get: { + getPage: { method: "GET", - url: statics.API + "/admin/pricing-plans" + url: statics.API + "/admin/pricing-plans?offset={offset}&limit={limit}" }, getOne: { method: "GET", @@ -657,6 +657,23 @@ qx.Class.define("osparc.data.Resources", { } }, + /* + * PRICING PLANS + */ + "pricingPlans": { + useCache: false, // handled in osparc.store.Pricing + endpoints: { + getPage: { + method: "GET", + url: statics.API + "/pricing-plans?offset={offset}&limit={limit}" + }, + getOne: { + method: "GET", + url: statics.API + "/pricing-plans/{pricingPlanId}" + }, + } + }, + /* * PRICING UNITS */ diff --git a/services/static-webserver/client/source/class/osparc/data/model/PricingPlan.js b/services/static-webserver/client/source/class/osparc/data/model/PricingPlan.js index b6fc4031552d..b6179d005fbb 100644 --- a/services/static-webserver/client/source/class/osparc/data/model/PricingPlan.js +++ b/services/static-webserver/client/source/class/osparc/data/model/PricingPlan.js @@ -65,7 +65,7 @@ qx.Class.define("osparc.data.model.PricingPlan", { check: "Array", nullable: true, init: [], - event: "changePricingunits" + event: "changePricingUnits" }, classification: { diff --git a/services/static-webserver/client/source/class/osparc/desktop/StudyEditor.js b/services/static-webserver/client/source/class/osparc/desktop/StudyEditor.js index 62a759623f6b..3e6a0c943e2b 100644 --- a/services/static-webserver/client/source/class/osparc/desktop/StudyEditor.js +++ b/services/static-webserver/client/source/class/osparc/desktop/StudyEditor.js @@ -187,7 +187,7 @@ qx.Class.define("osparc.desktop.StudyEditor", { if (err["status"] == 402) { msg = err["message"]; // The backend might have thrown a 402 because the wallet was negative - const match = msg.match(/Project debt\s([-]?\d+(\.\d+)?)\scredits/); + const match = msg.match(/last transaction of\s([-]?\d+(\.\d+)?)\sresulted/); let debt = null; if ("debtAmount" in err) { // the study has some debt that needs to be paid diff --git a/services/static-webserver/client/source/class/osparc/store/Pricing.js b/services/static-webserver/client/source/class/osparc/store/Pricing.js index 08d01f9e3c8c..b43ec07f8066 100644 --- a/services/static-webserver/client/source/class/osparc/store/Pricing.js +++ b/services/static-webserver/client/source/class/osparc/store/Pricing.js @@ -33,7 +33,8 @@ qx.Class.define("osparc.store.Pricing", { pricingPlansCached: null, fetchPricingPlans: function() { - return osparc.data.Resources.fetch("pricingPlans", "get") + const resourceName = osparc.data.Permissions.getInstance().isAdmin() ? "adminPricingPlans" : "pricingPlans"; + return osparc.data.Resources.getInstance().getAllPages(resourceName) .then(pricingPlansData => { const pricingPlans = []; pricingPlansData.forEach(pricingPlanData => { @@ -48,7 +49,7 @@ qx.Class.define("osparc.store.Pricing", { const params = { data: newPricingPlanData }; - return osparc.data.Resources.fetch("pricingPlans", "post", params) + return osparc.data.Resources.fetch("adminPricingPlans", "post", params) .then(pricingPlanData => { const pricingPlan = this.__addToCache(pricingPlanData); this.fireDataEvent("pricingPlansChanged", pricingPlan); @@ -63,7 +64,7 @@ qx.Class.define("osparc.store.Pricing", { }, data: updateData }; - return osparc.data.Resources.getInstance().fetch("pricingPlans", "update", params) + return osparc.data.Resources.getInstance().fetch("adminPricingPlans", "update", params) .then(pricingPlanData => { return this.__addToCache(pricingPlanData); }) @@ -86,7 +87,8 @@ qx.Class.define("osparc.store.Pricing", { pricingPlanId, } }; - return osparc.data.Resources.fetch("pricingPlans", "getOne", params) + const resourceName = osparc.data.Permissions.getInstance().isAdmin() ? "adminPricingPlans" : "pricingPlans"; + return osparc.data.Resources.fetch(resourceName, "getOne", params) .then(pricingPlanData => { const pricingPlan = this.__addToCache(pricingPlanData); const pricingUnits = pricingPlan.getPricingUnits(); 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 07ed5efb5928..8a07d898aa96 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 @@ -5289,7 +5289,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Envelope_list_ServiceRunGet__' + $ref: '#/components/schemas/Page_ServiceRunGet_' /v0/services/-/aggregated-usages: get: tags: @@ -5335,7 +5335,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Envelope_list_OsparcCreditsAggregatedByServiceGet__' + $ref: '#/components/schemas/Page_OsparcCreditsAggregatedByServiceGet_' /v0/services/-/usage-report: get: tags: @@ -5384,7 +5384,7 @@ paths: get: tags: - pricing-plans - summary: Retrieve detail information about pricing unit + summary: Get Pricing Plan Unit operationId: get_pricing_plan_unit parameters: - name: pricing_plan_id @@ -5410,31 +5410,97 @@ paths: application/json: schema: $ref: '#/components/schemas/Envelope_PricingUnitGet_' + /v0/pricing-plans: + get: + tags: + - pricing-plans + summary: List Pricing Plans + description: To keep the listing lightweight, the pricingUnits field is None. + operationId: list_pricing_plans + parameters: + - name: limit + in: query + required: false + schema: + type: integer + default: 20 + title: Limit + - name: offset + in: query + required: false + schema: + type: integer + default: 0 + title: Offset + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Page_PricingPlanGet_' + /v0/pricing-plans/{pricing_plan_id}: + get: + tags: + - pricing-plans + summary: Get Pricing Plan + operationId: get_pricing_plan + parameters: + - name: pricing_plan_id + in: path + required: true + schema: + type: integer + exclusiveMinimum: true + title: Pricing Plan Id + minimum: 0 + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope_PricingPlanGet_' /v0/admin/pricing-plans: get: tags: - admin - summary: List pricing plans + summary: List Pricing Plans For Admin User description: To keep the listing lightweight, the pricingUnits field is None. - operationId: list_pricing_plans + operationId: list_pricing_plans_for_admin_user + parameters: + - 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_PricingPlanAdminGet__' + $ref: '#/components/schemas/Page_PricingPlanAdminGet_' post: tags: - admin - summary: Create pricing plan + summary: Create Pricing Plan operationId: create_pricing_plan requestBody: + required: true content: application/json: schema: $ref: '#/components/schemas/CreatePricingPlanBodyParams' - required: true responses: '200': description: Successful Response @@ -5446,8 +5512,8 @@ paths: get: tags: - admin - summary: Retrieve detail information about pricing plan - operationId: get_pricing_plan + summary: Get Pricing Plan For Admin User + operationId: get_pricing_plan_for_admin_user parameters: - name: pricing_plan_id in: path @@ -5467,7 +5533,7 @@ paths: put: tags: - admin - summary: Update detail information about pricing plan + summary: Update Pricing Plan operationId: update_pricing_plan parameters: - name: pricing_plan_id @@ -5495,7 +5561,7 @@ paths: get: tags: - admin - summary: Retrieve detail information about pricing unit + summary: Get Pricing Unit operationId: get_pricing_unit parameters: - name: pricing_plan_id @@ -5524,7 +5590,7 @@ paths: put: tags: - admin - summary: Update detail information about pricing plan + summary: Update Pricing Unit operationId: update_pricing_unit parameters: - name: pricing_plan_id @@ -5560,7 +5626,7 @@ paths: post: tags: - admin - summary: Create pricing unit + summary: Create Pricing Unit operationId: create_pricing_unit parameters: - name: pricing_plan_id @@ -5588,7 +5654,7 @@ paths: get: tags: - admin - summary: List services that are connected to the provided pricing plan + summary: List Connected Services To Pricing Plan operationId: list_connected_services_to_pricing_plan parameters: - name: pricing_plan_id @@ -5609,7 +5675,7 @@ paths: post: tags: - admin - summary: Connect service with pricing plan + summary: Connect Service To Pricing Plan operationId: connect_service_to_pricing_plan parameters: - name: pricing_plan_id @@ -8517,6 +8583,19 @@ components: title: Error type: object title: Envelope[PricingPlanAdminGet] + Envelope_PricingPlanGet_: + properties: + data: + anyOf: + - $ref: '#/components/schemas/PricingPlanGet' + - type: 'null' + error: + anyOf: + - {} + - type: 'null' + title: Error + type: object + title: Envelope[PricingPlanGet] Envelope_PricingPlanToServiceAdminGet_: properties: data: @@ -9203,22 +9282,6 @@ components: title: Error type: object title: Envelope[list[MyTokenGet]] - Envelope_list_OsparcCreditsAggregatedByServiceGet__: - properties: - data: - anyOf: - - items: - $ref: '#/components/schemas/OsparcCreditsAggregatedByServiceGet' - type: array - - type: 'null' - title: Data - error: - anyOf: - - {} - - type: 'null' - title: Error - type: object - title: Envelope[list[OsparcCreditsAggregatedByServiceGet]] Envelope_list_PaymentMethodGet__: properties: data: @@ -9235,22 +9298,6 @@ components: title: Error type: object title: Envelope[list[PaymentMethodGet]] - Envelope_list_PricingPlanAdminGet__: - properties: - data: - anyOf: - - items: - $ref: '#/components/schemas/PricingPlanAdminGet' - type: array - - type: 'null' - title: Data - error: - anyOf: - - {} - - type: 'null' - title: Error - type: object - title: Envelope[list[PricingPlanAdminGet]] Envelope_list_PricingPlanToServiceAdminGet__: properties: data: @@ -9379,22 +9426,6 @@ components: title: Error type: object title: Envelope[list[ServiceOutputGet]] - Envelope_list_ServiceRunGet__: - properties: - data: - anyOf: - - items: - $ref: '#/components/schemas/ServiceRunGet' - type: array - - type: 'null' - title: Data - error: - anyOf: - - {} - - type: 'null' - title: Error - type: object - title: Envelope[list[ServiceRunGet]] Envelope_list_TagGet__: properties: data: @@ -10685,6 +10716,10 @@ components: exclusiveMinimum: true title: Purchasedbyuser minimum: 0 + userEmail: + type: string + format: email + title: Useremail purchasedAt: type: string format: date-time @@ -10705,6 +10740,7 @@ components: - expireAt - numOfSeats - purchasedByUser + - userEmail - purchasedAt - modifiedAt title: LicensedItemPurchaseGet @@ -11852,6 +11888,24 @@ components: - _links - data title: Page[LicensedItemPurchaseGet] + Page_OsparcCreditsAggregatedByServiceGet_: + properties: + _meta: + $ref: '#/components/schemas/PageMetaInfoLimitOffset' + _links: + $ref: '#/components/schemas/PageLinks' + data: + items: + $ref: '#/components/schemas/OsparcCreditsAggregatedByServiceGet' + type: array + title: Data + additionalProperties: false + type: object + required: + - _meta + - _links + - data + title: Page[OsparcCreditsAggregatedByServiceGet] Page_PaymentTransaction_: properties: _meta: @@ -11870,6 +11924,42 @@ components: - _links - data title: Page[PaymentTransaction] + Page_PricingPlanAdminGet_: + properties: + _meta: + $ref: '#/components/schemas/PageMetaInfoLimitOffset' + _links: + $ref: '#/components/schemas/PageLinks' + data: + items: + $ref: '#/components/schemas/PricingPlanAdminGet' + type: array + title: Data + additionalProperties: false + type: object + required: + - _meta + - _links + - data + title: Page[PricingPlanAdminGet] + Page_PricingPlanGet_: + properties: + _meta: + $ref: '#/components/schemas/PageMetaInfoLimitOffset' + _links: + $ref: '#/components/schemas/PageLinks' + data: + items: + $ref: '#/components/schemas/PricingPlanGet' + type: array + title: Data + additionalProperties: false + type: object + required: + - _meta + - _links + - data + title: Page[PricingPlanGet] Page_ProjectIterationItem_: properties: _meta: @@ -11942,6 +12032,24 @@ components: - _links - data title: Page[RepoApiModel] + Page_ServiceRunGet_: + properties: + _meta: + $ref: '#/components/schemas/PageMetaInfoLimitOffset' + _links: + $ref: '#/components/schemas/PageLinks' + data: + items: + $ref: '#/components/schemas/ServiceRunGet' + type: array + title: Data + additionalProperties: false + type: object + required: + - _meta + - _links + - data + title: Page[ServiceRunGet] ParentMetaProjectRef: properties: project_id: @@ -12298,7 +12406,7 @@ components: pricingUnits: anyOf: - items: - $ref: '#/components/schemas/PricingUnitGet' + $ref: '#/components/schemas/PricingUnitAdminGet' type: array - type: 'null' title: Pricingunits @@ -12322,6 +12430,49 @@ components: - TIER - LICENSE title: PricingPlanClassification + PricingPlanGet: + properties: + pricingPlanId: + type: integer + exclusiveMinimum: true + title: Pricingplanid + minimum: 0 + displayName: + type: string + title: Displayname + description: + type: string + title: Description + classification: + $ref: '#/components/schemas/PricingPlanClassification' + createdAt: + type: string + format: date-time + title: Createdat + pricingPlanKey: + type: string + title: Pricingplankey + pricingUnits: + anyOf: + - items: + $ref: '#/components/schemas/PricingUnitGet' + type: array + - type: 'null' + title: Pricingunits + isActive: + type: boolean + title: Isactive + type: object + required: + - pricingPlanId + - displayName + - description + - classification + - createdAt + - pricingPlanKey + - pricingUnits + - isActive + title: PricingPlanGet PricingPlanToServiceAdminGet: properties: pricingPlanId: diff --git a/services/web/server/src/simcore_service_webserver/catalog/_handlers.py b/services/web/server/src/simcore_service_webserver/catalog/_handlers.py index a9ba40b53783..cdc617c5db34 100644 --- a/services/web/server/src/simcore_service_webserver/catalog/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/catalog/_handlers.py @@ -36,7 +36,7 @@ from .._meta import API_VTAG from ..login.decorators import login_required -from ..resource_usage.api import get_default_service_pricing_plan +from ..resource_usage.service import get_default_service_pricing_plan from ..security.decorators import permission_required from ..utils_aiohttp import envelope_json_response from . import _api, _handlers_errors, client 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 43d6a290d6f7..390904c694c3 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 @@ -24,6 +24,7 @@ class LicensedItemCheckoutGet(BaseModel): licensed_item_id: LicensedItemID wallet_id: WalletID user_id: UserID + user_email: str product_name: ProductName started_at: datetime stopped_at: datetime | None 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 0bcdbe9636cd..53915e9531d8 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 @@ -63,6 +63,7 @@ async def get_licensed_item_checkout(request: web.Request): licensed_item_id=checkout_item.licensed_item_id, wallet_id=checkout_item.wallet_id, user_id=checkout_item.user_id, + user_email=checkout_item.user_email, product_name=checkout_item.product_name, started_at=checkout_item.started_at, stopped_at=checkout_item.stopped_at, @@ -106,6 +107,7 @@ async def list_licensed_item_checkouts_for_wallet(request: web.Request): licensed_item_id=checkout_item.licensed_item_id, wallet_id=checkout_item.wallet_id, user_id=checkout_item.user_id, + user_email=checkout_item.user_email, product_name=checkout_item.product_name, started_at=checkout_item.started_at, stopped_at=checkout_item.stopped_at, 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 87a8aaf14c5a..53c0f48379bd 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 @@ -62,6 +62,7 @@ async def list_licensed_items_checkouts_for_wallet( licensed_item_id=checkout_item.licensed_item_id, wallet_id=checkout_item.wallet_id, user_id=checkout_item.user_id, + user_email=checkout_item.user_email, product_name=checkout_item.product_name, started_at=checkout_item.started_at, stopped_at=checkout_item.stopped_at, @@ -101,6 +102,7 @@ async def get_licensed_item_checkout( licensed_item_id=checkout_item.licensed_item_id, wallet_id=checkout_item.wallet_id, user_id=checkout_item.user_id, + user_email=checkout_item.user_email, product_name=checkout_item.product_name, started_at=checkout_item.started_at, stopped_at=checkout_item.stopped_at, @@ -149,6 +151,7 @@ async def checkout_licensed_item_for_wallet( licensed_item_id=licensed_item_get.licensed_item_id, wallet_id=licensed_item_get.wallet_id, user_id=licensed_item_get.user_id, + user_email=licensed_item_get.user_email, product_name=licensed_item_get.product_name, started_at=licensed_item_get.started_at, stopped_at=licensed_item_get.stopped_at, @@ -194,6 +197,7 @@ async def release_licensed_item_for_wallet( licensed_item_id=licensed_item_get.licensed_item_id, wallet_id=licensed_item_get.wallet_id, user_id=licensed_item_get.user_id, + user_email=licensed_item_get.user_email, product_name=licensed_item_get.product_name, started_at=licensed_item_get.started_at, stopped_at=licensed_item_get.stopped_at, 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 2cfa6355f833..d42ad904851a 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 @@ -65,6 +65,7 @@ async def list_licensed_items_purchases( expire_at=item.expire_at, num_of_seats=item.num_of_seats, purchased_by_user=item.purchased_by_user, + user_email=item.user_email, purchased_at=item.purchased_at, modified_at=item.modified, ) @@ -108,6 +109,7 @@ async def get_licensed_item_purchase( expire_at=licensed_item_get.expire_at, num_of_seats=licensed_item_get.num_of_seats, purchased_by_user=licensed_item_get.purchased_by_user, + user_email=licensed_item_get.user_email, purchased_at=licensed_item_get.purchased_at, modified_at=licensed_item_get.modified, ) diff --git a/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_service.py b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_service.py index 374da33bbbef..102ef6c1339d 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 @@ -21,7 +21,7 @@ ) from ..rabbitmq import get_rabbitmq_rpc_client -from ..resource_usage.api import get_pricing_plan_unit +from ..resource_usage.service import get_pricing_plan_unit from ..users.api import get_user from ..wallets.api import get_wallet_with_available_credits_by_user_and_wallet from ..wallets.errors import WalletNotEnoughCreditsError diff --git a/services/web/server/src/simcore_service_webserver/payments/_onetime_api.py b/services/web/server/src/simcore_service_webserver/payments/_onetime_api.py index 903e14ad0027..fbe69e07f830 100644 --- a/services/web/server/src/simcore_service_webserver/payments/_onetime_api.py +++ b/services/web/server/src/simcore_service_webserver/payments/_onetime_api.py @@ -25,7 +25,7 @@ from ..db.plugin import get_database_engine from ..products.api import get_product_stripe_info -from ..resource_usage.api import add_credits_to_wallet +from ..resource_usage.service import add_credits_to_wallet from ..users.api import get_user_display_and_id_names, get_user_invoice_address from ..wallets.api import get_wallet_by_user, get_wallet_with_permissions_by_user from ..wallets.errors import WalletAccessForbiddenError diff --git a/services/web/server/src/simcore_service_webserver/projects/_projects_nodes_pricing_unit_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_projects_nodes_pricing_unit_handlers.py index a2797993c9a2..66b17383bba7 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_projects_nodes_pricing_unit_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_projects_nodes_pricing_unit_handlers.py @@ -17,7 +17,7 @@ from .._meta import API_VTAG from ..login.decorators import login_required -from ..resource_usage import api as rut_api +from ..resource_usage import service as rut_api from ..security.decorators import permission_required from ..utils_aiohttp import envelope_json_response from . import projects_service diff --git a/services/web/server/src/simcore_service_webserver/projects/exceptions.py b/services/web/server/src/simcore_service_webserver/projects/exceptions.py index 66197a2f9df3..cc45dabdfb29 100644 --- a/services/web/server/src/simcore_service_webserver/projects/exceptions.py +++ b/services/web/server/src/simcore_service_webserver/projects/exceptions.py @@ -239,11 +239,11 @@ class ProjectGroupNotFoundError(BaseProjectError): class ProjectInDebtCanNotChangeWalletError(BaseProjectError): - msg_template = "Can not change wallet on a project that is in debt. Project debt {debt_amount} credits. Project wallet {wallet_id}" + msg_template = "Unable to change the credit account linked to the project. The project is embargoed because the last transaction of {debt_amount} resulted in the credit account going negative." class ProjectInDebtCanNotOpenError(BaseProjectError): - msg_template = "Can not open project that is in debt. Project debt {debt_amount} credits. Project wallet {wallet_id}" + msg_template = "Unable to open the project. The project is embargoed because the last transaction of {debt_amount} resulted in the credit account going negative." class ProjectWalletPendingTransactionError(BaseProjectError): diff --git a/services/web/server/src/simcore_service_webserver/projects/projects_service.py b/services/web/server/src/simcore_service_webserver/projects/projects_service.py index 67c6154afa3a..112655b6d03f 100644 --- a/services/web/server/src/simcore_service_webserver/projects/projects_service.py +++ b/services/web/server/src/simcore_service_webserver/projects/projects_service.py @@ -106,7 +106,7 @@ UserSessionID, managed_resource, ) -from ..resource_usage import api as rut_api +from ..resource_usage import service as rut_api from ..socketio.messages import ( SOCKET_IO_NODE_UPDATED_EVENT, SOCKET_IO_PROJECT_UPDATED_EVENT, diff --git a/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_admin_handlers.py b/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_admin_rest.py similarity index 73% rename from services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_admin_handlers.py rename to services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_admin_rest.py index 5cad36d12729..93386ff05a8c 100644 --- a/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_admin_handlers.py +++ b/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_admin_rest.py @@ -1,6 +1,9 @@ import functools from aiohttp import web +from models_library.api_schemas_resource_usage_tracker.pricing_plans import ( + PricingPlanGet, +) from models_library.api_schemas_webserver.resource_usage import ( ConnectServiceToPricingPlanBodyParams, CreatePricingPlanBodyParams, @@ -19,21 +22,26 @@ PricingUnitWithCostCreate, PricingUnitWithCostUpdate, ) -from models_library.rest_base import StrictRequestParameters +from models_library.rest_pagination import Page, PageQueryParameters +from models_library.rest_pagination_utils import paginate_data from pydantic import BaseModel, ConfigDict from servicelib.aiohttp.requests_validation import ( parse_request_body_as, parse_request_path_parameters_as, + parse_request_query_parameters_as, ) from servicelib.aiohttp.typing_extension import Handler +from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON from servicelib.rabbitmq._errors import RPCServerError +from servicelib.rest_constants import RESPONSE_MODEL_POLICY from .._meta import API_VTAG as VTAG from ..login.decorators import login_required from ..models import RequestContext from ..security.decorators import permission_required from ..utils_aiohttp import envelope_json_response -from . import _pricing_plans_admin_api as admin_api +from . import _pricing_plans_admin_service as pricing_plans_admin_service +from ._pricing_plans_models import PricingPlanGetPathParams # # API components/schemas @@ -64,26 +72,27 @@ async def wrapper(request: web.Request) -> web.StreamResponse: ## Admin Pricing Plan endpoints -class PricingPlanGetPathParams(StrictRequestParameters): - pricing_plan_id: PricingPlanId - model_config = ConfigDict(extra="forbid") - - @routes.get( f"/{VTAG}/admin/pricing-plans", - name="list_pricing_plans", + name="list_pricing_plans_for_admin_user", ) @login_required @permission_required("resource-usage.write") @_handle_pricing_plan_admin_exceptions -async def list_pricing_plans(request: web.Request): +async def list_pricing_plans_for_admin_user(request: web.Request): req_ctx = RequestContext.model_validate(request) + query_params: PageQueryParameters = parse_request_query_parameters_as( + PageQueryParameters, request + ) - pricing_plans_list = await admin_api.list_pricing_plans( + pricing_plan_page = await pricing_plans_admin_service.list_pricing_plans( app=request.app, product_name=req_ctx.product_name, + exclude_inactive=False, + offset=query_params.offset, + limit=query_params.limit, ) - webserver_pricing_unit_get = [ + webserver_pricing_plans = [ PricingPlanAdminGet( pricing_plan_id=pricing_plan.pricing_plan_id, display_name=pricing_plan.display_name, @@ -94,33 +103,33 @@ async def list_pricing_plans(request: web.Request): pricing_units=None, is_active=pricing_plan.is_active, ) - for pricing_plan in pricing_plans_list + for pricing_plan in pricing_plan_page.items ] - return envelope_json_response(webserver_pricing_unit_get, web.HTTPOk) - + page = Page[PricingPlanAdminGet].model_validate( + paginate_data( + chunk=webserver_pricing_plans, + request_url=request.url, + total=pricing_plan_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}/admin/pricing-plans/{{pricing_plan_id}}", - name="get_pricing_plan", -) -@login_required -@permission_required("resource-usage.write") -@_handle_pricing_plan_admin_exceptions -async def get_pricing_plan(request: web.Request): - req_ctx = RequestContext.model_validate(request) - path_params = parse_request_path_parameters_as(PricingPlanGetPathParams, request) - pricing_plan_get = await admin_api.get_pricing_plan( - app=request.app, - product_name=req_ctx.product_name, - pricing_plan_id=path_params.pricing_plan_id, - ) +def pricing_plan_get_to_admin(pricing_plan_get: PricingPlanGet) -> PricingPlanAdminGet: + """ + Convert a PricingPlanGet object into a PricingPlanAdminGet object. + """ if pricing_plan_get.pricing_units is None: msg = "Pricing plan units should not be None" raise ValueError(msg) - webserver_admin_pricing_plan_get = PricingPlanAdminGet( + return PricingPlanAdminGet( pricing_plan_id=pricing_plan_get.pricing_plan_id, display_name=pricing_plan_get.display_name, description=pricing_plan_get.description, @@ -129,18 +138,37 @@ async def get_pricing_plan(request: web.Request): pricing_plan_key=pricing_plan_get.pricing_plan_key, pricing_units=[ PricingUnitAdminGet( - pricing_unit_id=pricing_unit.pricing_unit_id, - unit_name=pricing_unit.unit_name, - unit_extra_info=pricing_unit.unit_extra_info, - specific_info=pricing_unit.specific_info, - current_cost_per_unit=pricing_unit.current_cost_per_unit, - default=pricing_unit.default, + pricing_unit_id=pu.pricing_unit_id, + unit_name=pu.unit_name, + unit_extra_info=pu.unit_extra_info, + specific_info=pu.specific_info, + current_cost_per_unit=pu.current_cost_per_unit, + default=pu.default, ) - for pricing_unit in pricing_plan_get.pricing_units + for pu in pricing_plan_get.pricing_units ], is_active=pricing_plan_get.is_active, ) + +@routes.get( + f"/{VTAG}/admin/pricing-plans/{{pricing_plan_id}}", + name="get_pricing_plan_for_admin_user", +) +@login_required +@permission_required("resource-usage.write") +@_handle_pricing_plan_admin_exceptions +async def get_pricing_plan_for_admin_user(request: web.Request): + req_ctx = RequestContext.model_validate(request) + path_params = parse_request_path_parameters_as(PricingPlanGetPathParams, request) + + pricing_plan_get = await pricing_plans_admin_service.get_pricing_plan( + app=request.app, + product_name=req_ctx.product_name, + pricing_plan_id=path_params.pricing_plan_id, + ) + webserver_admin_pricing_plan_get = pricing_plan_get_to_admin(pricing_plan_get) + return envelope_json_response(webserver_admin_pricing_plan_get, web.HTTPOk) @@ -162,33 +190,11 @@ async def create_pricing_plan(request: web.Request): classification=body_params.classification, pricing_plan_key=body_params.pricing_plan_key, ) - pricing_plan_get = await admin_api.create_pricing_plan( + pricing_plan_get = await pricing_plans_admin_service.create_pricing_plan( app=request.app, data=_data, ) - if pricing_plan_get.pricing_units is None: - raise ValueError - - webserver_admin_pricing_plan_get = PricingPlanAdminGet( - pricing_plan_id=pricing_plan_get.pricing_plan_id, - display_name=pricing_plan_get.display_name, - description=pricing_plan_get.description, - classification=pricing_plan_get.classification, - created_at=pricing_plan_get.created_at, - pricing_plan_key=pricing_plan_get.pricing_plan_key, - pricing_units=[ - PricingUnitAdminGet( - pricing_unit_id=pricing_unit.pricing_unit_id, - unit_name=pricing_unit.unit_name, - unit_extra_info=pricing_unit.unit_extra_info, - specific_info=pricing_unit.specific_info, - current_cost_per_unit=pricing_unit.current_cost_per_unit, - default=pricing_unit.default, - ) - for pricing_unit in pricing_plan_get.pricing_units - ], - is_active=pricing_plan_get.is_active, - ) + webserver_admin_pricing_plan_get = pricing_plan_get_to_admin(pricing_plan_get) return envelope_json_response(webserver_admin_pricing_plan_get, web.HTTPOk) @@ -211,34 +217,12 @@ async def update_pricing_plan(request: web.Request): description=body_params.description, is_active=body_params.is_active, ) - pricing_plan_get = await admin_api.update_pricing_plan( + pricing_plan_get = await pricing_plans_admin_service.update_pricing_plan( app=request.app, product_name=req_ctx.product_name, data=_data, ) - if pricing_plan_get.pricing_units is None: - raise ValueError - - webserver_admin_pricing_plan_get = PricingPlanAdminGet( - pricing_plan_id=pricing_plan_get.pricing_plan_id, - display_name=pricing_plan_get.display_name, - description=pricing_plan_get.description, - classification=pricing_plan_get.classification, - created_at=pricing_plan_get.created_at, - pricing_plan_key=pricing_plan_get.pricing_plan_key, - pricing_units=[ - PricingUnitAdminGet( - pricing_unit_id=pricing_unit.pricing_unit_id, - unit_name=pricing_unit.unit_name, - unit_extra_info=pricing_unit.unit_extra_info, - specific_info=pricing_unit.specific_info, - current_cost_per_unit=pricing_unit.current_cost_per_unit, - default=pricing_unit.default, - ) - for pricing_unit in pricing_plan_get.pricing_units - ], - is_active=pricing_plan_get.is_active, - ) + webserver_admin_pricing_plan_get = pricing_plan_get_to_admin(pricing_plan_get) return envelope_json_response(webserver_admin_pricing_plan_get, web.HTTPOk) @@ -263,7 +247,7 @@ async def get_pricing_unit(request: web.Request): req_ctx = RequestContext.model_validate(request) path_params = parse_request_path_parameters_as(PricingUnitGetPathParams, request) - pricing_unit_get = await admin_api.get_pricing_unit( + pricing_unit_get = await pricing_plans_admin_service.get_pricing_unit( app=request.app, product_name=req_ctx.product_name, pricing_plan_id=path_params.pricing_plan_id, @@ -303,7 +287,7 @@ async def create_pricing_unit(request: web.Request): cost_per_unit=body_params.cost_per_unit, comment=body_params.comment, ) - pricing_unit_get = await admin_api.create_pricing_unit( + pricing_unit_get = await pricing_plans_admin_service.create_pricing_unit( app=request.app, product_name=req_ctx.product_name, data=_data, @@ -342,7 +326,7 @@ async def update_pricing_unit(request: web.Request): specific_info=body_params.specific_info, pricing_unit_cost_update=body_params.pricing_unit_cost_update, ) - pricing_unit_get = await admin_api.update_pricing_unit( + pricing_unit_get = await pricing_plans_admin_service.update_pricing_unit( app=request.app, product_name=req_ctx.product_name, data=_data, @@ -374,10 +358,12 @@ async def list_connected_services_to_pricing_plan(request: web.Request): req_ctx = RequestContext.model_validate(request) path_params = parse_request_path_parameters_as(PricingPlanGetPathParams, request) - connected_services_list = await admin_api.list_connected_services_to_pricing_plan( - app=request.app, - product_name=req_ctx.product_name, - pricing_plan_id=path_params.pricing_plan_id, + connected_services_list = ( + await pricing_plans_admin_service.list_connected_services_to_pricing_plan( + app=request.app, + product_name=req_ctx.product_name, + pricing_plan_id=path_params.pricing_plan_id, + ) ) connected_services_get = [ PricingPlanToServiceAdminGet( @@ -406,13 +392,15 @@ async def connect_service_to_pricing_plan(request: web.Request): ConnectServiceToPricingPlanBodyParams, request ) - connected_service = await admin_api.connect_service_to_pricing_plan( - app=request.app, - user_id=req_ctx.user_id, - product_name=req_ctx.product_name, - pricing_plan_id=path_params.pricing_plan_id, - service_key=body_params.service_key, - service_version=body_params.service_version, + connected_service = ( + await pricing_plans_admin_service.connect_service_to_pricing_plan( + app=request.app, + user_id=req_ctx.user_id, + product_name=req_ctx.product_name, + pricing_plan_id=path_params.pricing_plan_id, + service_key=body_params.service_key, + service_version=body_params.service_version, + ) ) connected_service_get = PricingPlanToServiceAdminGet( pricing_plan_id=connected_service.pricing_plan_id, diff --git a/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_admin_api.py b/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_admin_service.py similarity index 92% rename from services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_admin_api.py rename to services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_admin_service.py index 66668da89cb6..7f574b69d5d1 100644 --- a/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_admin_api.py +++ b/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_admin_service.py @@ -1,6 +1,7 @@ from aiohttp import web from models_library.api_schemas_resource_usage_tracker.pricing_plans import ( PricingPlanGet, + PricingPlanPage, PricingPlanToServiceGet, PricingUnitGet, ) @@ -28,11 +29,19 @@ async def list_pricing_plans( app: web.Application, + *, product_name: ProductName, -) -> list[PricingPlanGet]: + exclude_inactive: bool, + offset: int, + limit: int, +) -> PricingPlanPage: rpc_client = get_rabbitmq_rpc_client(app) - output: list[PricingPlanGet] = await pricing_plans.list_pricing_plans( - rpc_client, product_name=product_name + output: PricingPlanPage = await pricing_plans.list_pricing_plans( + rpc_client, + product_name=product_name, + exclude_inactive=exclude_inactive, + offset=offset, + limit=limit, ) return output diff --git a/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_handlers.py b/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_handlers.py deleted file mode 100644 index 294d4290b745..000000000000 --- a/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_handlers.py +++ /dev/null @@ -1,71 +0,0 @@ -import functools - -from aiohttp import web -from models_library.api_schemas_webserver.resource_usage import PricingUnitGet -from models_library.resource_tracker import PricingPlanId, PricingUnitId -from models_library.rest_base import StrictRequestParameters -from servicelib.aiohttp.requests_validation import parse_request_path_parameters_as -from servicelib.aiohttp.typing_extension import Handler - -from .._meta import API_VTAG as VTAG -from ..login.decorators import login_required -from ..models import RequestContext -from ..security.decorators import permission_required -from ..utils_aiohttp import envelope_json_response -from ..wallets.errors import WalletAccessForbiddenError -from . import _pricing_plans_api as api - -# -# API components/schemas -# - - -def _handle_resource_usage_exceptions(handler: Handler): - @functools.wraps(handler) - async def wrapper(request: web.Request) -> web.StreamResponse: - try: - return await handler(request) - - except WalletAccessForbiddenError as exc: - raise web.HTTPForbidden(reason=f"{exc}") from exc - - return wrapper - - -routes = web.RouteTableDef() - - -class PricingPlanUnitGetPathParams(StrictRequestParameters): - pricing_plan_id: PricingPlanId - pricing_unit_id: PricingUnitId - - -@routes.get( - f"/{VTAG}/pricing-plans/{{pricing_plan_id}}/pricing-units/{{pricing_unit_id}}", - name="get_pricing_plan_unit", -) -@login_required -@permission_required("resource-usage.read") -@_handle_resource_usage_exceptions -async def get_pricing_plan_unit(request: web.Request): - req_ctx = RequestContext.model_validate(request) - path_params = parse_request_path_parameters_as( - PricingPlanUnitGetPathParams, request - ) - - pricing_unit_get = await api.get_pricing_plan_unit( - app=request.app, - product_name=req_ctx.product_name, - pricing_plan_id=path_params.pricing_plan_id, - pricing_unit_id=path_params.pricing_unit_id, - ) - - webserver_pricing_unit_get = PricingUnitGet( - pricing_unit_id=pricing_unit_get.pricing_unit_id, - unit_name=pricing_unit_get.unit_name, - unit_extra_info=pricing_unit_get.unit_extra_info, - current_cost_per_unit=pricing_unit_get.current_cost_per_unit, - default=pricing_unit_get.default, - ) - - return envelope_json_response(webserver_pricing_unit_get, web.HTTPOk) diff --git a/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_models.py b/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_models.py new file mode 100644 index 000000000000..9798b7b111b6 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_models.py @@ -0,0 +1,6 @@ +from models_library.resource_tracker import PricingPlanId +from models_library.rest_base import StrictRequestParameters + + +class PricingPlanGetPathParams(StrictRequestParameters): + pricing_plan_id: PricingPlanId diff --git a/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_rest.py b/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_rest.py new file mode 100644 index 000000000000..e97446c2d88f --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_rest.py @@ -0,0 +1,175 @@ +import functools + +from aiohttp import web +from models_library.api_schemas_webserver.resource_usage import ( + PricingPlanGet, + PricingUnitGet, +) +from models_library.resource_tracker import PricingPlanId, PricingUnitId +from models_library.rest_base import StrictRequestParameters +from models_library.rest_pagination import Page, PageQueryParameters +from models_library.rest_pagination_utils import paginate_data +from servicelib.aiohttp.requests_validation import ( + parse_request_path_parameters_as, + parse_request_query_parameters_as, +) +from servicelib.aiohttp.typing_extension import Handler +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 ..models import RequestContext +from ..security.decorators import permission_required +from ..utils_aiohttp import envelope_json_response +from ..wallets.errors import WalletAccessForbiddenError +from . import _pricing_plans_admin_service as pricing_plans_admin_service +from . import _pricing_plans_service as pricing_plans_service +from ._pricing_plans_models import PricingPlanGetPathParams + +# +# API components/schemas +# + + +def _handle_resource_usage_exceptions(handler: Handler): + @functools.wraps(handler) + async def wrapper(request: web.Request) -> web.StreamResponse: + try: + return await handler(request) + + except WalletAccessForbiddenError as exc: + raise web.HTTPForbidden(reason=f"{exc}") from exc + + return wrapper + + +routes = web.RouteTableDef() + + +class PricingPlanUnitGetPathParams(StrictRequestParameters): + pricing_plan_id: PricingPlanId + pricing_unit_id: PricingUnitId + + +@routes.get( + f"/{VTAG}/pricing-plans/{{pricing_plan_id}}/pricing-units/{{pricing_unit_id}}", + name="get_pricing_plan_unit", +) +@login_required +@permission_required("resource-usage.read") +@_handle_resource_usage_exceptions +async def get_pricing_plan_unit(request: web.Request): + req_ctx = RequestContext.model_validate(request) + path_params = parse_request_path_parameters_as( + PricingPlanUnitGetPathParams, request + ) + + pricing_unit_get = await pricing_plans_service.get_pricing_plan_unit( + app=request.app, + product_name=req_ctx.product_name, + pricing_plan_id=path_params.pricing_plan_id, + pricing_unit_id=path_params.pricing_unit_id, + ) + + webserver_pricing_unit_get = PricingUnitGet( + pricing_unit_id=pricing_unit_get.pricing_unit_id, + unit_name=pricing_unit_get.unit_name, + unit_extra_info=pricing_unit_get.unit_extra_info, + current_cost_per_unit=pricing_unit_get.current_cost_per_unit, + default=pricing_unit_get.default, + ) + + return envelope_json_response(webserver_pricing_unit_get, web.HTTPOk) + + +@routes.get( + f"/{VTAG}/pricing-plans", + name="list_pricing_plans", +) +@login_required +@permission_required("resource-usage.read") +@_handle_resource_usage_exceptions +async def list_pricing_plans(request: web.Request): + req_ctx = RequestContext.model_validate(request) + query_params: PageQueryParameters = parse_request_query_parameters_as( + PageQueryParameters, request + ) + + pricing_plan_page = await pricing_plans_admin_service.list_pricing_plans( + app=request.app, + product_name=req_ctx.product_name, + exclude_inactive=True, + offset=query_params.offset, + limit=query_params.limit, + ) + webserver_pricing_plans = [ + PricingPlanGet( + pricing_plan_id=pricing_plan.pricing_plan_id, + display_name=pricing_plan.display_name, + description=pricing_plan.description, + classification=pricing_plan.classification, + created_at=pricing_plan.created_at, + pricing_plan_key=pricing_plan.pricing_plan_key, + pricing_units=None, + is_active=pricing_plan.is_active, + ) + for pricing_plan in pricing_plan_page.items + ] + + page = Page[PricingPlanGet].model_validate( + paginate_data( + chunk=webserver_pricing_plans, + request_url=request.url, + total=pricing_plan_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}/pricing-plans/{{pricing_plan_id}}", + name="get_pricing_plan", +) +@login_required +@permission_required("resource-usage.read") +@_handle_resource_usage_exceptions +async def get_pricing_plan(request: web.Request): + req_ctx = RequestContext.model_validate(request) + path_params = parse_request_path_parameters_as(PricingPlanGetPathParams, request) + + pricing_plan_get = await pricing_plans_admin_service.get_pricing_plan( + app=request.app, + product_name=req_ctx.product_name, + pricing_plan_id=path_params.pricing_plan_id, + ) + if pricing_plan_get.pricing_units is None: + msg = "Pricing plan units should not be None" + raise ValueError(msg) + + webserver_admin_pricing_plan_get = PricingPlanGet( + pricing_plan_id=pricing_plan_get.pricing_plan_id, + display_name=pricing_plan_get.display_name, + description=pricing_plan_get.description, + classification=pricing_plan_get.classification, + created_at=pricing_plan_get.created_at, + pricing_plan_key=pricing_plan_get.pricing_plan_key, + pricing_units=[ + PricingUnitGet( + pricing_unit_id=pricing_unit.pricing_unit_id, + unit_name=pricing_unit.unit_name, + unit_extra_info=pricing_unit.unit_extra_info, + current_cost_per_unit=pricing_unit.current_cost_per_unit, + default=pricing_unit.default, + ) + for pricing_unit in pricing_plan_get.pricing_units + ], + is_active=pricing_plan_get.is_active, + ) + + return envelope_json_response(webserver_admin_pricing_plan_get, web.HTTPOk) diff --git a/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_api.py b/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_service.py similarity index 100% rename from services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_api.py rename to services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_service.py diff --git a/services/web/server/src/simcore_service_webserver/resource_usage/_service_runs_handlers.py b/services/web/server/src/simcore_service_webserver/resource_usage/_service_runs_rest.py similarity index 97% rename from services/web/server/src/simcore_service_webserver/resource_usage/_service_runs_handlers.py rename to services/web/server/src/simcore_service_webserver/resource_usage/_service_runs_rest.py index 4f952a932dee..53b6297937bc 100644 --- a/services/web/server/src/simcore_service_webserver/resource_usage/_service_runs_handlers.py +++ b/services/web/server/src/simcore_service_webserver/resource_usage/_service_runs_rest.py @@ -6,6 +6,7 @@ OsparcCreditsAggregatedUsagesPage, ServiceRunPage, ) +from models_library.api_schemas_webserver.resource_usage import ServiceRunGet from models_library.basic_types import IDStr from models_library.resource_tracker import ( ServiceResourceUsagesFilters, @@ -32,7 +33,7 @@ from ..models import RequestContext from ..security.decorators import permission_required from ..wallets.errors import WalletAccessForbiddenError -from . import _service_runs_api as api +from . import _service_runs_service as api # # API components/schemas @@ -142,7 +143,7 @@ async def list_resource_usage_services(request: web.Request): ), ) - page = Page[dict[str, Any]].model_validate( + page = Page[ServiceRunGet].model_validate( paginate_data( chunk=services.items, request_url=request.url, diff --git a/services/web/server/src/simcore_service_webserver/resource_usage/_service_runs_api.py b/services/web/server/src/simcore_service_webserver/resource_usage/_service_runs_service.py similarity index 100% rename from services/web/server/src/simcore_service_webserver/resource_usage/_service_runs_api.py rename to services/web/server/src/simcore_service_webserver/resource_usage/_service_runs_service.py diff --git a/services/web/server/src/simcore_service_webserver/resource_usage/plugin.py b/services/web/server/src/simcore_service_webserver/resource_usage/plugin.py index cfad43b73986..32fcae9266ee 100644 --- a/services/web/server/src/simcore_service_webserver/resource_usage/plugin.py +++ b/services/web/server/src/simcore_service_webserver/resource_usage/plugin.py @@ -9,11 +9,7 @@ from ..rabbitmq import setup_rabbitmq from ..wallets.plugin import setup_wallets -from . import ( - _pricing_plans_admin_handlers, - _pricing_plans_handlers, - _service_runs_handlers, -) +from . import _pricing_plans_admin_rest, _pricing_plans_rest, _service_runs_rest from ._observer import setup_resource_usage_observer_events _logger = logging.getLogger(__name__) @@ -33,6 +29,6 @@ def setup_resource_tracker(app: web.Application): setup_wallets(app) setup_resource_usage_observer_events(app) - app.router.add_routes(_service_runs_handlers.routes) - app.router.add_routes(_pricing_plans_handlers.routes) - app.router.add_routes(_pricing_plans_admin_handlers.routes) + app.router.add_routes(_service_runs_rest.routes) + app.router.add_routes(_pricing_plans_rest.routes) + app.router.add_routes(_pricing_plans_admin_rest.routes) diff --git a/services/web/server/src/simcore_service_webserver/resource_usage/api.py b/services/web/server/src/simcore_service_webserver/resource_usage/service.py similarity index 93% rename from services/web/server/src/simcore_service_webserver/resource_usage/api.py rename to services/web/server/src/simcore_service_webserver/resource_usage/service.py index a65b538172d1..05992fe36e47 100644 --- a/services/web/server/src/simcore_service_webserver/resource_usage/api.py +++ b/services/web/server/src/simcore_service_webserver/resource_usage/service.py @@ -11,7 +11,10 @@ from models_library.wallets import WalletID from . import _client -from ._pricing_plans_api import get_default_service_pricing_plan, get_pricing_plan_unit +from ._pricing_plans_service import ( + get_default_service_pricing_plan, + get_pricing_plan_unit, +) async def get_wallet_total_available_credits( diff --git a/services/web/server/src/simcore_service_webserver/wallets/_api.py b/services/web/server/src/simcore_service_webserver/wallets/_api.py index c2af40743782..a9721f5dfe08 100644 --- a/services/web/server/src/simcore_service_webserver/wallets/_api.py +++ b/services/web/server/src/simcore_service_webserver/wallets/_api.py @@ -15,7 +15,7 @@ from models_library.wallets import UserWalletDB, WalletDB, WalletID, WalletStatus from pydantic import TypeAdapter -from ..resource_usage.api import get_wallet_total_available_credits +from ..resource_usage.service import get_wallet_total_available_credits from ..users import api as users_api from ..users import preferences_api as user_preferences_api from ..users.exceptions import UserDefaultWalletNotFoundError diff --git a/services/web/server/src/simcore_service_webserver/wallets/_events.py b/services/web/server/src/simcore_service_webserver/wallets/_events.py index 67d5ebcd2594..5e881ebdae53 100644 --- a/services/web/server/src/simcore_service_webserver/wallets/_events.py +++ b/services/web/server/src/simcore_service_webserver/wallets/_events.py @@ -8,7 +8,7 @@ from servicelib.aiohttp.observer import register_observer, setup_observer_registry from ..products.api import get_product -from ..resource_usage.api import add_credits_to_wallet +from ..resource_usage.service import add_credits_to_wallet from ..users import preferences_api from ..users.api import get_user_display_and_id_names from ._api import any_wallet_owned_by_user, create_wallet diff --git a/services/web/server/tests/unit/with_dbs/03/resource_usage/conftest.py b/services/web/server/tests/unit/with_dbs/03/resource_usage/conftest.py index a4691fcc3a21..28a01dacbc84 100644 --- a/services/web/server/tests/unit/with_dbs/03/resource_usage/conftest.py +++ b/services/web/server/tests/unit/with_dbs/03/resource_usage/conftest.py @@ -3,7 +3,17 @@ # pylint:disable=redefined-outer-name +from unittest.mock import MagicMock + import pytest +from faker import Faker +from models_library.api_schemas_resource_usage_tracker.pricing_plans import ( + PricingPlanGet, + PricingPlanPage, + PricingPlanToServiceGet, + PricingUnitGet, +) +from pytest_mock import MockerFixture from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict from pytest_simcore.helpers.typing_env import EnvVarsDict @@ -27,3 +37,88 @@ def app_environment(app_environment: EnvVarsDict, monkeypatch: pytest.MonkeyPatc "WEBSERVER_ANNOUNCEMENTS": "1", }, ) + + +@pytest.fixture +def mock_rpc_resource_usage_tracker_service_api( + mocker: MockerFixture, faker: Faker +) -> dict[str, MagicMock]: + return { + ## Pricing plans + "list_pricing_plans": mocker.patch( + "simcore_service_webserver.resource_usage._pricing_plans_admin_service.pricing_plans.list_pricing_plans", + autospec=True, + return_value=PricingPlanPage( + items=[ + PricingPlanGet.model_validate( + PricingPlanGet.model_config["json_schema_extra"]["examples"][0], + ) + ], + total=1, + ), + ), + "get_pricing_plan": mocker.patch( + "simcore_service_webserver.resource_usage._pricing_plans_admin_service.pricing_plans.get_pricing_plan", + autospec=True, + return_value=PricingPlanGet.model_validate( + PricingPlanGet.model_config["json_schema_extra"]["examples"][0], + ), + ), + "create_pricing_plan": mocker.patch( + "simcore_service_webserver.resource_usage._pricing_plans_admin_service.pricing_plans.create_pricing_plan", + autospec=True, + return_value=PricingPlanGet.model_validate( + PricingPlanGet.model_config["json_schema_extra"]["examples"][0], + ), + ), + "update_pricing_plan": mocker.patch( + "simcore_service_webserver.resource_usage._pricing_plans_admin_service.pricing_plans.update_pricing_plan", + autospec=True, + return_value=PricingPlanGet.model_validate( + PricingPlanGet.model_config["json_schema_extra"]["examples"][0], + ), + ), + ## Pricing units + "get_pricing_unit": mocker.patch( + "simcore_service_webserver.resource_usage._pricing_plans_admin_service.pricing_units.get_pricing_unit", + autospec=True, + return_value=PricingUnitGet.model_validate( + PricingUnitGet.model_config["json_schema_extra"]["examples"][0], + ), + ), + "create_pricing_unit": mocker.patch( + "simcore_service_webserver.resource_usage._pricing_plans_admin_service.pricing_units.create_pricing_unit", + autospec=True, + return_value=PricingUnitGet.model_validate( + PricingUnitGet.model_config["json_schema_extra"]["examples"][0], + ), + ), + "update_pricing_unit": mocker.patch( + "simcore_service_webserver.resource_usage._pricing_plans_admin_service.pricing_units.update_pricing_unit", + autospec=True, + return_value=PricingUnitGet.model_validate( + PricingUnitGet.model_config["json_schema_extra"]["examples"][0], + ), + ), + ## Pricing plan to service + "list_connected_services_to_pricing_plan_by_pricing_plan": mocker.patch( + "simcore_service_webserver.resource_usage._pricing_plans_admin_service.pricing_plans.list_connected_services_to_pricing_plan_by_pricing_plan", + autospec=True, + return_value=[ + PricingPlanToServiceGet.model_validate( + PricingPlanToServiceGet.model_config["json_schema_extra"][ + "examples" + ][0], + ) + ], + ), + "connect_service_to_pricing_plan": mocker.patch( + "simcore_service_webserver.resource_usage._pricing_plans_admin_service.pricing_plans.connect_service_to_pricing_plan", + autospec=True, + return_value=PricingPlanToServiceGet.model_validate( + PricingPlanToServiceGet.model_config["json_schema_extra"]["examples"][ + 0 + ], + ), + ), + } diff --git a/services/web/server/tests/unit/with_dbs/03/resource_usage/test_admin_pricing_plans.py b/services/web/server/tests/unit/with_dbs/03/resource_usage/test_admin_pricing_plans.py index ad508f523e4c..cfb2b06a7899 100644 --- a/services/web/server/tests/unit/with_dbs/03/resource_usage/test_admin_pricing_plans.py +++ b/services/web/server/tests/unit/with_dbs/03/resource_usage/test_admin_pricing_plans.py @@ -11,11 +11,6 @@ import pytest from aiohttp.test_utils import TestClient from faker import Faker -from models_library.api_schemas_resource_usage_tracker.pricing_plans import ( - PricingPlanGet, - PricingPlanToServiceGet, - PricingUnitGet, -) from models_library.resource_tracker import PricingPlanClassification from pytest_mock import MockerFixture from pytest_simcore.helpers.assert_checks import assert_status @@ -24,93 +19,11 @@ from simcore_service_webserver.db.models import UserRole -@pytest.fixture -def mock_rpc_resource_usage_tracker_service_api( - mocker: MockerFixture, faker: Faker -) -> dict[str, MagicMock]: - return { - ## Pricing plans - "list_pricing_plans": mocker.patch( - "simcore_service_webserver.resource_usage._pricing_plans_admin_api.pricing_plans.list_pricing_plans", - autospec=True, - return_value=[ - PricingPlanGet.model_validate( - PricingPlanGet.model_config["json_schema_extra"]["examples"][0], - ) - ], - ), - "get_pricing_plan": mocker.patch( - "simcore_service_webserver.resource_usage._pricing_plans_admin_api.pricing_plans.get_pricing_plan", - autospec=True, - return_value=PricingPlanGet.model_validate( - PricingPlanGet.model_config["json_schema_extra"]["examples"][0], - ), - ), - "create_pricing_plan": mocker.patch( - "simcore_service_webserver.resource_usage._pricing_plans_admin_api.pricing_plans.create_pricing_plan", - autospec=True, - return_value=PricingPlanGet.model_validate( - PricingPlanGet.model_config["json_schema_extra"]["examples"][0], - ), - ), - "update_pricing_plan": mocker.patch( - "simcore_service_webserver.resource_usage._pricing_plans_admin_api.pricing_plans.update_pricing_plan", - autospec=True, - return_value=PricingPlanGet.model_validate( - PricingPlanGet.model_config["json_schema_extra"]["examples"][0], - ), - ), - ## Pricing units - "get_pricing_unit": mocker.patch( - "simcore_service_webserver.resource_usage._pricing_plans_admin_api.pricing_units.get_pricing_unit", - autospec=True, - return_value=PricingUnitGet.model_validate( - PricingUnitGet.model_config["json_schema_extra"]["examples"][0], - ), - ), - "create_pricing_unit": mocker.patch( - "simcore_service_webserver.resource_usage._pricing_plans_admin_api.pricing_units.create_pricing_unit", - autospec=True, - return_value=PricingUnitGet.model_validate( - PricingUnitGet.model_config["json_schema_extra"]["examples"][0], - ), - ), - "update_pricing_unit": mocker.patch( - "simcore_service_webserver.resource_usage._pricing_plans_admin_api.pricing_units.update_pricing_unit", - autospec=True, - return_value=PricingUnitGet.model_validate( - PricingUnitGet.model_config["json_schema_extra"]["examples"][0], - ), - ), - ## Pricing plan to service - "list_connected_services_to_pricing_plan_by_pricing_plan": mocker.patch( - "simcore_service_webserver.resource_usage._pricing_plans_admin_api.pricing_plans.list_connected_services_to_pricing_plan_by_pricing_plan", - autospec=True, - return_value=[ - PricingPlanToServiceGet.model_validate( - PricingPlanToServiceGet.model_config["json_schema_extra"][ - "examples" - ][0], - ) - ], - ), - "connect_service_to_pricing_plan": mocker.patch( - "simcore_service_webserver.resource_usage._pricing_plans_admin_api.pricing_plans.connect_service_to_pricing_plan", - autospec=True, - return_value=PricingPlanToServiceGet.model_validate( - PricingPlanToServiceGet.model_config["json_schema_extra"]["examples"][ - 0 - ], - ), - ), - } - - @pytest.fixture def mock_catalog_client(mocker: MockerFixture, faker: Faker) -> dict[str, MagicMock]: return { "get_service": mocker.patch( - "simcore_service_webserver.resource_usage._pricing_plans_admin_api.catalog_client.get_service", + "simcore_service_webserver.resource_usage._pricing_plans_admin_service.catalog_client.get_service", autospec=True, ) } @@ -137,11 +50,13 @@ async def test_get_admin_pricing_endpoints_user_role_access( ): ## Pricing plans - url = client.app.router["list_pricing_plans"].url_for() + url = client.app.router["list_pricing_plans_for_admin_user"].url_for() resp = await client.get(f"{url}") await assert_status(resp, expected) - url = client.app.router["get_pricing_plan"].url_for(pricing_plan_id="1") + url = client.app.router["get_pricing_plan_for_admin_user"].url_for( + pricing_plan_id="1" + ) resp = await client.get(f"{url}") await assert_status(resp, expected) diff --git a/services/web/server/tests/unit/with_dbs/03/resource_usage/test_list_osparc_credits_aggregated_usages.py b/services/web/server/tests/unit/with_dbs/03/resource_usage/test_list_osparc_credits_aggregated_usages.py index bb6f2123276a..08b00cefd1b1 100644 --- a/services/web/server/tests/unit/with_dbs/03/resource_usage/test_list_osparc_credits_aggregated_usages.py +++ b/services/web/server/tests/unit/with_dbs/03/resource_usage/test_list_osparc_credits_aggregated_usages.py @@ -39,7 +39,7 @@ @pytest.fixture def mock_get_osparc_credits_aggregated_usages_page(mocker: MockerFixture) -> MagicMock: return mocker.patch( - "simcore_service_webserver.resource_usage._service_runs_api.service_runs.get_osparc_credits_aggregated_usages_page", + "simcore_service_webserver.resource_usage._service_runs_service.service_runs.get_osparc_credits_aggregated_usages_page", spec=True, return_value=_SERVICE_RUN_GET, ) diff --git a/services/web/server/tests/unit/with_dbs/03/resource_usage/test_pricing_plans.py b/services/web/server/tests/unit/with_dbs/03/resource_usage/test_pricing_plans.py index 701148200363..bbe5fa9f9515 100644 --- a/services/web/server/tests/unit/with_dbs/03/resource_usage/test_pricing_plans.py +++ b/services/web/server/tests/unit/with_dbs/03/resource_usage/test_pricing_plans.py @@ -14,6 +14,7 @@ PricingPlanGet, PricingUnitGet, ) +from models_library.api_schemas_webserver import resource_usage as webserver_api from models_library.utils.fastapi_encoders import jsonable_encoder from pytest_simcore.aioresponses_mocker import AioResponsesMock from pytest_simcore.helpers.assert_checks import assert_status @@ -101,3 +102,23 @@ async def test_get_pricing_plan( data, _ = await assert_status(resp, status.HTTP_200_OK) assert data["pricingPlanKey"] == "pricing-plan-sleeper" assert len(data["pricingUnits"]) == 1 + + +@pytest.mark.parametrize("user_role", [(UserRole.USER)]) +async def test_list_pricing_plans( + client: TestClient, + logged_user: UserInfoDict, + mock_rpc_resource_usage_tracker_service_api: AioResponsesMock, +): + url = client.app.router["list_pricing_plans"].url_for() + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert isinstance(data, list) + for item in data: + assert webserver_api.PricingPlanGet(**item) + assert len(item) == len(webserver_api.PricingPlanGet(**item).model_dump()) + url = client.app.router["get_pricing_plan"].url_for(pricing_plan_id="1") + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert webserver_api.PricingPlanGet(**data) + assert len(data) == len(webserver_api.PricingPlanGet(**data).model_dump()) diff --git a/services/web/server/tests/unit/with_dbs/03/resource_usage/test_usage_services__export.py b/services/web/server/tests/unit/with_dbs/03/resource_usage/test_usage_services__export.py index c91141a06746..ce24dfb9c439 100644 --- a/services/web/server/tests/unit/with_dbs/03/resource_usage/test_usage_services__export.py +++ b/services/web/server/tests/unit/with_dbs/03/resource_usage/test_usage_services__export.py @@ -27,7 +27,7 @@ @pytest.fixture def mock_export_usage_services(mocker: MockerFixture) -> MagicMock: return mocker.patch( - "simcore_service_webserver.resource_usage._service_runs_api.service_runs.export_service_runs", + "simcore_service_webserver.resource_usage._service_runs_service.service_runs.export_service_runs", spec=True, return_value=TypeAdapter(AnyUrl).validate_python("https://www.google.com/"), ) diff --git a/services/web/server/tests/unit/with_dbs/03/resource_usage/test_usage_services__list.py b/services/web/server/tests/unit/with_dbs/03/resource_usage/test_usage_services__list.py index 32480e03762d..1370507e491e 100644 --- a/services/web/server/tests/unit/with_dbs/03/resource_usage/test_usage_services__list.py +++ b/services/web/server/tests/unit/with_dbs/03/resource_usage/test_usage_services__list.py @@ -58,7 +58,7 @@ @pytest.fixture def mock_list_usage_services(mocker: MockerFixture) -> tuple: return mocker.patch( - "simcore_service_webserver.resource_usage._service_runs_api.service_runs.get_service_run_page", + "simcore_service_webserver.resource_usage._service_runs_service.service_runs.get_service_run_page", spec=True, return_value=_SERVICE_RUN_GET, ) 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 ee3656d2c1c9..a164c1b64068 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 @@ -34,6 +34,7 @@ "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", }