From 707d18589db2c64d0a805abf01da9b94b6562173 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 16 Oct 2023 17:16:54 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Enhancements=20for=20product-owner?= =?UTF-8?q?=20users=20and=20invitations=20(#4862)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env-devel | 2 +- api/specs/web-server/_products.py | 18 ++- .../api_schemas_webserver/product.py | 30 +++- .../src/models_library/invitations.py | 4 +- .../models/products_prices.py | 3 + .../utils_products_prices.py | 10 +- .../pytest_simcore/helpers/utils_assert.py | 20 ++- .../src/pytest_simcore/helpers/utils_login.py | 6 +- services/invitations/openapi.json | 18 +-- .../invitations.py | 2 +- .../source/class/osparc/po/Invitations.js | 8 +- services/web/server/VERSION | 2 +- services/web/server/setup.cfg | 2 +- .../api/v0/openapi.yaml | 149 +++++++++++++---- .../login/_registration.py | 8 +- .../login/handlers_confirmation.py | 6 +- .../login/handlers_registration.py | 2 +- .../simcore_service_webserver/login/utils.py | 4 +- .../products/_api.py | 7 +- .../simcore_service_webserver/products/_db.py | 34 +++- .../products/_events.py | 12 +- .../products/_handlers.py | 45 +++++- .../products/_invitations_handlers.py | 4 +- .../products/_model.py | 6 + .../simcore_service_webserver/products/api.py | 2 + .../resource_usage/api.py | 1 + .../security/_access_roles.py | 2 +- .../wallets/_events.py | 27 +++- .../server/tests/unit/with_dbs/03/conftest.py | 49 ++++-- .../03/invitations/test_invitations.py | 62 +------- .../invitations/test_products_invitations.py | 150 ++++++++++++++++++ .../03/products/test_product_prices.py | 35 ---- .../03/products/test_products_handlers.py | 108 +++++++++++++ .../03/wallets/payments/test_payments.py | 12 +- .../unit/with_dbs/03/wallets/test_wallets.py | 25 ++- 35 files changed, 643 insertions(+), 232 deletions(-) create mode 100644 services/web/server/tests/unit/with_dbs/03/invitations/test_products_invitations.py delete mode 100644 services/web/server/tests/unit/with_dbs/03/products/test_product_prices.py create mode 100644 services/web/server/tests/unit/with_dbs/03/products/test_products_handlers.py diff --git a/.env-devel b/.env-devel index 7eb1fc05ca4..6fad0eb21b9 100644 --- a/.env-devel +++ b/.env-devel @@ -117,7 +117,7 @@ S3_SECURE=0 SCICRUNCH_API_BASE_URL=https://scicrunch.org/api/1 SCICRUNCH_API_KEY=REPLACE_ME_with_valid_api_key -SMTP_HOST=mail.speag.com +SMTP_HOST=fake.mail.server.com SMTP_PORT=25 SMTP_USERNAME=it_doesnt_matter SMTP_PASSWORD=it_doesnt_matter diff --git a/api/specs/web-server/_products.py b/api/specs/web-server/_products.py index 4bcccf5bc1a..568b123cbb4 100644 --- a/api/specs/web-server/_products.py +++ b/api/specs/web-server/_products.py @@ -5,14 +5,18 @@ # pylint: disable=unused-variable -from fastapi import APIRouter +from typing import Annotated + +from fastapi import APIRouter, Depends from models_library.api_schemas_webserver.product import ( - CreditPriceGet, GenerateInvitation, + GetCreditPrice, + GetProduct, InvitationGenerated, ) from models_library.generics import Envelope from simcore_service_webserver._meta import API_VTAG +from simcore_service_webserver.products._handlers import _ProductsRequestParams router = APIRouter( prefix=f"/{API_VTAG}", @@ -24,12 +28,20 @@ @router.get( "/credits-price", - response_model=Envelope[CreditPriceGet], + response_model=Envelope[GetCreditPrice], ) async def get_current_product_price(): ... +@router.get( + "/products/{product_name}", + response_model=Envelope[GetProduct], +) +async def get_product(_params: Annotated[_ProductsRequestParams, Depends()]): + ... + + @router.post( "/invitation:generate", response_model=Envelope[InvitationGenerated], diff --git a/packages/models-library/src/models_library/api_schemas_webserver/product.py b/packages/models-library/src/models_library/api_schemas_webserver/product.py index c4dde433e65..ce8a2059626 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/product.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/product.py @@ -9,7 +9,7 @@ from ._base import InputSchema, OutputSchema -class CreditPriceGet(OutputSchema): +class GetCreditPrice(OutputSchema): product_name: str usd_per_credit: NonNegativeDecimal | None = Field( ..., @@ -26,10 +26,32 @@ class Config(OutputSchema.Config): } +class GetProduct(OutputSchema): + name: ProductName + display_name: str + short_name: str | None = Field( + default=None, description="Short display name for SMS" + ) + + vendor: dict | None = Field(default=None, description="vendor attributes") + issues: list[dict] | None = Field( + default=None, description="Reference to issues tracker" + ) + manuals: list[dict] | None = Field(default=None, description="List of manuals") + support: list[dict] | None = Field( + default=None, description="List of support resources" + ) + + login_settings: dict + max_open_studies_per_user: PositiveInt | None + is_payment_enabled: bool + credits_per_usd: NonNegativeDecimal | None + + class GenerateInvitation(InputSchema): guest: LowerCaseEmailStr trial_account_days: PositiveInt | None = None - extra_credits: PositiveInt | None = None + extra_credits_in_usd: PositiveInt | None = None class InvitationGenerated(OutputSchema): @@ -37,7 +59,7 @@ class InvitationGenerated(OutputSchema): issuer: LowerCaseEmailStr guest: LowerCaseEmailStr trial_account_days: PositiveInt | None = None - extra_credits: PositiveInt | None = None + extra_credits_in_usd: PositiveInt | None = None created: datetime invitation_link: HttpUrl @@ -49,7 +71,7 @@ class Config(OutputSchema.Config): "issuer": "john.doe@email.com", "guest": "guest@example.com", "trialAccountDays": 7, - "extraCredits": 30, + "extraCreditsInUsd": 30, "created": "2023-09-27T15:30:00", "invitationLink": "https://example.com/invitation#1234", }, diff --git a/packages/models-library/src/models_library/invitations.py b/packages/models-library/src/models_library/invitations.py index 7a64b0c7e68..1b89004b81e 100644 --- a/packages/models-library/src/models_library/invitations.py +++ b/packages/models-library/src/models_library/invitations.py @@ -23,9 +23,9 @@ class InvitationInputs(BaseModel): description="If set, this invitation will activate a trial account." "Sets the number of days from creation until the account expires", ) - extra_credits: PositiveInt | None = Field( + extra_credits_in_usd: PositiveInt | None = Field( None, - description="If set, the account's primary wallet will add these extra credits", + description="If set, the account's primary wallet will add extra credits corresponding to this ammount in USD", ) diff --git a/packages/postgres-database/src/simcore_postgres_database/models/products_prices.py b/packages/postgres-database/src/simcore_postgres_database/models/products_prices.py index ac6a7f5c400..b6cf898584c 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/products_prices.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/products_prices.py @@ -49,3 +49,6 @@ "usd_per_credit >= 0", name="non_negative_usd_per_credit_constraint" ), ) + + +__all__: tuple[str, ...] = ("NUMERIC_KWARGS",) diff --git a/packages/postgres-database/src/simcore_postgres_database/utils_products_prices.py b/packages/postgres-database/src/simcore_postgres_database/utils_products_prices.py index db82ebbd963..25ddd26a465 100644 --- a/packages/postgres-database/src/simcore_postgres_database/utils_products_prices.py +++ b/packages/postgres-database/src/simcore_postgres_database/utils_products_prices.py @@ -1,8 +1,14 @@ from decimal import Decimal +from typing import Final import sqlalchemy as sa from aiopg.sa.connection import SAConnection -from simcore_postgres_database.models.products_prices import products_prices +from simcore_postgres_database.models.products_prices import ( + NUMERIC_KWARGS, + products_prices, +) + +QUANTIZE_EXP_ARG: Final = Decimal(f"1e-{NUMERIC_KWARGS['scale']}") async def get_product_latest_credit_price_or_none( @@ -16,7 +22,7 @@ async def get_product_latest_credit_price_or_none( .limit(1) ) if usd_per_credit is not None: - return Decimal(usd_per_credit) + return Decimal(usd_per_credit).quantize(QUANTIZE_EXP_ARG) return None diff --git a/packages/pytest-simcore/src/pytest_simcore/helpers/utils_assert.py b/packages/pytest-simcore/src/pytest_simcore/helpers/utils_assert.py index 5208c8c4fe0..d28b1ca68ec 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/utils_assert.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/utils_assert.py @@ -2,7 +2,6 @@ """ from pprint import pformat -from typing import Optional from aiohttp import ClientResponse from aiohttp.web import HTTPError, HTTPException, HTTPInternalServerError, HTTPNoContent @@ -12,10 +11,10 @@ async def assert_status( response: ClientResponse, expected_cls: type[HTTPException], - expected_msg: Optional[str] = None, - expected_error_code: Optional[str] = None, - include_meta: Optional[bool] = False, - include_links: Optional[bool] = False, + expected_msg: str | None = None, + expected_error_code: str | None = None, + include_meta: bool | None = False, + include_links: bool | None = False, ) -> tuple[dict, ...]: """ Asserts for enveloped responses @@ -34,9 +33,8 @@ async def assert_status( assert not data, pformat(data) assert not error, pformat(error) else: - # with a 200, data may still be empty see - # https://medium.com/@santhoshkumarkrishna/http-get-rest-api-no-content-404-vs-204-vs-200-6dd869e3af1d - # assert data is not None, pformat(data) + # with a 200, data may still be empty so we cannot 'assert data is not None' + # SEE https://medium.com/@santhoshkumarkrishna/http-get-rest-api-no-content-404-vs-204-vs-200-6dd869e3af1d assert not error, pformat(error) if expected_msg: @@ -56,7 +54,7 @@ async def assert_status( async def assert_error( response: ClientResponse, expected_cls: type[HTTPException], - expected_msg: Optional[str] = None, + expected_msg: str | None = None, ): data, error = unwrap_envelope(await response.json()) return do_assert_error(data, error, expected_cls, expected_msg) @@ -66,8 +64,8 @@ def do_assert_error( data, error, expected_cls: type[HTTPException], - expected_msg: Optional[str] = None, - expected_error_code: Optional[str] = None, + expected_msg: str | None = None, + expected_error_code: str | None = None, ): assert not data, pformat(data) assert error, pformat(error) diff --git a/packages/pytest-simcore/src/pytest_simcore/helpers/utils_login.py b/packages/pytest-simcore/src/pytest_simcore/helpers/utils_login.py index 52c8c5fa88c..f52d50ae44d 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/utils_login.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/utils_login.py @@ -126,7 +126,7 @@ def __init__( guest_email: str | None = None, host: dict | None = None, trial_days: int | None = None, - extra_credits: int | None = None, + extra_credits_in_usd: int | None = None, ): assert client.app super().__init__(params=host, app=client.app) @@ -134,7 +134,7 @@ def __init__( self.tag = f"Created by {guest_email or FAKE.email()}" self.confirmation = None self.trial_days = trial_days - self.extra_credits = extra_credits + self.extra_credits_in_usd = extra_credits_in_usd async def __aenter__(self) -> "NewInvitation": # creates host user @@ -148,7 +148,7 @@ async def __aenter__(self) -> "NewInvitation": user_email=self.user["email"], tag=self.tag, trial_days=self.trial_days, - extra_credits=self.extra_credits, + extra_credits_in_usd=self.extra_credits_in_usd, ) return self diff --git a/services/invitations/openapi.json b/services/invitations/openapi.json index 9aa20746979..32714d47661 100644 --- a/services/invitations/openapi.json +++ b/services/invitations/openapi.json @@ -188,11 +188,11 @@ "description": "If set, this invitation will activate a trial account.Sets the number of days from creation until the account expires", "minimum": 0 }, - "extra_credits": { - "title": "Extra Credits", + "extra_credits_in_usd": { + "title": "Extra Credits In Usd", "exclusiveMinimum": true, "type": "integer", - "description": "If set, the account's primary wallet will add these extra credits", + "description": "If set, the account's primary wallet will add extra credits corresponding to this ammount in USD", "minimum": 0 }, "created": { @@ -240,11 +240,11 @@ "description": "If set, this invitation will activate a trial account.Sets the number of days from creation until the account expires", "minimum": 0 }, - "extra_credits": { - "title": "Extra Credits", + "extra_credits_in_usd": { + "title": "Extra Credits In Usd", "exclusiveMinimum": true, "type": "integer", - "description": "If set, the account's primary wallet will add these extra credits", + "description": "If set, the account's primary wallet will add extra credits corresponding to this ammount in USD", "minimum": 0 }, "created": { @@ -299,11 +299,11 @@ "description": "If set, this invitation will activate a trial account.Sets the number of days from creation until the account expires", "minimum": 0 }, - "extra_credits": { - "title": "Extra Credits", + "extra_credits_in_usd": { + "title": "Extra Credits In Usd", "exclusiveMinimum": true, "type": "integer", - "description": "If set, the account's primary wallet will add these extra credits", + "description": "If set, the account's primary wallet will add extra credits corresponding to this ammount in USD", "minimum": 0 } }, diff --git a/services/invitations/src/simcore_service_invitations/invitations.py b/services/invitations/src/simcore_service_invitations/invitations.py index 3cbb9f8175c..bdc6f3b1e2e 100644 --- a/services/invitations/src/simcore_service_invitations/invitations.py +++ b/services/invitations/src/simcore_service_invitations/invitations.py @@ -60,7 +60,7 @@ class Config: "trial_account_days": { "alias": "t", }, - "extra_credits": { + "extra_credits_in_usd": { "alias": "e", }, "created": { diff --git a/services/static-webserver/client/source/class/osparc/po/Invitations.js b/services/static-webserver/client/source/class/osparc/po/Invitations.js index ef514bf454b..87e54f65668 100644 --- a/services/static-webserver/client/source/class/osparc/po/Invitations.js +++ b/services/static-webserver/client/source/class/osparc/po/Invitations.js @@ -99,12 +99,12 @@ qx.Class.define("osparc.po.Invitations", { }); form.add(userEmail, this.tr("User Email")); - const extraCredits = new qx.ui.form.Spinner().set({ + const extraCreditsInUsd = new qx.ui.form.Spinner().set({ minimum: 0, maximum: 1000, value: 0 }); - form.add(extraCredits, this.tr("Welcome Credits")); + form.add(extraCreditsInUsd, this.tr("Welcome Credits (US $)")); const withExpiration = new qx.ui.form.CheckBox().set({ value: false @@ -135,8 +135,8 @@ qx.Class.define("osparc.po.Invitations", { "guest": userEmail.getValue() } }; - if (extraCredits.getValue() > 0) { - params.data["extraCredits"] = extraCredits.getValue(); + if (extraCreditsInUsd.getValue() > 0) { + params.data["extraCreditsInUsd"] = extraCreditsInUsd.getValue(); } if (withExpiration.getValue()) { params.data["trialAccountDays"] = trialDays.getValue(); diff --git a/services/web/server/VERSION b/services/web/server/VERSION index be386c9ede3..85e60ed180c 100644 --- a/services/web/server/VERSION +++ b/services/web/server/VERSION @@ -1 +1 @@ -0.33.0 +0.34.0 diff --git a/services/web/server/setup.cfg b/services/web/server/setup.cfg index 1e3b46f7274..c1dba20396f 100644 --- a/services/web/server/setup.cfg +++ b/services/web/server/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.33.0 +current_version = 0.34.0 commit = True message = services/webserver api version: {current_version} → {new_version} tag = False 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 6d4365c5bc2..d9a1b0ba026 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 @@ -3,7 +3,7 @@ info: title: simcore-service-webserver description: ' Main service with an interface (http-API & websockets) to the web front-end' - version: 0.33.0 + version: 0.34.0 servers: - url: '' description: webserver @@ -1802,7 +1802,32 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Envelope_CreditPriceGet_' + $ref: '#/components/schemas/Envelope_GetCreditPrice_' + /v0/products/{product_name}: + get: + tags: + - products + summary: Get Product + operationId: get_product + parameters: + - required: true + schema: + title: Product Name + anyOf: + - minLength: 1 + type: string + - enum: + - current + type: string + name: product_name + in: path + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope_GetProduct_' /v0/invitation:generate: post: tags: @@ -5063,22 +5088,6 @@ components: title: Comment maxLength: 100 type: string - CreditPriceGet: - title: CreditPriceGet - required: - - productName - - usdPerCredit - type: object - properties: - productName: - title: Productname - type: string - usdPerCredit: - title: Usdpercredit - minimum: 0.0 - type: number - description: Price of a credit in USD. If None, then this product's price - is UNDEFINED DatCoreFileLink: title: DatCoreFileLink required: @@ -5220,14 +5229,6 @@ components: $ref: '#/components/schemas/ComputationTaskGet' error: title: Error - Envelope_CreditPriceGet_: - title: Envelope[CreditPriceGet] - type: object - properties: - data: - $ref: '#/components/schemas/CreditPriceGet' - error: - title: Error Envelope_Error_: title: Envelope[Error] type: object @@ -5276,6 +5277,22 @@ components: $ref: '#/components/schemas/FileUploadSchema' error: title: Error + Envelope_GetCreditPrice_: + title: Envelope[GetCreditPrice] + type: object + properties: + data: + $ref: '#/components/schemas/GetCreditPrice' + error: + title: Error + Envelope_GetProduct_: + title: Envelope[GetProduct] + type: object + properties: + data: + $ref: '#/components/schemas/GetProduct' + error: + title: Error Envelope_GetWalletAutoRecharge_: title: Envelope[GetWalletAutoRecharge] type: object @@ -6272,11 +6289,83 @@ components: exclusiveMinimum: true type: integer minimum: 0 - extraCredits: - title: Extracredits + extraCreditsInUsd: + title: Extracreditsinusd exclusiveMinimum: true type: integer minimum: 0 + GetCreditPrice: + title: GetCreditPrice + required: + - productName + - usdPerCredit + type: object + properties: + productName: + title: Productname + type: string + usdPerCredit: + title: Usdpercredit + minimum: 0.0 + type: number + description: Price of a credit in USD. If None, then this product's price + is UNDEFINED + GetProduct: + title: GetProduct + required: + - name + - displayName + - loginSettings + - isPaymentEnabled + type: object + properties: + name: + title: Name + type: string + displayName: + title: Displayname + type: string + shortName: + title: Shortname + type: string + description: Short display name for SMS + vendor: + title: Vendor + type: object + description: vendor attributes + issues: + title: Issues + type: array + items: + type: object + description: Reference to issues tracker + manuals: + title: Manuals + type: array + items: + type: object + description: List of manuals + support: + title: Support + type: array + items: + type: object + description: List of support resources + loginSettings: + title: Loginsettings + type: object + maxOpenStudiesPerUser: + title: Maxopenstudiesperuser + exclusiveMinimum: true + type: integer + minimum: 0 + isPaymentEnabled: + title: Ispaymentenabled + type: boolean + creditsPerUsd: + title: Creditsperusd + minimum: 0.0 + type: number GetWalletAutoRecharge: title: GetWalletAutoRecharge required: @@ -6473,8 +6562,8 @@ components: exclusiveMinimum: true type: integer minimum: 0 - extraCredits: - title: Extracredits + extraCreditsInUsd: + title: Extracreditsinusd exclusiveMinimum: true type: integer minimum: 0 diff --git a/services/web/server/src/simcore_service_webserver/login/_registration.py b/services/web/server/src/simcore_service_webserver/login/_registration.py index e403c1ea365..0f644548b0e 100644 --- a/services/web/server/src/simcore_service_webserver/login/_registration.py +++ b/services/web/server/src/simcore_service_webserver/login/_registration.py @@ -50,7 +50,7 @@ class InvitationData(BaseModel): description="If set, this invitation will activate a trial account." "Sets the number of days from creation until the account expires", ) - extra_credits: PositiveInt | None = None + extra_credits_in_usd: PositiveInt | None = None class _InvitationValidator(BaseModel): @@ -121,7 +121,7 @@ async def create_invitation_token( user_email: LowerCaseEmailStr | None = None, tag: str | None = None, trial_days: PositiveInt | None = None, - extra_credits: PositiveInt | None = None, + extra_credits_in_usd: PositiveInt | None = None, ) -> ConfirmationTokenDict: """Creates an invitation token for a guest to register in the platform and returns @@ -136,7 +136,7 @@ async def create_invitation_token( issuer=user_email, guest=tag, trial_account_days=trial_days, - extra_credits=extra_credits, + extra_credits_in_usd=extra_credits_in_usd, ) return await db.create_confirmation( user_id=user_id, @@ -218,7 +218,7 @@ async def check_and_consume_invitation( issuer=content.issuer, guest=content.guest, trial_account_days=content.trial_account_days, - extra_credits=content.extra_credits, + extra_credits_in_usd=content.extra_credits_in_usd, ) # database-type invitations diff --git a/services/web/server/src/simcore_service_webserver/login/handlers_confirmation.py b/services/web/server/src/simcore_service_webserver/login/handlers_confirmation.py index cf8685134e7..d1f5858e39f 100644 --- a/services/web/server/src/simcore_service_webserver/login/handlers_confirmation.py +++ b/services/web/server/src/simcore_service_webserver/login/handlers_confirmation.py @@ -66,12 +66,12 @@ class _PathParam(BaseModel): code: SecretStr -def _parse_extra_credits_or_none( +def _parse_extra_credits_in_usd_or_none( confirmation: ConfirmationTokenDict, ) -> PositiveInt | None: with suppress(ValidationError, JSONDecodeError): invitation = InvitationData.parse_raw(confirmation.get("data", "EMPTY")) - return invitation.extra_credits + return invitation.extra_credits_in_usd return None @@ -94,7 +94,7 @@ async def _handle_confirm_registration( app, user_id=user_id, product_name=product_name, - extra_credits=_parse_extra_credits_or_none(confirmation), + extra_credits_in_usd=_parse_extra_credits_in_usd_or_none(confirmation), ) diff --git a/services/web/server/src/simcore_service_webserver/login/handlers_registration.py b/services/web/server/src/simcore_service_webserver/login/handlers_registration.py index 9aca34cb7b6..d7e7ff0e6ea 100644 --- a/services/web/server/src/simcore_service_webserver/login/handlers_registration.py +++ b/services/web/server/src/simcore_service_webserver/login/handlers_registration.py @@ -265,7 +265,7 @@ async def register(request: web.Request): request.app, user_id=user["id"], product_name=product.name, - extra_credits=invitation.extra_credits if invitation else None, + extra_credits_in_usd=invitation.extra_credits_in_usd if invitation else None, ) # No confirmation required: authorize login diff --git a/services/web/server/src/simcore_service_webserver/login/utils.py b/services/web/server/src/simcore_service_webserver/login/utils.py index 37791836a9d..a87313a6023 100644 --- a/services/web/server/src/simcore_service_webserver/login/utils.py +++ b/services/web/server/src/simcore_service_webserver/login/utils.py @@ -70,7 +70,7 @@ async def notify_user_confirmation( app: web.Application, user_id: UserID, product_name: ProductName, - extra_credits: PositiveInt | None, + extra_credits_in_usd: PositiveInt | None, ): """Broadcast that user with 'user_id' has login for the first-time in 'product_name'""" # NOTE: Follow up in https://github.com/ITISFoundation/osparc-simcore/issues/4822 @@ -79,7 +79,7 @@ async def notify_user_confirmation( "SIGNAL_ON_USER_CONFIRMATION", user_id=user_id, product_name=product_name, - extra_credits=extra_credits, + extra_credits_in_usd=extra_credits_in_usd, ) diff --git a/services/web/server/src/simcore_service_webserver/products/_api.py b/services/web/server/src/simcore_service_webserver/products/_api.py index 5108bf29566..9368627c17a 100644 --- a/services/web/server/src/simcore_service_webserver/products/_api.py +++ b/services/web/server/src/simcore_service_webserver/products/_api.py @@ -17,10 +17,15 @@ def get_product_name(request: web.Request) -> str: return product_name +def get_product(app: web.Application, product_name: ProductName) -> Product: + product: Product = app[APP_PRODUCTS_KEY][product_name] + return product + + def get_current_product(request: web.Request) -> Product: """Returns product associated to current request""" product_name: ProductName = get_product_name(request) - current_product: Product = request.app[APP_PRODUCTS_KEY][product_name] + current_product: Product = get_product(request.app, product_name=product_name) return current_product diff --git a/services/web/server/src/simcore_service_webserver/products/_db.py b/services/web/server/src/simcore_service_webserver/products/_db.py index 37b534627ed..502d0bbe3cc 100644 --- a/services/web/server/src/simcore_service_webserver/products/_db.py +++ b/services/web/server/src/simcore_service_webserver/products/_db.py @@ -1,15 +1,18 @@ import logging from collections.abc import AsyncIterator +from decimal import Decimal +from typing import NamedTuple import sqlalchemy as sa from aiopg.sa.connection import SAConnection from aiopg.sa.result import ResultProxy, RowProxy from models_library.basic_types import NonNegativeDecimal +from models_library.products import ProductName from pydantic import parse_obj_as from simcore_postgres_database.models.products import jinja2_templates from simcore_postgres_database.utils_products_prices import ( + QUANTIZE_EXP_ARG, get_product_latest_credit_price_or_none, - is_payment_enabled, ) from ..db.base_repository import BaseRepository @@ -42,6 +45,27 @@ ] +class PaymentFieldsTuple(NamedTuple): + enabled: bool + credits_per_usd: Decimal | None + + +async def get_product_payment_fields( + conn: SAConnection, product_name: ProductName +) -> PaymentFieldsTuple: + usd_per_credit = await get_product_latest_credit_price_or_none( + conn, product_name=product_name + ) + if usd_per_credit is None or usd_per_credit == 0: + enabled = False + credits_per_usd = None + else: + enabled = True + credits_per_usd = Decimal(1 / usd_per_credit).quantize(QUANTIZE_EXP_ARG) + + return PaymentFieldsTuple(enabled=enabled, credits_per_usd=credits_per_usd) + + async def iter_products(conn: SAConnection) -> AsyncIterator[ResultProxy]: """Iterates on products sorted by priority i.e. the first is considered the default""" async for row in conn.execute( @@ -63,8 +87,12 @@ async def get_product(self, product_name: str) -> Product | None: # that the product is not billable when there is no product in the products_prices table # or it's price is 0. We should change it and always assume that the product is billable, unless # explicitely stated that it is free - enabled = await is_payment_enabled(conn, product_name=row.name) - return Product(**dict(row.items()), is_payment_enabled=enabled) + payments = await get_product_payment_fields(conn, product_name=row.name) + return Product( + **dict(row.items()), + is_payment_enabled=payments.enabled, + credits_per_usd=payments.credits_per_usd, + ) return None async def get_product_latest_credit_price_or_none( diff --git a/services/web/server/src/simcore_service_webserver/products/_events.py b/services/web/server/src/simcore_service_webserver/products/_events.py index df6c0299eae..f0165a21cd8 100644 --- a/services/web/server/src/simcore_service_webserver/products/_events.py +++ b/services/web/server/src/simcore_service_webserver/products/_events.py @@ -10,11 +10,10 @@ get_default_product_name, get_or_create_product_group, ) -from simcore_postgres_database.utils_products_prices import is_payment_enabled from .._constants import APP_DB_ENGINE_KEY, APP_PRODUCTS_KEY from ..statics._constants import FRONTEND_APP_DEFAULT, FRONTEND_APPS_AVAILABLE -from ._db import iter_products +from ._db import get_product_payment_fields, iter_products from ._model import Product _logger = logging.getLogger(__name__) @@ -77,9 +76,14 @@ async def load_products_on_startup(app: web.Application): async for row in iter_products(connection): try: name = row.name - is_enabled = await is_payment_enabled(connection, product_name=name) + + payments = await get_product_payment_fields( + connection, product_name=row.name + ) app_products[name] = Product( - **dict(row.items()), is_payment_enabled=is_enabled + **dict(row.items()), + is_payment_enabled=payments.enabled, + credits_per_usd=payments.credits_per_usd, ) assert name in FRONTEND_APPS_AVAILABLE # nosec diff --git a/services/web/server/src/simcore_service_webserver/products/_handlers.py b/services/web/server/src/simcore_service_webserver/products/_handlers.py index 8f59c32fad1..a75f185bfd4 100644 --- a/services/web/server/src/simcore_service_webserver/products/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/products/_handlers.py @@ -1,10 +1,16 @@ import logging +from typing import Literal from aiohttp import web -from models_library.api_schemas_webserver.product import CreditPriceGet +from models_library.api_schemas_webserver.product import GetCreditPrice, GetProduct +from models_library.basic_types import IDStr from models_library.users import UserID -from pydantic import Field -from servicelib.aiohttp.requests_validation import RequestParams +from pydantic import Extra, Field +from servicelib.aiohttp.requests_validation import ( + RequestParams, + StrictRequestParams, + parse_request_path_parameters_as, +) from servicelib.request_keys import RQT_USERID_KEY from simcore_service_webserver.utils_aiohttp import envelope_json_response @@ -12,7 +18,8 @@ from .._meta import API_VTAG as VTAG from ..login.decorators import login_required from ..security.decorators import permission_required -from . import _api +from . import _api, api +from ._model import Product routes = web.RouteTableDef() @@ -28,11 +35,37 @@ class _ProductsRequestContext(RequestParams): @routes.get(f"/{VTAG}/credits-price", name="get_current_product_price") @login_required @permission_required("product.price.read") -async def get_current_product_price(request: web.Request): +async def _get_current_product_price(request: web.Request): req_ctx = _ProductsRequestContext.parse_obj(request) - credit_price = CreditPriceGet( + credit_price = GetCreditPrice( product_name=req_ctx.product_name, usd_per_credit=await _api.get_current_product_credit_price(request), ) return envelope_json_response(credit_price) + + +class _ProductsRequestParams(StrictRequestParams): + product_name: IDStr | Literal["current"] + + +@routes.get(f"/{VTAG}/products/{{product_name}}", name="get_product") +@login_required +@permission_required("product.details.read") +async def _get_product(request: web.Request): + req_ctx = _ProductsRequestContext.parse_obj(request) + path_params = parse_request_path_parameters_as(_ProductsRequestParams, request) + + if path_params.product_name == "current": + product_name = req_ctx.product_name + else: + product_name = path_params.product_name + + try: + product: Product = api.get_product(request.app, product_name=product_name) + except KeyError as err: + raise web.HTTPNotFound(reason=f"{product_name=} not found") from err + + assert GetProduct.Config.extra == Extra.ignore # nosec + data = GetProduct(**product.dict()) + return envelope_json_response(data) diff --git a/services/web/server/src/simcore_service_webserver/products/_invitations_handlers.py b/services/web/server/src/simcore_service_webserver/products/_invitations_handlers.py index 6e4aee44b50..2f423e66cf9 100644 --- a/services/web/server/src/simcore_service_webserver/products/_invitations_handlers.py +++ b/services/web/server/src/simcore_service_webserver/products/_invitations_handlers.py @@ -48,7 +48,7 @@ async def generate_invitation(request: web.Request): issuer=user_email, trial_account_days=body.trial_account_days, guest=body.guest, - extra_credits=body.extra_credits, + extra_credits_in_usd=body.extra_credits_in_usd, ), ) assert request.url.host # nosec @@ -61,7 +61,7 @@ async def generate_invitation(request: web.Request): issuer=generated.issuer, guest=generated.guest, trial_account_days=generated.trial_account_days, - extra_credits=generated.extra_credits, + extra_credits_in_usd=generated.extra_credits_in_usd, created=generated.created, invitation_link=f"{invitation_link}", ) diff --git a/services/web/server/src/simcore_service_webserver/products/_model.py b/services/web/server/src/simcore_service_webserver/products/_model.py index 537a0d79e89..c67486d09d9 100644 --- a/services/web/server/src/simcore_service_webserver/products/_model.py +++ b/services/web/server/src/simcore_service_webserver/products/_model.py @@ -6,6 +6,7 @@ PUBLIC_VARIABLE_NAME_RE, TWILIO_ALPHANUMERIC_SENDER_ID_RE, ) +from models_library.basic_types import NonNegativeDecimal from models_library.emails import LowerCaseEmailStr from models_library.products import ProductName from models_library.utils.change_case import snake_to_camel @@ -93,6 +94,11 @@ class Product(BaseModel): description="True if this product offers credits", ) + credits_per_usd: NonNegativeDecimal | None = Field( + default=None, + description="Price of the credits in this product given in credit/USD. None for free product.", + ) + @validator("*", pre=True) @classmethod def parse_empty_string_as_null(cls, v): diff --git a/services/web/server/src/simcore_service_webserver/products/api.py b/services/web/server/src/simcore_service_webserver/products/api.py index de553d16854..58d82f2a872 100644 --- a/services/web/server/src/simcore_service_webserver/products/api.py +++ b/services/web/server/src/simcore_service_webserver/products/api.py @@ -3,6 +3,7 @@ from ._api import ( get_current_product, get_current_product_credit_price, + get_product, get_product_name, get_product_template_path, list_products, @@ -14,6 +15,7 @@ "get_current_product", "get_product_name", "get_product_template_path", + "get_product", "list_products", "Product", "ProductName", diff --git a/services/web/server/src/simcore_service_webserver/resource_usage/api.py b/services/web/server/src/simcore_service_webserver/resource_usage/api.py index 50aa3e61c7a..a65b538172d 100644 --- a/services/web/server/src/simcore_service_webserver/resource_usage/api.py +++ b/services/web/server/src/simcore_service_webserver/resource_usage/api.py @@ -36,6 +36,7 @@ async def add_credits_to_wallet( payment_id: PaymentID, created_at: datetime, ) -> None: + assert osparc_credits != 0 # nosec await _client.add_credits_to_wallet( app=app, product_name=product_name, diff --git a/services/web/server/src/simcore_service_webserver/security/_access_roles.py b/services/web/server/src/simcore_service_webserver/security/_access_roles.py index 6b861fb3a58..3c0bcb6258a 100644 --- a/services/web/server/src/simcore_service_webserver/security/_access_roles.py +++ b/services/web/server/src/simcore_service_webserver/security/_access_roles.py @@ -89,7 +89,7 @@ class PermissionDict(TypedDict, total=False): ), UserRole.PRODUCT_OWNER: PermissionDict( can=[ - "product.details.*", + "product.details.read", "product.invitations", ], inherits=[UserRole.TESTER], 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 5b425ec066a..c36f1e4cb30 100644 --- a/services/web/server/src/simcore_service_webserver/wallets/_events.py +++ b/services/web/server/src/simcore_service_webserver/wallets/_events.py @@ -6,32 +6,40 @@ from pydantic import PositiveInt 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 ..users import preferences_api from ..users.api import get_user_name_and_email from ._api import any_wallet_owned_by_user, create_wallet +_WALLET_NAME_TEMPLATE = "{} Credits" +_WALLET_DESCRIPTION_TEMPLATE = "Credits purchased by {} end up in here" + async def _auto_add_default_wallet( app: web.Application, user_id: UserID, product_name: ProductName, - extra_credits: PositiveInt | None = None, + extra_credits_in_usd: PositiveInt | None = None, ): if not await any_wallet_owned_by_user( app, user_id=user_id, product_name=product_name ): + user = await get_user_name_and_email(app, user_id=user_id) + product = get_product(app, product_name) + + user_name = user.name.capitalize() wallet = await create_wallet( app, user_id=user_id, - wallet_name="Credits", - description="Purchased credits end up in here", + wallet_name=_WALLET_NAME_TEMPLATE.format(user_name), + description=_WALLET_DESCRIPTION_TEMPLATE.format(user_name), thumbnail=None, product_name=product_name, ) - if extra_credits: - user = await get_user_name_and_email(app, user_id=user_id) + if extra_credits_in_usd and product.is_payment_enabled: + assert product.credits_per_usd # nosec await add_credits_to_wallet( app, product_name=product_name, @@ -39,7 +47,7 @@ async def _auto_add_default_wallet( wallet_name=wallet.name, user_id=user_id, user_email=user.email, - osparc_credits=extra_credits, # type: ignore + osparc_credits=extra_credits_in_usd * product.credits_per_usd, payment_id="INVITATION", # TODO: invitation id??? created_at=wallet.created, ) @@ -60,10 +68,13 @@ async def _on_user_confirmation( app: web.Application, user_id: UserID, product_name: ProductName, - extra_credits: PositiveInt, + extra_credits_in_usd: PositiveInt, ): await _auto_add_default_wallet( - app, user_id=user_id, product_name=product_name, extra_credits=extra_credits + app, + user_id=user_id, + product_name=product_name, + extra_credits_in_usd=extra_credits_in_usd, ) diff --git a/services/web/server/tests/unit/with_dbs/03/conftest.py b/services/web/server/tests/unit/with_dbs/03/conftest.py index 5e72fd3440f..e1ec4e429ed 100644 --- a/services/web/server/tests/unit/with_dbs/03/conftest.py +++ b/services/web/server/tests/unit/with_dbs/03/conftest.py @@ -12,6 +12,7 @@ import sqlalchemy as sa from aiopg.sa import create_engine from aiopg.sa.connection import SAConnection +from faker import Faker from models_library.products import ProductName from pytest_simcore.helpers.rawdata_fakers import random_product from simcore_postgres_database.models.products import products @@ -90,42 +91,56 @@ async def all_products_names( @pytest.fixture async def all_product_prices( - all_products_names: list[ProductName], _pre_connection: SAConnection, -) -> dict[ProductName, Decimal]: - credits_price = { - "osparc": Decimal(0), + all_products_names: list[ProductName], + faker: Faker, +) -> dict[ProductName, Decimal | None]: + """Initial list of prices for all products""" + + # initial list of prices + product_price = { + "osparc": Decimal(0), # free of charge "tis": Decimal(5), "s4l": Decimal(9), - "s4llite": Decimal(0), + "s4llite": Decimal(0), # free of charge "s4lacad": Decimal(1.1), } - # initial prices - for name in all_products_names: - await _pre_connection.execute( - products_prices.insert().values( - product_name=name, - usd_per_credit=credits_price[name], - comment="MrK", + result = {} + for product_name in all_products_names: + usd_or_none = product_price.get(product_name, None) + if usd_or_none is not None: + await _pre_connection.execute( + products_prices.insert().values( + product_name=product_name, + usd_per_credit=usd_or_none, + comment=faker.sentence(), + ) ) - ) - return credits_price + + result[product_name] = usd_or_none + + return result @pytest.fixture -async def new_osparc_price( +async def latest_osparc_price( all_product_prices: dict[ProductName, Decimal], _pre_connection: SAConnection, ) -> Decimal: + """This inserts a new price for osparc in the history + (i.e. the old price of osparc is still in the database) + """ + usd = await _pre_connection.scalar( products_prices.insert() .values( product_name="osparc", - usd_per_credit=Decimal(1.0), - comment="MrK", + usd_per_credit=all_product_prices["osparc"] + 5, + comment="New price for osparc", ) .returning(products_prices.c.usd_per_credit) ) assert usd is not None + assert usd != all_product_prices["osparc"] return Decimal(usd) diff --git a/services/web/server/tests/unit/with_dbs/03/invitations/test_invitations.py b/services/web/server/tests/unit/with_dbs/03/invitations/test_invitations.py index 25ba244b0a8..72330812e85 100644 --- a/services/web/server/tests/unit/with_dbs/03/invitations/test_invitations.py +++ b/services/web/server/tests/unit/with_dbs/03/invitations/test_invitations.py @@ -4,19 +4,12 @@ # pylint: disable=too-many-arguments -from datetime import datetime, timezone - import pytest -from aiohttp import web from aiohttp.test_utils import TestClient -from faker import Faker from models_library.api_schemas_invitations.invitations import ApiInvitationContent -from models_library.api_schemas_webserver.product import InvitationGenerated from pytest_simcore.aioresponses_mocker import AioResponsesMock -from pytest_simcore.helpers.utils_assert import assert_status from pytest_simcore.helpers.utils_envs import EnvVarsDict, setenvs_from_dict -from pytest_simcore.helpers.utils_login import NewUser, UserInfoDict -from simcore_postgres_database.models.users import UserRole +from pytest_simcore.helpers.utils_login import NewUser from simcore_service_webserver.application_settings import ApplicationSettings from simcore_service_webserver.invitations._client import ( InvitationsServiceApi, @@ -156,56 +149,3 @@ async def test_invalid_invitation_if_not_guest( guest_email="unexpected_guest@email.me", invitation_url="https://server.com#register?invitation=1234", ) - - -@pytest.mark.parametrize( - "user_role,expected_status", - [ - (UserRole.ANONYMOUS, web.HTTPUnauthorized), - (UserRole.GUEST, web.HTTPForbidden), - (UserRole.USER, web.HTTPForbidden), - (UserRole.TESTER, web.HTTPForbidden), - (UserRole.PRODUCT_OWNER, web.HTTPOk), - (UserRole.ADMIN, web.HTTPForbidden), - ], -) -async def test_product_owner_generate_invitation( - client: TestClient, - mock_invitations_service_http_api: AioResponsesMock, - faker: Faker, - logged_user: UserInfoDict, - expected_status: type[web.HTTPException], -): - before_dt = datetime.now(tz=timezone.utc) - guest_email = faker.email() - trial_account_days = 3 - extra_credits = 10 - - # request - response = await client.post( - "/v0/invitation:generate", - json={ - "guest": guest_email, - "trialAccountDays": trial_account_days, - "extraCredits": extra_credits, - }, - ) - - # checks - data, error = await assert_status(response, expected_status) - if data: - got = InvitationGenerated.parse_obj(data) - expected = { - "issuer": logged_user["email"], - "guest": guest_email, - "trial_account_days": trial_account_days, - "extra_credits": extra_credits, - } - assert got.dict(include=set(expected), by_alias=False) == expected - - product_base_url = f"{client.make_url('/')}" - assert got.invitation_link.startswith(product_base_url) - assert before_dt < got.created - assert got.created < datetime.now(tz=timezone.utc) - else: - assert error diff --git a/services/web/server/tests/unit/with_dbs/03/invitations/test_products_invitations.py b/services/web/server/tests/unit/with_dbs/03/invitations/test_products_invitations.py new file mode 100644 index 00000000000..fd1f1ae69dc --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/03/invitations/test_products_invitations.py @@ -0,0 +1,150 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments + + +from datetime import datetime, timezone + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient +from faker import Faker +from models_library.api_schemas_webserver.product import ( + GenerateInvitation, + InvitationGenerated, +) +from pydantic import PositiveInt +from pytest_simcore.aioresponses_mocker import AioResponsesMock +from pytest_simcore.helpers.utils_assert import assert_status +from pytest_simcore.helpers.utils_envs import EnvVarsDict, setenvs_from_dict +from pytest_simcore.helpers.utils_login import UserInfoDict +from simcore_postgres_database.models.users import UserRole + + +@pytest.fixture +def app_environment( + app_environment: EnvVarsDict, + env_devel_dict: EnvVarsDict, + monkeypatch: pytest.MonkeyPatch, +): + envs_plugins = setenvs_from_dict( + monkeypatch, + { + "WEBSERVER_ACTIVITY": "null", + "WEBSERVER_DB_LISTENER": "0", + "WEBSERVER_DIAGNOSTICS": "null", + "WEBSERVER_EXPORTER": "null", + "WEBSERVER_GARBAGE_COLLECTOR": "null", + "WEBSERVER_META_MODELING": "0", + "WEBSERVER_NOTIFICATIONS": "0", + "WEBSERVER_PUBLICATIONS": "0", + "WEBSERVER_REMOTE_DEBUG": "0", + "WEBSERVER_SOCKETIO": "0", + "WEBSERVER_STUDIES_ACCESS_ENABLED": "0", + "WEBSERVER_TAGS": "0", + "WEBSERVER_TRACING": "null", + "WEBSERVER_VERSION_CONTROL": "0", + "WEBSERVER_WALLETS": "0", + }, + ) + + # undefine WEBSERVER_INVITATIONS + app_environment.pop("WEBSERVER_INVITATIONS", None) + monkeypatch.delenv("WEBSERVER_INVITATIONS", raising=False) + + # set INVITATIONS_* variables using those in .devel-env + envs_invitations = setenvs_from_dict( + monkeypatch, + envs={ + name: value + for name, value in env_devel_dict.items() + if name.startswith("INVITATIONS_") + }, + ) + + return app_environment | envs_plugins | envs_invitations + + +@pytest.fixture +def guest_email(faker: Faker) -> str: + return faker.email() + + +@pytest.mark.parametrize( + "user_role,expected_status", + [ + (UserRole.ANONYMOUS, web.HTTPUnauthorized), + (UserRole.GUEST, web.HTTPForbidden), + (UserRole.USER, web.HTTPForbidden), + (UserRole.TESTER, web.HTTPForbidden), + (UserRole.PRODUCT_OWNER, web.HTTPOk), + (UserRole.ADMIN, web.HTTPForbidden), + ], +) +async def test_role_access_to_generate_invitation( + client: TestClient, + mock_invitations_service_http_api: AioResponsesMock, + logged_user: UserInfoDict, + expected_status: type[web.HTTPException], + guest_email: str, +): + response = await client.post( + "/v0/invitation:generate", + json={ + "guest": guest_email, + }, + ) + data, error = await assert_status(response, expected_status) + if data: + got = InvitationGenerated.parse_obj(data) + assert got.guest == guest_email + else: + assert error + + +@pytest.mark.parametrize( + "user_role,expected_status", + [ + (UserRole.PRODUCT_OWNER, web.HTTPOk), + ], +) +@pytest.mark.parametrize( + "trial_account_days,extra_credits_in_usd", + [(3, 10), (None, 10), (None, 0), (3, None), (None, None)], +) +async def test_product_owner_generates_invitation( + client: TestClient, + mock_invitations_service_http_api: AioResponsesMock, + logged_user: UserInfoDict, + guest_email: str, + expected_status: type[web.HTTPException], + trial_account_days: PositiveInt | None, + extra_credits_in_usd: PositiveInt | None, +): + before_dt = datetime.now(tz=timezone.utc) + + request_model = GenerateInvitation( + guest=guest_email, + trial_account_days=trial_account_days, + extra_credits_in_usd=extra_credits_in_usd, + ) + + # request + response = await client.post( + "/v0/invitation:generate", + json=request_model.dict(exclude_none=True), + ) + + # checks + data, error = await assert_status(response, expected_status) + assert not error + + got = InvitationGenerated.parse_obj(data) + expected = {"issuer": logged_user["email"], **request_model.dict(exclude_none=True)} + assert got.dict(include=set(expected), by_alias=False) == expected + + product_base_url = f"{client.make_url('/')}" + assert got.invitation_link.startswith(product_base_url) + assert before_dt < got.created + assert got.created < datetime.now(tz=timezone.utc) diff --git a/services/web/server/tests/unit/with_dbs/03/products/test_product_prices.py b/services/web/server/tests/unit/with_dbs/03/products/test_product_prices.py deleted file mode 100644 index 03dbd22c48b..00000000000 --- a/services/web/server/tests/unit/with_dbs/03/products/test_product_prices.py +++ /dev/null @@ -1,35 +0,0 @@ -# pylint: disable=redefined-outer-name -# pylint: disable=unused-argument -# pylint: disable=unused-variable -# pylint: disable=too-many-arguments - - -from decimal import Decimal - -import pytest -from aiohttp import web -from aiohttp.test_utils import TestClient -from pytest_simcore.helpers.utils_assert import assert_status -from pytest_simcore.helpers.utils_login import UserInfoDict -from simcore_service_webserver.db.models import UserRole - - -@pytest.mark.parametrize( - "user_role,expected", - [ - (UserRole.ANONYMOUS, web.HTTPUnauthorized), - (UserRole.GUEST, web.HTTPForbidden), - *((role, web.HTTPOk) for role in UserRole if role >= UserRole.USER), - ], -) -async def test_get_product_price_when_undefined( - client: TestClient, - logged_user: UserInfoDict, - expected: type[web.HTTPException], - new_osparc_price: Decimal, -): - response = await client.get("/v0/credits-price") - data, error = await assert_status(response, expected) - - if not error: - assert data["usdPerCredit"] == new_osparc_price diff --git a/services/web/server/tests/unit/with_dbs/03/products/test_products_handlers.py b/services/web/server/tests/unit/with_dbs/03/products/test_products_handlers.py new file mode 100644 index 00000000000..04234689105 --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/03/products/test_products_handlers.py @@ -0,0 +1,108 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments + + +import operator +from decimal import Decimal + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient +from models_library.api_schemas_webserver.product import GetProduct +from models_library.products import ProductName +from pytest_simcore.helpers.utils_assert import assert_status +from pytest_simcore.helpers.utils_login import UserInfoDict +from simcore_postgres_database.utils_products_prices import QUANTIZE_EXP_ARG +from simcore_service_webserver._constants import X_PRODUCT_NAME_HEADER +from simcore_service_webserver.db.models import UserRole + + +@pytest.mark.parametrize( + "user_role,expected", + [ + (UserRole.ANONYMOUS, web.HTTPUnauthorized), + (UserRole.GUEST, web.HTTPForbidden), + *((role, web.HTTPOk) for role in UserRole if role >= UserRole.USER), + ], +) +async def test_get_product_price_when_undefined( + client: TestClient, + logged_user: UserInfoDict, + expected: type[web.HTTPException], + latest_osparc_price: Decimal, +): + response = await client.get("/v0/credits-price") + data, error = await assert_status(response, expected) + + if not error: + assert data["usdPerCredit"] == latest_osparc_price + + +@pytest.mark.parametrize( + "user_role,expected", + [ + (UserRole.ANONYMOUS, web.HTTPUnauthorized), + (UserRole.GUEST, web.HTTPForbidden), + (UserRole.USER, web.HTTPForbidden), + (UserRole.TESTER, web.HTTPForbidden), + (UserRole.PRODUCT_OWNER, web.HTTPOk), + (UserRole.ADMIN, web.HTTPForbidden), + ], +) +async def test_get_product_access_rights( + client: TestClient, + logged_user: UserInfoDict, + expected: type[web.HTTPException], + latest_osparc_price: Decimal, +): + response = await client.get("/v0/products/current") + data, error = await assert_status(response, expected) + assert operator.xor(data is None, error is None) + + +@pytest.fixture(params=["osparc", "tis", "s4l"]) +def product_name(request: pytest.FixtureRequest) -> ProductName: + return request.param + + +@pytest.fixture +def expected_credits_per_usd( + all_product_prices: dict[ProductName, Decimal], + product_name: ProductName, +) -> Decimal | None: + if usd_per_credit := all_product_prices[product_name]: + return Decimal(1 / usd_per_credit).quantize(QUANTIZE_EXP_ARG) + return None + + +@pytest.mark.testit +@pytest.mark.parametrize( + "user_role", + [(UserRole.PRODUCT_OWNER)], +) +async def test_get_product( + product_name: ProductName, + expected_credits_per_usd: Decimal | None, + logged_user: UserInfoDict, + client: TestClient, +): + current_project_headers = {X_PRODUCT_NAME_HEADER: product_name} + response = await client.get("/v0/products/current", headers=current_project_headers) + data, error = await assert_status(response, web.HTTPOk) + + got_product = GetProduct(**data) + assert got_product.name == product_name + assert got_product.credits_per_usd == expected_credits_per_usd + assert not error + + response = await client.get(f"/v0/products/{product_name}") + data, error = await assert_status(response, web.HTTPOk) + assert got_product == GetProduct(**data) + assert not error + + response = await client.get("/v0/product/invalid") + data, error = await assert_status(response, web.HTTPNotFound) + assert not data + assert error diff --git a/services/web/server/tests/unit/with_dbs/03/wallets/payments/test_payments.py b/services/web/server/tests/unit/with_dbs/03/wallets/payments/test_payments.py index 8dbc2c1d126..fed3ad98126 100644 --- a/services/web/server/tests/unit/with_dbs/03/wallets/payments/test_payments.py +++ b/services/web/server/tests/unit/with_dbs/03/wallets/payments/test_payments.py @@ -35,7 +35,7 @@ async def test_payment_on_invalid_wallet( - new_osparc_price: Decimal, + latest_osparc_price: Decimal, client: TestClient, logged_user_wallet: WalletGet, ): @@ -59,7 +59,7 @@ async def test_payment_on_invalid_wallet( "For https://github.com/ITISFoundation/osparc-simcore/issues/4657" ) async def test_payments_worfklow( - new_osparc_price: Decimal, + latest_osparc_price: Decimal, client: TestClient, logged_user_wallet: WalletGet, mocker: MockerFixture, @@ -128,7 +128,7 @@ async def test_payments_worfklow( async def test_multiple_payments( - new_osparc_price: Decimal, + latest_osparc_price: Decimal, client: TestClient, logged_user_wallet: WalletGet, mocker: MockerFixture, @@ -210,7 +210,7 @@ async def test_multiple_payments( async def test_complete_payment_errors( - new_osparc_price: Decimal, + latest_osparc_price: Decimal, client: TestClient, logged_user_wallet: WalletGet, mocker: MockerFixture, @@ -258,7 +258,7 @@ async def test_complete_payment_errors( async def test_payment_not_found( - new_osparc_price: Decimal, + latest_osparc_price: Decimal, client: TestClient, logged_user_wallet: WalletGet, faker: Faker, @@ -287,7 +287,7 @@ def test_models_state_in_sync(): async def test_payment_on_wallet_without_access( - new_osparc_price: Decimal, + latest_osparc_price: Decimal, logged_user_wallet: WalletGet, client: TestClient, ): diff --git a/services/web/server/tests/unit/with_dbs/03/wallets/test_wallets.py b/services/web/server/tests/unit/with_dbs/03/wallets/test_wallets.py index 52d48d87127..6a834fb696d 100644 --- a/services/web/server/tests/unit/with_dbs/03/wallets/test_wallets.py +++ b/services/web/server/tests/unit/with_dbs/03/wallets/test_wallets.py @@ -16,13 +16,19 @@ from models_library.api_schemas_resource_usage_tracker.credit_transactions import ( WalletTotalCredits, ) +from models_library.api_schemas_webserver.wallets import WalletGet from models_library.products import ProductName from pytest_mock import MockerFixture from pytest_simcore.helpers.utils_assert import assert_status from pytest_simcore.helpers.utils_login import LoggedUser, UserInfoDict from simcore_service_webserver.db.models import UserRole from simcore_service_webserver.login.utils import notify_user_confirmation +from simcore_service_webserver.products.api import get_product from simcore_service_webserver.projects.models import ProjectDict +from simcore_service_webserver.wallets._events import ( + _WALLET_DESCRIPTION_TEMPLATE, + _WALLET_NAME_TEMPLATE, +) @pytest.fixture @@ -162,7 +168,7 @@ async def test_wallets_full_workflow( @pytest.mark.parametrize("user_role,expected", [(UserRole.USER, web.HTTPOk)]) -async def test_auto_wallet_on_user_registration_confirmation( +async def test_wallets_events_auto_add_default_wallet_on_user_confirmation( client: TestClient, logged_user: UserInfoDict, expected: type[web.HTTPException], @@ -173,9 +179,12 @@ async def test_auto_wallet_on_user_registration_confirmation( ): assert client.app - mocker.patch( + product = get_product(client.app, osparc_product_name) + assert product.name == osparc_product_name + + mock_add_credits_to_wallet = mocker.patch( "simcore_service_webserver.wallets._events.add_credits_to_wallet", - autospec=True, + spec=True, return_value=None, ) @@ -187,12 +196,16 @@ async def test_auto_wallet_on_user_registration_confirmation( await notify_user_confirmation( client.app, user_id=logged_user["id"], - product_name=osparc_product_name, - extra_credits=10, + product_name=product.name, + extra_credits_in_usd=10, ) resp = await client.get(f"{url}") data, _ = await assert_status(resp, web.HTTPOk) assert len(data) == 1 - + wallet = WalletGet(**data[0]) + user_name = logged_user["name"].capitalize() + assert wallet.name == _WALLET_NAME_TEMPLATE.format(user_name) + assert wallet.description == _WALLET_DESCRIPTION_TEMPLATE.format(user_name) assert mock_rut_sum_total_available_credits_in_the_wallet.called + assert mock_add_credits_to_wallet.called == product.is_payment_enabled