diff --git a/api/specs/web-server/_products.py b/api/specs/web-server/_products.py index 052fabcf324..260ee88c5af 100644 --- a/api/specs/web-server/_products.py +++ b/api/specs/web-server/_products.py @@ -7,21 +7,17 @@ from typing import Annotated -from fastapi import APIRouter, Depends, status +from fastapi import APIRouter, Depends from models_library.api_schemas_webserver.product import ( - GenerateInvitation, - GetCreditPrice, + CreditPriceGet, + InvitationGenerate, InvitationGenerated, ProductGet, ProductUIGet, - UpdateProductTemplate, ) from models_library.generics import Envelope from simcore_service_webserver._meta import API_VTAG -from simcore_service_webserver.products._handlers import ( - _ProductsRequestParams, - _ProductTemplateParams, -) +from simcore_service_webserver.products._rest_schemas import ProductsRequestParams router = APIRouter( prefix=f"/{API_VTAG}", @@ -33,7 +29,7 @@ @router.get( "/credits-price", - response_model=Envelope[GetCreditPrice], + response_model=Envelope[CreditPriceGet], ) async def get_current_product_price(): ... @@ -47,7 +43,7 @@ async def get_current_product_price(): "po", ], ) -async def get_product(_params: Annotated[_ProductsRequestParams, Depends()]): +async def get_product(_params: Annotated[ProductsRequestParams, Depends()]): ... @@ -59,19 +55,6 @@ async def get_current_product_ui(): ... -@router.put( - "/products/{product_name}/templates/{template_id}", - status_code=status.HTTP_204_NO_CONTENT, - tags=[ - "po", - ], -) -async def update_product_template( - _params: Annotated[_ProductTemplateParams, Depends()], _body: UpdateProductTemplate -): - ... - - @router.post( "/invitation:generate", response_model=Envelope[InvitationGenerated], @@ -79,5 +62,5 @@ async def update_product_template( "po", ], ) -async def generate_invitation(_body: GenerateInvitation): +async def generate_invitation(_body: InvitationGenerate): ... diff --git a/api/specs/web-server/_statics.py b/api/specs/web-server/_statics.py index cf3b846f7d7..da1a1667e02 100644 --- a/api/specs/web-server/_statics.py +++ b/api/specs/web-server/_statics.py @@ -8,7 +8,7 @@ from fastapi import APIRouter from fastapi.responses import HTMLResponse -from simcore_service_webserver._constants import INDEX_RESOURCE_NAME +from simcore_service_webserver.constants import INDEX_RESOURCE_NAME from simcore_service_webserver.statics.settings import FrontEndInfoDict router = APIRouter( 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 4c50e2bf2b4..475361d8ca4 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 @@ -19,16 +19,20 @@ from ._base import InputSchema, OutputSchema -class GetCreditPrice(OutputSchema): +class CreditPriceGet(OutputSchema): product_name: str usd_per_credit: Annotated[ - NonNegativeDecimal, - PlainSerializer(float, return_type=NonNegativeFloat, when_used="json"), - ] | None = Field( - ..., - description="Price of a credit in USD. " - "If None, then this product's price is UNDEFINED", - ) + Annotated[ + NonNegativeDecimal, + PlainSerializer(float, return_type=NonNegativeFloat, when_used="json"), + ] + | None, + Field( + description="Price of a credit in USD. " + "If None, then this product's price is UNDEFINED", + ), + ] + min_payment_amount_usd: Annotated[ NonNegativeInt | None, Field( @@ -61,15 +65,11 @@ def _update_json_schema_extra(schema: JsonDict) -> None: ) -class GetProductTemplate(OutputSchema): +class ProductTemplateGet(OutputSchema): id_: Annotated[IDStr, Field(alias="id")] content: str -class UpdateProductTemplate(InputSchema): - content: str - - class ProductGet(OutputSchema): name: ProductName display_name: str @@ -92,7 +92,7 @@ class ProductGet(OutputSchema): credits_per_usd: NonNegativeDecimal | None templates: Annotated[ - list[GetProductTemplate], + list[ProductTemplateGet], Field( description="List of templates available to this product for communications (e.g. emails, sms, etc)", default_factory=list, @@ -111,7 +111,7 @@ class ProductUIGet(OutputSchema): ExtraCreditsUsdRangeInt: TypeAlias = Annotated[int, Field(ge=0, lt=500)] -class GenerateInvitation(InputSchema): +class InvitationGenerate(InputSchema): guest: LowerCaseEmailStr trial_account_days: PositiveInt | None = None extra_credits_in_usd: ExtraCreditsUsdRangeInt | None = None 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 7d39de55d00..b573b78e415 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 @@ -59,7 +59,7 @@ async def get_product_latest_stripe_info( ) ).fetchone() if row is None: - msg = "No product Stripe info defined in database" + msg = f"Required Stripe information missing from product {product_name=}" raise ValueError(msg) return (row.stripe_price_id, row.stripe_tax_rate_id) diff --git a/packages/pytest-simcore/src/pytest_simcore/helpers/webserver_login.py b/packages/pytest-simcore/src/pytest_simcore/helpers/webserver_login.py index 062a33d693a..ccb0f9587fb 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/webserver_login.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/webserver_login.py @@ -11,7 +11,7 @@ from simcore_service_webserver.login._constants import MSG_LOGGED_IN from simcore_service_webserver.login._registration import create_invitation_token from simcore_service_webserver.login.storage import AsyncpgStorage, get_plugin_storage -from simcore_service_webserver.products.api import list_products +from simcore_service_webserver.products.products_service import list_products from simcore_service_webserver.security.api import clean_auth_policy_cache from yarl import URL diff --git a/services/api-server/src/simcore_service_api_server/models/schemas/model_adapter.py b/services/api-server/src/simcore_service_api_server/models/schemas/model_adapter.py index ba74311b9e5..6c41399f1d4 100644 --- a/services/api-server/src/simcore_service_api_server/models/schemas/model_adapter.py +++ b/services/api-server/src/simcore_service_api_server/models/schemas/model_adapter.py @@ -14,7 +14,7 @@ LicensedItemCheckoutRpcGet as _LicensedItemCheckoutRpcGet, ) from models_library.api_schemas_webserver.product import ( - GetCreditPrice as _GetCreditPrice, + CreditPriceGet as _GetCreditPrice, ) from models_library.api_schemas_webserver.resource_usage import ( PricingUnitGet as _PricingUnitGet, diff --git a/services/api-server/tests/unit/test_credits.py b/services/api-server/tests/unit/test_credits.py index 3630e218754..4f7dd8b41e5 100644 --- a/services/api-server/tests/unit/test_credits.py +++ b/services/api-server/tests/unit/test_credits.py @@ -2,7 +2,7 @@ from fastapi import status from httpx import AsyncClient, BasicAuth -from models_library.api_schemas_webserver.product import GetCreditPrice +from models_library.api_schemas_webserver.product import CreditPriceGet from pytest_simcore.helpers.httpx_calls_capture_models import CreateRespxMockCallback from simcore_service_api_server._meta import API_VTAG @@ -23,4 +23,4 @@ async def test_get_credits_price( response = await client.get(f"{API_VTAG}/credits/price", auth=auth) assert response.status_code == status.HTTP_200_OK - _ = GetCreditPrice.model_validate(response.json()) + _ = CreditPriceGet.model_validate(response.json()) diff --git a/services/web/server/src/simcore_service_webserver/activity/settings.py b/services/web/server/src/simcore_service_webserver/activity/settings.py index bfa727ccb35..f84eede661a 100644 --- a/services/web/server/src/simcore_service_webserver/activity/settings.py +++ b/services/web/server/src/simcore_service_webserver/activity/settings.py @@ -1,7 +1,7 @@ from aiohttp import web from settings_library.prometheus import PrometheusSettings -from .._constants import APP_SETTINGS_KEY +from ..constants import APP_SETTINGS_KEY def get_plugin_settings(app: web.Application) -> PrometheusSettings: diff --git a/services/web/server/src/simcore_service_webserver/announcements/_handlers.py b/services/web/server/src/simcore_service_webserver/announcements/_handlers.py index 596d31a43c2..ca925a39e14 100644 --- a/services/web/server/src/simcore_service_webserver/announcements/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/announcements/_handlers.py @@ -3,7 +3,7 @@ from aiohttp import web from .._meta import api_version_prefix -from ..products.api import get_product_name +from ..products import products_web from ..utils_aiohttp import envelope_json_response from . import _api from ._models import Announcement @@ -14,7 +14,7 @@ @routes.get(f"/{api_version_prefix}/announcements", name="list_announcements") async def list_announcements(request: web.Request) -> web.Response: """Returns non-expired announcements for current product""" - product_name = get_product_name(request) + product_name = products_web.get_product_name(request) announcements: list[Announcement] = await _api.list_announcements( request.app, product_name=product_name ) diff --git a/services/web/server/src/simcore_service_webserver/announcements/plugin.py b/services/web/server/src/simcore_service_webserver/announcements/plugin.py index fd3b3f79b43..88a39940cbf 100644 --- a/services/web/server/src/simcore_service_webserver/announcements/plugin.py +++ b/services/web/server/src/simcore_service_webserver/announcements/plugin.py @@ -7,7 +7,7 @@ from aiohttp import web from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup -from .._constants import APP_SETTINGS_KEY +from ..constants import APP_SETTINGS_KEY from ..products.plugin import setup_products from ..redis import setup_redis from . import _handlers 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 086af834d11..63c43cff9d3 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 @@ -1107,7 +1107,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Envelope_GetCreditPrice_' + $ref: '#/components/schemas/Envelope_CreditPriceGet_' /v0/products/{product_name}: get: tags: @@ -1149,42 +1149,6 @@ paths: application/json: schema: $ref: '#/components/schemas/Envelope_ProductUIGet_' - /v0/products/{product_name}/templates/{template_id}: - put: - tags: - - products - - po - summary: Update Product Template - operationId: update_product_template - parameters: - - name: product_name - in: path - required: true - schema: - anyOf: - - type: string - minLength: 1 - maxLength: 100 - - const: current - type: string - title: Product Name - - name: template_id - in: path - required: true - schema: - type: string - minLength: 1 - maxLength: 100 - title: Template Id - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateProductTemplate' - responses: - '204': - description: Successful Response /v0/invitation:generate: post: tags: @@ -1196,7 +1160,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/GenerateInvitation' + $ref: '#/components/schemas/InvitationGenerate' required: true responses: '200': @@ -8413,6 +8377,33 @@ components: required: - priceDollars title: CreateWalletPayment + CreditPriceGet: + properties: + productName: + type: string + title: Productname + usdPerCredit: + anyOf: + - type: number + minimum: 0.0 + - type: 'null' + title: Usdpercredit + description: Price of a credit in USD. If None, then this product's price + is UNDEFINED + minPaymentAmountUsd: + anyOf: + - type: integer + minimum: 0 + - type: 'null' + title: Minpaymentamountusd + description: Minimum amount (included) in USD that can be paid for this + productCan be None if this product's price is UNDEFINED + type: object + required: + - productName + - usdPerCredit + - minPaymentAmountUsd + title: CreditPriceGet CursorPage_PathMetaDataGet_: properties: items: @@ -8658,6 +8649,19 @@ components: title: Error type: object title: Envelope[ComputationGet] + Envelope_CreditPriceGet_: + properties: + data: + anyOf: + - $ref: '#/components/schemas/CreditPriceGet' + - type: 'null' + error: + anyOf: + - {} + - type: 'null' + title: Error + type: object + title: Envelope[CreditPriceGet] Envelope_FileMetaDataGet_: properties: data: @@ -8736,19 +8740,6 @@ components: title: Error type: object title: Envelope[FolderGet] - Envelope_GetCreditPrice_: - properties: - data: - anyOf: - - $ref: '#/components/schemas/GetCreditPrice' - - type: 'null' - error: - anyOf: - - {} - - type: 'null' - title: Error - type: object - title: Envelope[GetCreditPrice] Envelope_GetProjectInactivityResponse_: properties: data: @@ -10557,73 +10548,6 @@ components: required: - name title: FolderReplaceBodyParams - GenerateInvitation: - properties: - guest: - type: string - format: email - title: Guest - trialAccountDays: - anyOf: - - type: integer - exclusiveMinimum: true - minimum: 0 - - type: 'null' - title: Trialaccountdays - extraCreditsInUsd: - anyOf: - - type: integer - exclusiveMaximum: true - minimum: 0 - maximum: 500 - - type: 'null' - title: Extracreditsinusd - type: object - required: - - guest - title: GenerateInvitation - GetCreditPrice: - properties: - productName: - type: string - title: Productname - usdPerCredit: - anyOf: - - type: number - minimum: 0.0 - - type: 'null' - title: Usdpercredit - description: Price of a credit in USD. If None, then this product's price - is UNDEFINED - minPaymentAmountUsd: - anyOf: - - type: integer - minimum: 0 - - type: 'null' - title: Minpaymentamountusd - description: Minimum amount (included) in USD that can be paid for this - productCan be None if this product's price is UNDEFINED - type: object - required: - - productName - - usdPerCredit - - minPaymentAmountUsd - title: GetCreditPrice - GetProductTemplate: - properties: - id: - type: string - maxLength: 100 - minLength: 1 - title: Id - content: - type: string - title: Content - type: object - required: - - id - - content - title: GetProductTemplate GetProjectInactivityResponse: properties: is_inactive: @@ -10966,6 +10890,31 @@ components: required: - invitation title: InvitationCheck + InvitationGenerate: + properties: + guest: + type: string + format: email + title: Guest + trialAccountDays: + anyOf: + - type: integer + exclusiveMinimum: true + minimum: 0 + - type: 'null' + title: Trialaccountdays + extraCreditsInUsd: + anyOf: + - type: integer + exclusiveMaximum: true + minimum: 0 + maximum: 500 + - type: 'null' + title: Extracreditsinusd + type: object + required: + - guest + title: InvitationGenerate InvitationGenerated: properties: productName: @@ -13014,7 +12963,7 @@ components: title: Creditsperusd templates: items: - $ref: '#/components/schemas/GetProductTemplate' + $ref: '#/components/schemas/ProductTemplateGet' type: array title: Templates description: List of templates available to this product for communications @@ -13028,6 +12977,21 @@ components: - isPaymentEnabled - creditsPerUsd title: ProductGet + ProductTemplateGet: + properties: + id: + type: string + maxLength: 100 + minLength: 1 + title: Id + content: + type: string + title: Content + type: object + required: + - id + - content + title: ProductTemplateGet ProductUIGet: properties: productName: @@ -15269,15 +15233,6 @@ components: - default - specificInfo title: UpdatePricingUnitBodyParams - UpdateProductTemplate: - properties: - content: - type: string - title: Content - type: object - required: - - content - title: UpdateProductTemplate UploadedPart: properties: number: diff --git a/services/web/server/src/simcore_service_webserver/api_keys/_repository.py b/services/web/server/src/simcore_service_webserver/api_keys/_repository.py index 1f4a8dbdc79..e53dab0a5d2 100644 --- a/services/web/server/src/simcore_service_webserver/api_keys/_repository.py +++ b/services/web/server/src/simcore_service_webserver/api_keys/_repository.py @@ -8,11 +8,11 @@ from models_library.users import UserID from simcore_postgres_database.models.api_keys import api_keys from simcore_postgres_database.utils_repos import transaction_context -from simcore_service_webserver.api_keys._models import ApiKey from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncConnection from ..db.plugin import get_asyncpg_engine +from ._models import ApiKey from .errors import ApiKeyDuplicatedDisplayNameError _logger = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/api_keys/plugin.py b/services/web/server/src/simcore_service_webserver/api_keys/plugin.py index 9c8cc742c23..636615f3062 100644 --- a/services/web/server/src/simcore_service_webserver/api_keys/plugin.py +++ b/services/web/server/src/simcore_service_webserver/api_keys/plugin.py @@ -3,7 +3,7 @@ from aiohttp import web from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup -from .._constants import APP_SETTINGS_KEY +from ..constants import APP_SETTINGS_KEY from ..db.plugin import setup_db from ..products.plugin import setup_products from ..rabbitmq import setup_rabbitmq diff --git a/services/web/server/src/simcore_service_webserver/application_settings.py b/services/web/server/src/simcore_service_webserver/application_settings.py index 7c83046ce51..d1916698b39 100644 --- a/services/web/server/src/simcore_service_webserver/application_settings.py +++ b/services/web/server/src/simcore_service_webserver/application_settings.py @@ -25,17 +25,17 @@ from settings_library.tracing import TracingSettings from settings_library.utils_logging import MixinLoggingSettings from settings_library.utils_service import DEFAULT_AIOHTTP_PORT -from simcore_service_webserver.licenses.settings import LicensesSettings -from ._constants import APP_SETTINGS_KEY from ._meta import API_VERSION, API_VTAG, APP_NAME from .catalog.settings import CatalogSettings +from .constants import APP_SETTINGS_KEY from .diagnostics.settings import DiagnosticsSettings from .director_v2.settings import DirectorV2Settings from .dynamic_scheduler.settings import DynamicSchedulerSettings from .exporter.settings import ExporterSettings from .garbage_collector.settings import GarbageCollectorSettings from .invitations.settings import InvitationsSettings +from .licenses.settings import LicensesSettings from .login.settings import LoginSettings from .payments.settings import PaymentsSettings from .projects.settings import ProjectsSettings diff --git a/services/web/server/src/simcore_service_webserver/application_settings_utils.py b/services/web/server/src/simcore_service_webserver/application_settings_utils.py index d14f20846a5..4adf8936f94 100644 --- a/services/web/server/src/simcore_service_webserver/application_settings_utils.py +++ b/services/web/server/src/simcore_service_webserver/application_settings_utils.py @@ -14,8 +14,8 @@ from pydantic.types import SecretStr from servicelib.aiohttp.typing_extension import Handler -from ._constants import MSG_UNDER_DEVELOPMENT from .application_settings import ApplicationSettings, get_application_settings +from .constants import MSG_UNDER_DEVELOPMENT _logger = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/catalog/_api.py b/services/web/server/src/simcore_service_webserver/catalog/_api.py index 9a287235133..2c85d4007e6 100644 --- a/services/web/server/src/simcore_service_webserver/catalog/_api.py +++ b/services/web/server/src/simcore_service_webserver/catalog/_api.py @@ -27,7 +27,7 @@ from servicelib.rabbitmq.rpc_interfaces.catalog import services as catalog_rpc from servicelib.rest_constants import RESPONSE_MODEL_POLICY -from .._constants import RQ_PRODUCT_KEY, RQT_USERID_KEY +from ..constants import RQ_PRODUCT_KEY, RQT_USERID_KEY from ..rabbitmq import get_rabbitmq_rpc_client from . import client from ._api_units import can_connect, replace_service_input_outputs diff --git a/services/web/server/src/simcore_service_webserver/catalog/settings.py b/services/web/server/src/simcore_service_webserver/catalog/settings.py index 0687cbcb56e..6e7768b03cc 100644 --- a/services/web/server/src/simcore_service_webserver/catalog/settings.py +++ b/services/web/server/src/simcore_service_webserver/catalog/settings.py @@ -7,7 +7,7 @@ from aiohttp import web from settings_library.catalog import CatalogSettings -from .._constants import APP_SETTINGS_KEY +from ..constants import APP_SETTINGS_KEY def get_plugin_settings(app: web.Application) -> CatalogSettings: diff --git a/services/web/server/src/simcore_service_webserver/_constants.py b/services/web/server/src/simcore_service_webserver/constants.py similarity index 68% rename from services/web/server/src/simcore_service_webserver/_constants.py rename to services/web/server/src/simcore_service_webserver/constants.py index 6590592afaf..b2997155ba4 100644 --- a/services/web/server/src/simcore_service_webserver/_constants.py +++ b/services/web/server/src/simcore_service_webserver/constants.py @@ -1,5 +1,4 @@ # pylint:disable=unused-import -# nopycln: file from typing import Final @@ -14,24 +13,48 @@ # Application storage keys APP_PRODUCTS_KEY: Final[str] = f"{__name__ }.APP_PRODUCTS_KEY" -# Request storage keys -RQ_PRODUCT_KEY: Final[str] = f"{__name__}.RQ_PRODUCT_KEY" +# Public config per product returned in /config +APP_PUBLIC_CONFIG_PER_PRODUCT: Final[str] = f"{__name__}.APP_PUBLIC_CONFIG_PER_PRODUCT" + +FRONTEND_APPS_AVAILABLE = frozenset( + # These are the apps built right now by static-webserver/client + { + "osparc", + "s4l", + "s4lacad", + "s4ldesktop", + "s4ldesktopacad", + "s4lengine", + "s4llite", + "tiplite", + "tis", + } +) +FRONTEND_APP_DEFAULT = "osparc" + +assert FRONTEND_APP_DEFAULT in FRONTEND_APPS_AVAILABLE # nosec + # main index route name = front-end INDEX_RESOURCE_NAME: Final[str] = "get_cached_frontend_index" -# Public config per product returned in /config -APP_PUBLIC_CONFIG_PER_PRODUCT: Final[str] = f"{__name__}.APP_PUBLIC_CONFIG_PER_PRODUCT" - MSG_UNDER_DEVELOPMENT: Final[ str ] = "Under development. Use WEBSERVER_DEV_FEATURES_ENABLED=1 to enable current implementation" +# Request storage keys +RQ_PRODUCT_KEY: Final[str] = f"{__name__}.RQ_PRODUCT_KEY" + + __all__: tuple[str, ...] = ( "APP_AIOPG_ENGINE_KEY", "APP_CONFIG_KEY", "APP_FIRE_AND_FORGET_TASKS_KEY", "APP_SETTINGS_KEY", + "FRONTEND_APPS_AVAILABLE", + "FRONTEND_APP_DEFAULT", "RQT_USERID_KEY", ) + +# nopycln: file diff --git a/services/web/server/src/simcore_service_webserver/db/base_repository.py b/services/web/server/src/simcore_service_webserver/db/base_repository.py index f7c207fb1b0..7c32e618277 100644 --- a/services/web/server/src/simcore_service_webserver/db/base_repository.py +++ b/services/web/server/src/simcore_service_webserver/db/base_repository.py @@ -2,7 +2,7 @@ from aiopg.sa.engine import Engine from models_library.users import UserID -from .._constants import RQT_USERID_KEY +from ..constants import RQT_USERID_KEY from . import _aiopg diff --git a/services/web/server/src/simcore_service_webserver/db/settings.py b/services/web/server/src/simcore_service_webserver/db/settings.py index 6ba62e8b4d4..b30787bd952 100644 --- a/services/web/server/src/simcore_service_webserver/db/settings.py +++ b/services/web/server/src/simcore_service_webserver/db/settings.py @@ -1,7 +1,7 @@ from aiohttp.web import Application from settings_library.postgres import PostgresSettings -from .._constants import APP_SETTINGS_KEY +from ..constants import APP_SETTINGS_KEY def get_plugin_settings(app: Application) -> PostgresSettings: diff --git a/services/web/server/src/simcore_service_webserver/diagnostics/plugin.py b/services/web/server/src/simcore_service_webserver/diagnostics/plugin.py index a9bcb1306b8..8c843699bd5 100644 --- a/services/web/server/src/simcore_service_webserver/diagnostics/plugin.py +++ b/services/web/server/src/simcore_service_webserver/diagnostics/plugin.py @@ -6,8 +6,8 @@ from servicelib.aiohttp import monitor_slow_callbacks from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup from servicelib.aiohttp.profiler_middleware import profiling_middleware -from simcore_service_webserver.application_settings import get_application_settings +from ..application_settings import get_application_settings from ..rest.healthcheck import HealthCheck from ..rest.plugin import setup_rest from . import _handlers diff --git a/services/web/server/src/simcore_service_webserver/director_v2/_api_utils.py b/services/web/server/src/simcore_service_webserver/director_v2/_api_utils.py index 74bc8e8ee14..9cd864f01cf 100644 --- a/services/web/server/src/simcore_service_webserver/director_v2/_api_utils.py +++ b/services/web/server/src/simcore_service_webserver/director_v2/_api_utils.py @@ -5,7 +5,7 @@ from pydantic import TypeAdapter from ..application_settings import get_application_settings -from ..products.api import Product +from ..products.models import Product from ..projects import api as projects_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/director_v2/_core_computations.py b/services/web/server/src/simcore_service_webserver/director_v2/_core_computations.py index 7785f7936d2..93fe967bea9 100644 --- a/services/web/server/src/simcore_service_webserver/director_v2/_core_computations.py +++ b/services/web/server/src/simcore_service_webserver/director_v2/_core_computations.py @@ -21,7 +21,7 @@ from servicelib.aiohttp import status from servicelib.logging_utils import log_decorator -from ..products.api import get_product +from ..products import products_service from ._api_utils import get_wallet_info from ._core_base import DataType, request_director_v2 from .exceptions import ComputationNotFoundError, DirectorServiceError @@ -107,7 +107,7 @@ async def create_or_update_pipeline( "product_name": product_name, "wallet_info": await get_wallet_info( app, - product=get_product(app, product_name), + product=products_service.get_product(app, product_name), user_id=user_id, project_id=project_id, product_name=product_name, diff --git a/services/web/server/src/simcore_service_webserver/director_v2/_handlers.py b/services/web/server/src/simcore_service_webserver/director_v2/_handlers.py index d32b0f9bafc..32c7fc8b132 100644 --- a/services/web/server/src/simcore_service_webserver/director_v2/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/director_v2/_handlers.py @@ -24,7 +24,7 @@ from ..db.plugin import get_database_engine from ..login.decorators import login_required from ..models import RequestContext -from ..products import api as products_api +from ..products import products_web from ..security.decorators import permission_required from ..users.exceptions import UserDefaultWalletNotFoundError from ..utils_aiohttp import envelope_json_response @@ -88,7 +88,7 @@ async def start_computation(request: web.Request) -> web.Response: ) # Get wallet information - product = products_api.get_current_product(request) + product = products_web.get_current_product(request) wallet_info = await get_wallet_info( request.app, product=product, diff --git a/services/web/server/src/simcore_service_webserver/director_v2/settings.py b/services/web/server/src/simcore_service_webserver/director_v2/settings.py index 31fc096a5dd..79429dbd696 100644 --- a/services/web/server/src/simcore_service_webserver/director_v2/settings.py +++ b/services/web/server/src/simcore_service_webserver/director_v2/settings.py @@ -13,7 +13,7 @@ from settings_library.utils_service import DEFAULT_FASTAPI_PORT, MixinServiceSettings from yarl import URL -from .._constants import APP_SETTINGS_KEY +from ..constants import APP_SETTINGS_KEY _MINUTE = 60 _HOUR = 60 * _MINUTE diff --git a/services/web/server/src/simcore_service_webserver/dynamic_scheduler/plugin.py b/services/web/server/src/simcore_service_webserver/dynamic_scheduler/plugin.py index 2dec18abcd8..905026d97be 100644 --- a/services/web/server/src/simcore_service_webserver/dynamic_scheduler/plugin.py +++ b/services/web/server/src/simcore_service_webserver/dynamic_scheduler/plugin.py @@ -6,9 +6,9 @@ from aiohttp import web from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup -from simcore_service_webserver.rabbitmq import setup_rabbitmq -from .._constants import APP_SETTINGS_KEY +from ..constants import APP_SETTINGS_KEY +from ..rabbitmq import setup_rabbitmq _logger = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/dynamic_scheduler/settings.py b/services/web/server/src/simcore_service_webserver/dynamic_scheduler/settings.py index 5f33995a89e..b2f1cec26f5 100644 --- a/services/web/server/src/simcore_service_webserver/dynamic_scheduler/settings.py +++ b/services/web/server/src/simcore_service_webserver/dynamic_scheduler/settings.py @@ -5,7 +5,7 @@ from settings_library.base import BaseCustomSettings from settings_library.utils_service import MixinServiceSettings -from .._constants import APP_SETTINGS_KEY +from ..constants import APP_SETTINGS_KEY class DynamicSchedulerSettings(BaseCustomSettings, MixinServiceSettings): diff --git a/services/web/server/src/simcore_service_webserver/email/_handlers.py b/services/web/server/src/simcore_service_webserver/email/_handlers.py index 84126852347..6029fb0cd5c 100644 --- a/services/web/server/src/simcore_service_webserver/email/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/email/_handlers.py @@ -8,7 +8,8 @@ from .._meta import API_VTAG from ..login.decorators import login_required -from ..products.api import Product, get_current_product, get_product_template_path +from ..products import products_web +from ..products.models import Product from ..security.decorators import permission_required from ..utils import get_traceback_string from ..utils_aiohttp import envelope_json_response @@ -72,9 +73,9 @@ async def test_email(request: web.Request): body = await parse_request_body_as(TestEmail, request) - product: Product = get_current_product(request) + product: Product = products_web.get_current_product(request) - template_path = await get_product_template_path( + template_path = await products_web.get_product_template_path( request, filename=body.template_name ) diff --git a/services/web/server/src/simcore_service_webserver/email/settings.py b/services/web/server/src/simcore_service_webserver/email/settings.py index 4657998f7c8..bd952059261 100644 --- a/services/web/server/src/simcore_service_webserver/email/settings.py +++ b/services/web/server/src/simcore_service_webserver/email/settings.py @@ -1,7 +1,7 @@ from aiohttp import web from settings_library.email import SMTPSettings -from .._constants import APP_SETTINGS_KEY +from ..constants import APP_SETTINGS_KEY def get_plugin_settings(app: web.Application) -> SMTPSettings: diff --git a/services/web/server/src/simcore_service_webserver/exporter/_handlers.py b/services/web/server/src/simcore_service_webserver/exporter/_handlers.py index d0e0d975f6c..783ec5679c0 100644 --- a/services/web/server/src/simcore_service_webserver/exporter/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/exporter/_handlers.py @@ -12,8 +12,8 @@ from servicelib.redis import with_project_locked from servicelib.request_keys import RQT_USERID_KEY -from .._constants import RQ_PRODUCT_KEY from .._meta import API_VTAG +from ..constants import RQ_PRODUCT_KEY from ..login.decorators import login_required from ..projects.projects_service import create_user_notification_cb from ..redis import get_redis_lock_manager_client_sdk diff --git a/services/web/server/src/simcore_service_webserver/folders/_common/models.py b/services/web/server/src/simcore_service_webserver/folders/_common/models.py index 551c531d74c..b48588d4d59 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_common/models.py +++ b/services/web/server/src/simcore_service_webserver/folders/_common/models.py @@ -20,7 +20,7 @@ from models_library.workspaces import WorkspaceID from pydantic import BaseModel, BeforeValidator, ConfigDict, Field -from ..._constants import RQ_PRODUCT_KEY, RQT_USERID_KEY +from ...constants import RQ_PRODUCT_KEY, RQT_USERID_KEY _logger = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/folders/_folders_repository.py b/services/web/server/src/simcore_service_webserver/folders/_folders_repository.py index 31ce1b0fc8e..072e944688e 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_folders_repository.py +++ b/services/web/server/src/simcore_service_webserver/folders/_folders_repository.py @@ -93,8 +93,6 @@ def _create_private_workspace_query( return ( sql.select( *_FOLDER_DB_MODEL_COLS, - # NOTE: design INVARIANT: - # a user in his private workspace owns his folders sql.func.json_build_object( "read", sa.text("true"), @@ -132,8 +130,6 @@ def _create_shared_workspace_query( shared_workspace_query = ( sql.select( *_FOLDER_DB_MODEL_COLS, - # NOTE: design INVARIANT: - # a user access rights to a folder in a SHARED workspace is inherited from the workspace workspace_access_rights_subquery.c.my_access_rights, ) .select_from( @@ -160,12 +156,12 @@ def _create_shared_workspace_query( return shared_workspace_query -def _to_sql_expression(table: sa.Table, order_by: OrderBy): +def _to_expression(order_by: OrderBy): direction_func: Callable = { OrderDirection.ASC: sql.asc, OrderDirection.DESC: sql.desc, }[order_by.direction] - return direction_func(table.columns[order_by.field]) + return direction_func(folders_v2.columns[order_by.field]) async def list_( # pylint: disable=too-many-arguments,too-many-branches @@ -249,9 +245,7 @@ async def list_( # pylint: disable=too-many-arguments,too-many-branches # Ordering and pagination list_query = ( - combined_query.order_by(_to_sql_expression(folders_v2, order_by)) - .offset(offset) - .limit(limit) + combined_query.order_by(_to_expression(order_by)).offset(offset).limit(limit) ) async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: @@ -279,8 +273,9 @@ async def list_trashed_folders( ) -> tuple[int, list[FolderDB]]: """ NOTE: this is app-wide i.e. no product, user or workspace filtered + TODO: check with MD about workspaces """ - base_query = sql.select(*_FOLDER_DB_MODEL_COLS).where( + base_query = sql.select(_FOLDER_DB_MODEL_COLS).where( folders_v2.c.trashed.is_not(None) ) @@ -299,9 +294,7 @@ async def list_trashed_folders( # Ordering and pagination list_query = ( - base_query.order_by(_to_sql_expression(folders_v2, order_by)) - .offset(offset) - .limit(limit) + base_query.order_by(_to_expression(order_by)).offset(offset).limit(limit) ) async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: @@ -467,24 +460,6 @@ async def delete_recursively( ) -def _create_folder_hierarchy_cte(base_query: Select): - folder_hierarchy_cte = base_query.cte(name="folder_hierarchy", recursive=True) - - # Step 2: Define the recursive case - folder_alias = aliased(folders_v2) - recursive_query = sql.select( - folder_alias.c.folder_id, folder_alias.c.parent_folder_id - ).select_from( - folder_alias.join( - folder_hierarchy_cte, - folder_alias.c.parent_folder_id == folder_hierarchy_cte.c.folder_id, - ) - ) - - # Step 3: Combine base and recursive cases into a CTE - return folder_hierarchy_cte.union_all(recursive_query) - - async def get_projects_recursively_only_if_user_is_owner( app: web.Application, connection: AsyncConnection | None = None, @@ -511,9 +486,21 @@ async def get_projects_recursively_only_if_user_is_owner( (folders_v2.c.folder_id == folder_id) # <-- specified folder id & (folders_v2.c.product_name == product_name) ) + folder_hierarchy_cte = base_query.cte(name="folder_hierarchy", recursive=True) - # Step 2,3 - folder_hierarchy_cte = _create_folder_hierarchy_cte(base_query) + # Step 2: Define the recursive case + folder_alias = aliased(folders_v2) + recursive_query = sql.select( + folder_alias.c.folder_id, folder_alias.c.parent_folder_id + ).select_from( + folder_alias.join( + folder_hierarchy_cte, + folder_alias.c.parent_folder_id == folder_hierarchy_cte.c.folder_id, + ) + ) + + # Step 3: Combine base and recursive cases into a CTE + folder_hierarchy_cte = folder_hierarchy_cte.union_all(recursive_query) # Step 4: Execute the query to get all descendants final_query = sql.select(folder_hierarchy_cte) @@ -557,9 +544,21 @@ async def get_all_folders_and_projects_ids_recursively( (folders_v2.c.folder_id == folder_id) # <-- specified folder id & (folders_v2.c.product_name == product_name) ) + folder_hierarchy_cte = base_query.cte(name="folder_hierarchy", recursive=True) - # Step 2, 3 - folder_hierarchy_cte = _create_folder_hierarchy_cte(base_query) + # Step 2: Define the recursive case + folder_alias = aliased(folders_v2) + recursive_query = sql.select( + folder_alias.c.folder_id, folder_alias.c.parent_folder_id + ).select_from( + folder_alias.join( + folder_hierarchy_cte, + folder_alias.c.parent_folder_id == folder_hierarchy_cte.c.folder_id, + ) + ) + + # Step 3: Combine base and recursive cases into a CTE + folder_hierarchy_cte = folder_hierarchy_cte.union_all(recursive_query) # Step 4: Execute the query to get all descendants final_query = sql.select(folder_hierarchy_cte) @@ -594,9 +593,21 @@ async def get_folders_recursively( (folders_v2.c.folder_id == folder_id) # <-- specified folder id & (folders_v2.c.product_name == product_name) ) + folder_hierarchy_cte = base_query.cte(name="folder_hierarchy", recursive=True) + + # Step 2: Define the recursive case + folder_alias = aliased(folders_v2) + recursive_query = sql.select( + folder_alias.c.folder_id, folder_alias.c.parent_folder_id + ).select_from( + folder_alias.join( + folder_hierarchy_cte, + folder_alias.c.parent_folder_id == folder_hierarchy_cte.c.folder_id, + ) + ) - # Step 2, 3 - folder_hierarchy_cte = _create_folder_hierarchy_cte(base_query) + # Step 3: Combine base and recursive cases into a CTE + folder_hierarchy_cte = folder_hierarchy_cte.union_all(recursive_query) # Step 4: Execute the query to get all descendants final_query = sql.select(folder_hierarchy_cte) diff --git a/services/web/server/src/simcore_service_webserver/folders/_trash_rest.py b/services/web/server/src/simcore_service_webserver/folders/_trash_rest.py index 0e035012adb..6540a43eef9 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_trash_rest.py +++ b/services/web/server/src/simcore_service_webserver/folders/_trash_rest.py @@ -9,7 +9,7 @@ from .._meta import API_VTAG as VTAG from ..login.decorators import get_user_id, login_required -from ..products.api import get_product_name +from ..products import products_web from ..security.decorators import permission_required from . import _trash_service from ._common.exceptions_handlers import handle_plugin_requests_exceptions @@ -27,7 +27,7 @@ @handle_plugin_requests_exceptions async def trash_folder(request: web.Request): user_id = get_user_id(request) - product_name = get_product_name(request) + product_name = products_web.get_product_name(request) path_params = parse_request_path_parameters_as(FoldersPathParams, request) query_params: FolderTrashQueryParams = parse_request_query_parameters_as( FolderTrashQueryParams, request @@ -50,7 +50,7 @@ async def trash_folder(request: web.Request): @handle_plugin_requests_exceptions async def untrash_folder(request: web.Request): user_id = get_user_id(request) - product_name = get_product_name(request) + product_name = products_web.get_product_name(request) path_params = parse_request_path_parameters_as(FoldersPathParams, request) await _trash_service.untrash_folder( diff --git a/services/web/server/src/simcore_service_webserver/garbage_collector/plugin.py b/services/web/server/src/simcore_service_webserver/garbage_collector/plugin.py index 7374eccd561..167679b62d8 100644 --- a/services/web/server/src/simcore_service_webserver/garbage_collector/plugin.py +++ b/services/web/server/src/simcore_service_webserver/garbage_collector/plugin.py @@ -3,10 +3,10 @@ from aiohttp import web from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup from servicelib.logging_utils import set_parent_module_log_level -from simcore_service_webserver.products.plugin import setup_products from ..application_settings import get_application_settings from ..login.plugin import setup_login_storage +from ..products.plugin import setup_products from ..projects.db import setup_projects_db from ..socketio.plugin import setup_socketio from . import _tasks_api_keys, _tasks_core, _tasks_trash, _tasks_users diff --git a/services/web/server/src/simcore_service_webserver/groups/_common/schemas.py b/services/web/server/src/simcore_service_webserver/groups/_common/schemas.py index 18ab7cba5ff..dc173d59496 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_common/schemas.py +++ b/services/web/server/src/simcore_service_webserver/groups/_common/schemas.py @@ -5,7 +5,7 @@ from models_library.users import UserID from pydantic import Field -from ..._constants import RQ_PRODUCT_KEY, RQT_USERID_KEY +from ...constants import RQ_PRODUCT_KEY, RQT_USERID_KEY class GroupsRequestContext(RequestParameters): diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_rest.py b/services/web/server/src/simcore_service_webserver/groups/_groups_rest.py index 5456776cfe6..18032f8ea37 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_rest.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_rest.py @@ -19,7 +19,8 @@ from .._meta import API_VTAG from ..login.decorators import login_required -from ..products.api import Product, get_current_product +from ..products import products_web +from ..products.models import Product from ..security.decorators import permission_required from ..utils_aiohttp import envelope_json_response from . import _groups_service @@ -45,7 +46,7 @@ async def list_groups(request: web.Request): """ List all groups (organizations, primary, everyone and products) I belong to """ - product: Product = get_current_product(request) + product: Product = products_web.get_current_product(request) req_ctx = GroupsRequestContext.model_validate(request) groups_by_type = await _groups_service.list_user_groups_with_read_access( diff --git a/services/web/server/src/simcore_service_webserver/groups/plugin.py b/services/web/server/src/simcore_service_webserver/groups/plugin.py index 4b240bee190..e8e56413671 100644 --- a/services/web/server/src/simcore_service_webserver/groups/plugin.py +++ b/services/web/server/src/simcore_service_webserver/groups/plugin.py @@ -3,7 +3,7 @@ from aiohttp import web from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup -from .._constants import APP_SETTINGS_KEY +from ..constants import APP_SETTINGS_KEY from ..products.plugin import setup_products from . import _classifiers_rest, _groups_rest diff --git a/services/web/server/src/simcore_service_webserver/invitations/_client.py b/services/web/server/src/simcore_service_webserver/invitations/_client.py index b7abdcf10ee..84417a759ea 100644 --- a/services/web/server/src/simcore_service_webserver/invitations/_client.py +++ b/services/web/server/src/simcore_service_webserver/invitations/_client.py @@ -16,7 +16,7 @@ from servicelib.aiohttp import status from yarl import URL -from .._constants import APP_SETTINGS_KEY +from ..constants import APP_SETTINGS_KEY from .errors import ( InvalidInvitationError, InvitationsError, diff --git a/services/web/server/src/simcore_service_webserver/products/_invitations_handlers.py b/services/web/server/src/simcore_service_webserver/invitations/_rest.py similarity index 91% rename from services/web/server/src/simcore_service_webserver/products/_invitations_handlers.py rename to services/web/server/src/simcore_service_webserver/invitations/_rest.py index a7cbd01dee1..ebbd5349503 100644 --- a/services/web/server/src/simcore_service_webserver/products/_invitations_handlers.py +++ b/services/web/server/src/simcore_service_webserver/invitations/_rest.py @@ -3,7 +3,7 @@ from aiohttp import web from models_library.api_schemas_invitations.invitations import ApiInvitationInputs from models_library.api_schemas_webserver.product import ( - GenerateInvitation, + InvitationGenerate, InvitationGenerated, ) from models_library.rest_base import RequestParameters @@ -11,15 +11,15 @@ from pydantic import Field from servicelib.aiohttp.requests_validation import parse_request_body_as from servicelib.request_keys import RQT_USERID_KEY -from simcore_service_webserver.utils_aiohttp import envelope_json_response from yarl import URL -from .._constants import RQ_PRODUCT_KEY from .._meta import API_VTAG as VTAG -from ..invitations import api +from ..constants import RQ_PRODUCT_KEY from ..login.decorators import login_required from ..security.decorators import permission_required from ..users.api import get_user_name_and_email +from ..utils_aiohttp import envelope_json_response +from . import api routes = web.RouteTableDef() @@ -37,7 +37,7 @@ class _ProductsRequestContext(RequestParameters): @permission_required("product.invitations.create") async def generate_invitation(request: web.Request): req_ctx = _ProductsRequestContext.model_validate(request) - body = await parse_request_body_as(GenerateInvitation, request) + body = await parse_request_body_as(InvitationGenerate, request) _, user_email = await get_user_name_and_email(request.app, user_id=req_ctx.user_id) diff --git a/services/web/server/src/simcore_service_webserver/invitations/_service.py b/services/web/server/src/simcore_service_webserver/invitations/_service.py index ae7cabaf616..f29bf595efc 100644 --- a/services/web/server/src/simcore_service_webserver/invitations/_service.py +++ b/services/web/server/src/simcore_service_webserver/invitations/_service.py @@ -11,7 +11,7 @@ from pydantic import AnyHttpUrl, TypeAdapter, ValidationError from ..groups.api import is_user_by_email_in_group -from ..products.api import Product +from ..products.models import Product from ._client import get_invitations_service_api from .errors import ( MSG_INVALID_INVITATION_URL, diff --git a/services/web/server/src/simcore_service_webserver/invitations/plugin.py b/services/web/server/src/simcore_service_webserver/invitations/plugin.py index fd20f0f8601..344f652ac83 100644 --- a/services/web/server/src/simcore_service_webserver/invitations/plugin.py +++ b/services/web/server/src/simcore_service_webserver/invitations/plugin.py @@ -6,11 +6,11 @@ from aiohttp import web from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup -from simcore_service_webserver.products.plugin import setup_products -from .._constants import APP_SETTINGS_KEY +from ..constants import APP_SETTINGS_KEY from ..db.plugin import setup_db from ..products.plugin import setup_products +from . import _rest from ._client import invitations_service_api_cleanup_ctx _logger = logging.getLogger(__name__) @@ -28,4 +28,6 @@ def setup_invitations(app: web.Application): setup_db(app) setup_products(app) + app.router.add_routes(_rest.routes) + app.cleanup_ctx.append(invitations_service_api_cleanup_ctx) diff --git a/services/web/server/src/simcore_service_webserver/invitations/settings.py b/services/web/server/src/simcore_service_webserver/invitations/settings.py index 02755291910..9401a1912f8 100644 --- a/services/web/server/src/simcore_service_webserver/invitations/settings.py +++ b/services/web/server/src/simcore_service_webserver/invitations/settings.py @@ -17,7 +17,7 @@ URLPart, ) -from .._constants import APP_SETTINGS_KEY +from ..constants import APP_SETTINGS_KEY _INVITATION_VTAG_V1: Final[VersionTag] = TypeAdapter(VersionTag).validate_python("v1") diff --git a/services/web/server/src/simcore_service_webserver/licenses/_common/models.py b/services/web/server/src/simcore_service_webserver/licenses/_common/models.py index 94a74d686f4..2469eb14614 100644 --- a/services/web/server/src/simcore_service_webserver/licenses/_common/models.py +++ b/services/web/server/src/simcore_service_webserver/licenses/_common/models.py @@ -16,7 +16,7 @@ from pydantic import BaseModel, ConfigDict, Field from servicelib.request_keys import RQT_USERID_KEY -from ..._constants import RQ_PRODUCT_KEY +from ...constants import RQ_PRODUCT_KEY class LicensedItemsRequestContext(RequestParameters): diff --git a/services/web/server/src/simcore_service_webserver/login/_2fa_api.py b/services/web/server/src/simcore_service_webserver/login/_2fa_api.py index fc844dd79f6..8ab315902fb 100644 --- a/services/web/server/src/simcore_service_webserver/login/_2fa_api.py +++ b/services/web/server/src/simcore_service_webserver/login/_2fa_api.py @@ -20,7 +20,7 @@ from twilio.rest import Client # type: ignore[import-untyped] from ..login.errors import SendingVerificationEmailError, SendingVerificationSmsError -from ..products.api import Product +from ..products.models import Product from ..redis import get_redis_validation_code_client from .utils_email import get_template_path, send_email_from_template diff --git a/services/web/server/src/simcore_service_webserver/login/_2fa_handlers.py b/services/web/server/src/simcore_service_webserver/login/_2fa_handlers.py index 7d35b2e7cca..83c2119dab3 100644 --- a/services/web/server/src/simcore_service_webserver/login/_2fa_handlers.py +++ b/services/web/server/src/simcore_service_webserver/login/_2fa_handlers.py @@ -9,7 +9,8 @@ from servicelib.aiohttp.requests_validation import parse_request_body_as from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON -from ..products.api import Product, get_current_product +from ..products import products_web +from ..products.models import Product from ..session.access_policies import session_access_required from ._2fa_api import ( create_2fa_code, @@ -51,7 +52,7 @@ class Resend2faBody(InputSchema): @handle_login_exceptions async def resend_2fa_code(request: web.Request): """Resends 2FA code via SMS/Email""" - product: Product = get_current_product(request) + product: Product = products_web.get_current_product(request) settings: LoginSettingsForProduct = get_plugin_settings( request.app, product_name=product.name ) diff --git a/services/web/server/src/simcore_service_webserver/login/_auth_api.py b/services/web/server/src/simcore_service_webserver/login/_auth_api.py index a5de2c1abc5..5e00ae0b9e6 100644 --- a/services/web/server/src/simcore_service_webserver/login/_auth_api.py +++ b/services/web/server/src/simcore_service_webserver/login/_auth_api.py @@ -8,7 +8,7 @@ from ..db.plugin import get_database_engine from ..groups.api import is_user_by_email_in_group -from ..products.api import Product +from ..products.models import Product from ..security.api import check_password, encrypt_password from ._constants import MSG_UNKNOWN_EMAIL, MSG_WRONG_PASSWORD from .storage import AsyncpgStorage, get_plugin_storage diff --git a/services/web/server/src/simcore_service_webserver/login/_auth_handlers.py b/services/web/server/src/simcore_service_webserver/login/_auth_handlers.py index db8ee3421e3..fe1794363d8 100644 --- a/services/web/server/src/simcore_service_webserver/login/_auth_handlers.py +++ b/services/web/server/src/simcore_service_webserver/login/_auth_handlers.py @@ -13,7 +13,8 @@ from simcore_postgres_database.models.users import UserRole from .._meta import API_VTAG -from ..products.api import Product, get_current_product +from ..products import products_web +from ..products.models import Product from ..security.api import forget_identity from ..session.access_policies import ( on_success_grant_session_access_to, @@ -96,7 +97,7 @@ async def login(request: web.Request): If 2FA is enabled, then the login continues with a second request to login_2fa """ - product: Product = get_current_product(request) + product: Product = products_web.get_current_product(request) settings: LoginSettingsForProduct = get_plugin_settings( request.app, product_name=product.name ) @@ -235,7 +236,7 @@ class LoginTwoFactorAuthBody(InputSchema): ) async def login_2fa(request: web.Request): """Login (continuation): Submits 2FA code""" - product: Product = get_current_product(request) + product: Product = products_web.get_current_product(request) settings: LoginSettingsForProduct = get_plugin_settings( request.app, product_name=product.name ) 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 6471757d183..2cbabf64706 100644 --- a/services/web/server/src/simcore_service_webserver/login/_registration.py +++ b/services/web/server/src/simcore_service_webserver/login/_registration.py @@ -39,7 +39,7 @@ InvalidInvitationError, InvitationsServiceUnavailableError, ) -from ..products.api import Product +from ..products.models import Product from ._confirmation import is_confirmation_expired, validate_confirmation_code from ._constants import ( MSG_EMAIL_ALREADY_REGISTERED, diff --git a/services/web/server/src/simcore_service_webserver/login/_registration_api.py b/services/web/server/src/simcore_service_webserver/login/_registration_api.py index 2d538942680..3e248fcabee 100644 --- a/services/web/server/src/simcore_service_webserver/login/_registration_api.py +++ b/services/web/server/src/simcore_service_webserver/login/_registration_api.py @@ -13,7 +13,8 @@ from servicelib.utils_secrets import generate_passcode from ..email.utils import send_email_from_template -from ..products.api import Product, get_current_product, get_product_template_path +from ..products import products_web +from ..products.models import Product _logger = logging.getLogger(__name__) @@ -25,8 +26,10 @@ async def send_close_account_email( retention_days: PositiveInt, ): template_name = "close_account.jinja2" - email_template_path = await get_product_template_path(request, template_name) - product = get_current_product(request) + email_template_path = await products_web.get_product_template_path( + request, template_name + ) + product: Product = products_web.get_current_product(request) try: await send_email_from_template( @@ -64,7 +67,9 @@ async def send_account_request_email_to_support( ): template_name = "request_account.jinja2" destination_email = product.product_owners_email or product.support_email - email_template_path = await get_product_template_path(request, template_name) + email_template_path = await products_web.get_product_template_path( + request, template_name + ) try: user_email = TypeAdapter(LowerCaseEmailStr).validate_python( request_form.get("email", None) diff --git a/services/web/server/src/simcore_service_webserver/login/_registration_handlers.py b/services/web/server/src/simcore_service_webserver/login/_registration_handlers.py index 42e8229e7a6..2cbb69db5ee 100644 --- a/services/web/server/src/simcore_service_webserver/login/_registration_handlers.py +++ b/services/web/server/src/simcore_service_webserver/login/_registration_handlers.py @@ -16,9 +16,10 @@ from servicelib.request_keys import RQT_USERID_KEY from servicelib.utils import fire_and_forget_task -from .._constants import RQ_PRODUCT_KEY from .._meta import API_VTAG -from ..products.api import Product, get_current_product +from ..constants import RQ_PRODUCT_KEY +from ..products import products_web +from ..products.models import Product from ..security.api import check_password, forget_identity from ..security.decorators import permission_required from ..session.api import get_session @@ -62,7 +63,7 @@ def _get_ipinfo(request: web.Request) -> dict[str, Any]: ) @global_rate_limit_route(number_of_requests=30, interval_seconds=MINUTE) async def request_product_account(request: web.Request): - product = get_current_product(request) + product = products_web.get_current_product(request) session = await get_session(request) body = await parse_request_body_as(AccountRequestInfo, request) @@ -101,7 +102,7 @@ async def unregister_account(request: web.Request): req_ctx = _AuthenticatedContext.model_validate(request) body = await parse_request_body_as(UnregisterCheck, request) - product: Product = get_current_product(request) + product: Product = products_web.get_current_product(request) settings: LoginSettingsForProduct = get_plugin_settings( request.app, product_name=product.name ) diff --git a/services/web/server/src/simcore_service_webserver/login/decorators.py b/services/web/server/src/simcore_service_webserver/login/decorators.py index 1fd2bc90871..e5be70e1efb 100644 --- a/services/web/server/src/simcore_service_webserver/login/decorators.py +++ b/services/web/server/src/simcore_service_webserver/login/decorators.py @@ -7,7 +7,7 @@ from servicelib.aiohttp.typing_extension import HandlerAnyReturn from servicelib.request_keys import RQT_USERID_KEY -from ..products.api import get_product_name +from ..products import products_web from ..security.api import ( PERMISSION_PRODUCT_LOGIN_KEY, AuthContextDict, @@ -62,7 +62,7 @@ async def _wrapper(request: web.Request): request, PERMISSION_PRODUCT_LOGIN_KEY, context=AuthContextDict( - product_name=get_product_name(request), + product_name=products_web.get_product_name(request), authorized_uid=user_id, ), ) diff --git a/services/web/server/src/simcore_service_webserver/login/handlers_change.py b/services/web/server/src/simcore_service_webserver/login/handlers_change.py index 75c93ff990e..2325bd46d41 100644 --- a/services/web/server/src/simcore_service_webserver/login/handlers_change.py +++ b/services/web/server/src/simcore_service_webserver/login/handlers_change.py @@ -8,10 +8,11 @@ from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON from servicelib.request_keys import RQT_USERID_KEY from simcore_postgres_database.utils_users import UsersRepo -from simcore_service_webserver.db.plugin import get_database_engine from .._meta import API_VTAG -from ..products.api import Product, get_current_product +from ..db.plugin import get_database_engine +from ..products import products_web +from ..products.models import Product from ..security.api import check_password, encrypt_password from ..utils import HOUR from ..utils_rate_limiting import global_rate_limit_route @@ -66,7 +67,7 @@ async def submit_request_to_reset_password(request: web.Request): db: AsyncpgStorage = get_plugin_storage(request.app) cfg: LoginOptions = get_plugin_options(request.app) - product: Product = get_current_product(request) + product: Product = products_web.get_current_product(request) request_body = await parse_request_body_as(ResetPasswordBody, request) @@ -139,7 +140,7 @@ class ChangeEmailBody(InputSchema): async def submit_request_to_change_email(request: web.Request): # NOTE: This code have been intentially disabled in https://github.com/ITISFoundation/osparc-simcore/pull/5472 db: AsyncpgStorage = get_plugin_storage(request.app) - product: Product = get_current_product(request) + product: Product = products_web.get_current_product(request) request_body = await parse_request_body_as(ChangeEmailBody, request) 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 2fe63036378..886ee6c355a 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 @@ -26,7 +26,8 @@ from simcore_postgres_database.errors import UniqueViolation from yarl import URL -from ..products.api import Product, get_current_product +from ..products import products_web +from ..products.models import Product from ..security.api import encrypt_password from ..session.access_policies import session_access_required from ..utils import MINUTE @@ -138,7 +139,7 @@ async def validate_confirmation_and_redirect(request: web.Request): """ db: AsyncpgStorage = get_plugin_storage(request.app) cfg: LoginOptions = get_plugin_options(request.app) - product: Product = get_current_product(request) + product: Product = products_web.get_current_product(request) path_params = parse_request_path_parameters_as(_PathParam, request) @@ -224,7 +225,7 @@ class PhoneConfirmationBody(InputSchema): unauthorized_reason=MSG_UNAUTHORIZED_PHONE_CONFIRMATION, ) async def phone_confirmation(request: web.Request): - product: Product = get_current_product(request) + product: Product = products_web.get_current_product(request) settings: LoginSettingsForProduct = get_plugin_settings( request.app, product_name=product.name ) @@ -280,7 +281,7 @@ async def reset_password(request: web.Request): """ db: AsyncpgStorage = get_plugin_storage(request.app) cfg: LoginOptions = get_plugin_options(request.app) - product: Product = get_current_product(request) + product: Product = products_web.get_current_product(request) path_params = parse_request_path_parameters_as(_PathParam, request) request_body = await parse_request_body_as(ResetPasswordConfirmation, request) 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 3d00ab57c03..baaff12dbc5 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 @@ -23,7 +23,8 @@ from .._meta import API_VTAG from ..groups.api import auto_add_user_to_groups, auto_add_user_to_product_group from ..invitations.api import is_service_invitation_code -from ..products.api import Product, get_current_product +from ..products import products_web +from ..products.models import Product from ..session.access_policies import ( on_success_grant_session_access_to, session_access_required, @@ -94,7 +95,7 @@ async def check_registration_invitation(request: web.Request): raises HTTPForbidden, HTTPServiceUnavailable """ - product: Product = get_current_product(request) + product: Product = products_web.get_current_product(request) settings: LoginSettingsForProduct = get_plugin_settings( request.app, product_name=product.name ) @@ -145,7 +146,7 @@ async def register(request: web.Request): An email with a link to 'email_confirmation' is sent to complete registration """ - product: Product = get_current_product(request) + product: Product = products_web.get_current_product(request) settings: LoginSettingsForProduct = get_plugin_settings( request.app, product_name=product.name ) @@ -249,7 +250,9 @@ async def register(request: web.Request): if settings.LOGIN_REGISTRATION_CONFIRMATION_REQUIRED: # Confirmation required: send confirmation email _confirmation: ConfirmationTokenDict = await db.create_confirmation( - user["id"], REGISTRATION, data=invitation.model_dump_json() if invitation else None + user["id"], + REGISTRATION, + data=invitation.model_dump_json() if invitation else None, ) try: @@ -358,7 +361,7 @@ async def register_phone(request: web.Request): - sends a code - registration is completed requesting to 'phone_confirmation' route with the code received """ - product: Product = get_current_product(request) + product: Product = products_web.get_current_product(request) settings: LoginSettingsForProduct = get_plugin_settings( request.app, product_name=product.name ) diff --git a/services/web/server/src/simcore_service_webserver/login/plugin.py b/services/web/server/src/simcore_service_webserver/login/plugin.py index ef0c77c2f18..149780b668e 100644 --- a/services/web/server/src/simcore_service_webserver/login/plugin.py +++ b/services/web/server/src/simcore_service_webserver/login/plugin.py @@ -8,7 +8,7 @@ from settings_library.email import SMTPSettings from settings_library.postgres import PostgresSettings -from .._constants import ( +from ..constants import ( APP_PUBLIC_CONFIG_PER_PRODUCT, APP_SETTINGS_KEY, INDEX_RESOURCE_NAME, @@ -18,7 +18,8 @@ from ..email.plugin import setup_email from ..email.settings import get_plugin_settings as get_email_plugin_settings from ..invitations.plugin import setup_invitations -from ..products.api import ProductName, list_products +from ..products import products_service +from ..products.models import ProductName from ..products.plugin import setup_products from ..redis import setup_redis from ..rest.plugin import setup_rest @@ -90,7 +91,7 @@ async def _resolve_login_settings_per_product(app: web.Application): # compose app and product settings errors = {} - for product in list_products(app): + for product in products_service.list_products(app): try: login_settings_per_product[ product.name diff --git a/services/web/server/src/simcore_service_webserver/login/utils_email.py b/services/web/server/src/simcore_service_webserver/login/utils_email.py index a5746e2fde8..9aef8317104 100644 --- a/services/web/server/src/simcore_service_webserver/login/utils_email.py +++ b/services/web/server/src/simcore_service_webserver/login/utils_email.py @@ -5,7 +5,7 @@ from .._resources import webserver_resources from ..email.utils import AttachmentTuple, send_email_from_template -from ..products.api import get_product_template_path +from ..products import products_web log = logging.getLogger(__name__) @@ -16,7 +16,7 @@ def themed(dirname: str, template: str) -> Path: async def get_template_path(request: web.Request, filename: str) -> Path: - return await get_product_template_path(request, filename) + return await products_web.get_product_template_path(request, filename) # prevents auto-removal by pycln diff --git a/services/web/server/src/simcore_service_webserver/models.py b/services/web/server/src/simcore_service_webserver/models.py index 48ffd369586..0b816268baa 100644 --- a/services/web/server/src/simcore_service_webserver/models.py +++ b/services/web/server/src/simcore_service_webserver/models.py @@ -3,7 +3,7 @@ from pydantic import Field from servicelib.request_keys import RQT_USERID_KEY -from ._constants import RQ_PRODUCT_KEY +from .constants import RQ_PRODUCT_KEY class RequestContext(RequestParameters): diff --git a/services/web/server/src/simcore_service_webserver/payments/_events.py b/services/web/server/src/simcore_service_webserver/payments/_events.py index fbc4ebc2047..e9f63d26c20 100644 --- a/services/web/server/src/simcore_service_webserver/payments/_events.py +++ b/services/web/server/src/simcore_service_webserver/payments/_events.py @@ -6,7 +6,7 @@ from aiohttp import web -from ..products.api import list_products +from ..products import products_service from ..products.errors import BelowMinimumPaymentError from .settings import get_plugin_settings @@ -16,7 +16,7 @@ async def validate_prices_in_product_settings_on_startup(app: web.Application): payment_settings = get_plugin_settings(app) - for product in list_products(app): + for product in products_service.list_products(app): if product.min_payment_amount_usd is not None: if ( product.min_payment_amount_usd 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 fbe69e07f83..488189a81a5 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 @@ -24,7 +24,7 @@ from yarl import URL from ..db.plugin import get_database_engine -from ..products.api import get_product_stripe_info +from ..products import products_service 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 @@ -296,7 +296,9 @@ async def init_creation_of_wallet_payment( user_invoice_address = await get_user_invoice_address(app, user_id=user_id) # stripe info - product_stripe_info = await get_product_stripe_info(app, product_name=product_name) + product_stripe_info = await products_service.get_product_stripe_info( + app, product_name=product_name + ) settings: PaymentsSettings = get_plugin_settings(app) payment_inited: WalletPaymentInitiated @@ -378,7 +380,9 @@ async def pay_with_payment_method( assert user_wallet.wallet_id == wallet_id # nosec # stripe info - product_stripe_info = await get_product_stripe_info(app, product_name=product_name) + product_stripe_info = await products_service.get_product_stripe_info( + app, product_name=product_name + ) # user info user = await get_user_display_and_id_names(app, user_id=user_id) diff --git a/services/web/server/src/simcore_service_webserver/payments/_rpc_invoice.py b/services/web/server/src/simcore_service_webserver/payments/_rpc_invoice.py index 359f8cbf4cb..3f1c7255638 100644 --- a/services/web/server/src/simcore_service_webserver/payments/_rpc_invoice.py +++ b/services/web/server/src/simcore_service_webserver/payments/_rpc_invoice.py @@ -8,7 +8,7 @@ from models_library.users import UserID from servicelib.rabbitmq import RPCRouter -from ..products.api import get_credit_amount, get_product_stripe_info +from ..products import products_service from ..rabbitmq import get_rabbitmq_rpc_server from ..users.api import get_user_display_and_id_names, get_user_invoice_address @@ -23,11 +23,11 @@ async def get_invoice_data( dollar_amount: Decimal, product_name: ProductName, ) -> InvoiceDataGet: - credit_result_get: CreditResultGet = await get_credit_amount( + credit_result_get: CreditResultGet = await products_service.get_credit_amount( app, dollar_amount=dollar_amount, product_name=product_name ) - product_stripe_info_get: ProductStripeInfoGet = await get_product_stripe_info( - app, product_name=product_name + product_stripe_info_get: ProductStripeInfoGet = ( + await products_service.get_product_stripe_info(app, product_name=product_name) ) user_invoice_address: UserInvoiceAddress = await get_user_invoice_address( app, user_id=user_id diff --git a/services/web/server/src/simcore_service_webserver/payments/plugin.py b/services/web/server/src/simcore_service_webserver/payments/plugin.py index 777ba5b599a..3e8bbecc56e 100644 --- a/services/web/server/src/simcore_service_webserver/payments/plugin.py +++ b/services/web/server/src/simcore_service_webserver/payments/plugin.py @@ -6,11 +6,11 @@ from aiohttp import web from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup -from simcore_service_webserver.rabbitmq import setup_rabbitmq -from .._constants import APP_SETTINGS_KEY +from ..constants import APP_SETTINGS_KEY from ..db.plugin import setup_db from ..products.plugin import setup_products +from ..rabbitmq import setup_rabbitmq from ..users.plugin import setup_users from . import _events, _rpc_invoice from ._tasks import create_background_task_to_fake_payment_completion diff --git a/services/web/server/src/simcore_service_webserver/payments/settings.py b/services/web/server/src/simcore_service_webserver/payments/settings.py index ef825a5c1e9..1d424000d57 100644 --- a/services/web/server/src/simcore_service_webserver/payments/settings.py +++ b/services/web/server/src/simcore_service_webserver/payments/settings.py @@ -22,7 +22,7 @@ URLPart, ) -from .._constants import APP_SETTINGS_KEY +from ..constants import APP_SETTINGS_KEY class PaymentsSettings(BaseCustomSettings, MixinServiceSettings): diff --git a/services/web/server/src/simcore_service_webserver/products/_api.py b/services/web/server/src/simcore_service_webserver/products/_api.py deleted file mode 100644 index 5b7a3532ea0..00000000000 --- a/services/web/server/src/simcore_service_webserver/products/_api.py +++ /dev/null @@ -1,188 +0,0 @@ -from decimal import Decimal -from pathlib import Path -from typing import Any, cast - -import aiofiles -from aiohttp import web -from models_library.products import CreditResultGet, ProductName, ProductStripeInfoGet -from simcore_postgres_database.utils_products_prices import ProductPriceInfo - -from .._constants import APP_PRODUCTS_KEY, RQ_PRODUCT_KEY -from .._resources import webserver_resources -from ._db import ProductRepository -from ._events import APP_PRODUCTS_TEMPLATES_DIR_KEY -from ._model import Product -from .errors import ( - BelowMinimumPaymentError, - ProductNotFoundError, - ProductPriceNotDefinedError, -) - - -def get_product_name(request: web.Request) -> str: - """Returns product name in request but might be undefined""" - product_name: str = request[RQ_PRODUCT_KEY] - 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 = get_product(request.app, product_name=product_name) - return current_product - - -def list_products(app: web.Application) -> list[Product]: - products: list[Product] = list(app[APP_PRODUCTS_KEY].values()) - return products - - -async def list_products_names(app: web.Application) -> list[ProductName]: - repo = ProductRepository.create_from_app(app) - names: list[ProductName] = await repo.list_products_names() - return names - - -async def get_current_product_credit_price_info( - request: web.Request, -) -> ProductPriceInfo | None: - """Gets latest credit price for this product. - - NOTE: Contrary to other product api functions (e.g. get_current_product) this function - gets the latest update from the database. Otherwise, products are loaded - on startup and cached therefore in those cases would require a restart - of the service for the latest changes to take effect. - """ - current_product_name = get_product_name(request) - repo = ProductRepository.create_from_request(request) - return cast( # mypy: not sure why - ProductPriceInfo | None, - await repo.get_product_latest_price_info_or_none(current_product_name), - ) - - -async def get_product_ui( - repo: ProductRepository, product_name: ProductName -) -> dict[str, Any]: - ui = await repo.get_product_ui(product_name=product_name) - if ui is not None: - return ui - - raise ProductNotFoundError(product_name=product_name) - - -async def get_credit_amount( - app: web.Application, - *, - dollar_amount: Decimal, - product_name: ProductName, -) -> CreditResultGet: - """For provided dollars and product gets credit amount. - - NOTE: Contrary to other product api functions (e.g. get_current_product) this function - gets the latest update from the database. Otherwise, products are loaded - on startup and cached therefore in those cases would require a restart - of the service for the latest changes to take effect. - - Raises: - ProductPriceNotDefinedError - BelowMinimumPaymentError - - """ - repo = ProductRepository.create_from_app(app) - price_info = await repo.get_product_latest_price_info_or_none(product_name) - if price_info is None or not price_info.usd_per_credit: - # '0 or None' should raise - raise ProductPriceNotDefinedError( - reason=f"Product {product_name} usd_per_credit is either not defined or zero" - ) - - if dollar_amount < price_info.min_payment_amount_usd: - raise BelowMinimumPaymentError( - amount_usd=dollar_amount, - min_payment_amount_usd=price_info.min_payment_amount_usd, - ) - - credit_amount = dollar_amount / price_info.usd_per_credit - return CreditResultGet(product_name=product_name, credit_amount=credit_amount) - - -async def get_product_stripe_info( - app: web.Application, *, product_name: ProductName -) -> ProductStripeInfoGet: - repo = ProductRepository.create_from_app(app) - product_stripe_info = await repo.get_product_stripe_info(product_name) - if ( - not product_stripe_info - or "missing!!" in product_stripe_info.stripe_price_id - or "missing!!" in product_stripe_info.stripe_tax_rate_id - ): - msg = f"Missing product stripe for product {product_name}" - raise ValueError(msg) - return cast(ProductStripeInfoGet, product_stripe_info) # mypy: not sure why - - -# -# helpers for get_product_template_path -# - - -def _themed(dirname: str, template: str) -> Path: - path: Path = webserver_resources.get_path(f"{Path(dirname) / template}") - return path - - -async def _get_content(request: web.Request, template_name: str): - repo = ProductRepository.create_from_request(request) - content = await repo.get_template_content(template_name) - if not content: - msg = f"Missing template {template_name} for product" - raise ValueError(msg) - return content - - -def _safe_get_current_product(request: web.Request) -> Product | None: - try: - product: Product = get_current_product(request) - return product - except KeyError: - return None - - -async def get_product_template_path(request: web.Request, filename: str) -> Path: - if product := _safe_get_current_product(request): - if template_name := product.get_template_name_for(filename): - template_dir: Path = request.app[APP_PRODUCTS_TEMPLATES_DIR_KEY] - template_path = template_dir / template_name - if not template_path.exists(): - # cache - content = await _get_content(request, template_name) - try: - async with aiofiles.open(template_path, "wt") as fh: - await fh.write(content) - except Exception: - # fails to write - if template_path.exists(): - template_path.unlink() - raise - - return template_path - - # check static resources under templates/ - if ( - template_path := _themed(f"templates/{product.name}", filename) - ) and template_path.exists(): - return template_path - - # If no product or template for product defined, we fall back to common templates - common_template = _themed("templates/common", filename) - if not common_template.exists(): - msg = f"{filename} is not part of the templates/common" - raise ValueError(msg) - - return common_template diff --git a/services/web/server/src/simcore_service_webserver/products/_model.py b/services/web/server/src/simcore_service_webserver/products/_model.py deleted file mode 100644 index ef4b7f9498c..00000000000 --- a/services/web/server/src/simcore_service_webserver/products/_model.py +++ /dev/null @@ -1,289 +0,0 @@ -import logging -import re -import string -from typing import ( # noqa: UP035 # pydantic does not validate with re.Pattern - Annotated, - Any, -) - -from models_library.basic_regex import ( - 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 -from pydantic import ( - BaseModel, - BeforeValidator, - ConfigDict, - Field, - PositiveInt, - field_serializer, - field_validator, -) -from simcore_postgres_database.models.products import ( - EmailFeedback, - Forum, - IssueTracker, - Manual, - ProductLoginSettingsDict, - Vendor, - WebFeedback, -) -from sqlalchemy import Column - -from ..db.models import products -from ..statics._constants import FRONTEND_APPS_AVAILABLE - -_logger = logging.getLogger(__name__) - - -class Product(BaseModel): - """Model used to parse a row of pg product's table - - The info in this model is static and read-only - - SEE descriptions in packages/postgres-database/src/simcore_postgres_database/models/products.py - """ - - name: ProductName = Field(pattern=PUBLIC_VARIABLE_NAME_RE, validate_default=True) - - display_name: Annotated[str, Field(..., description="Long display name")] - short_name: str | None = Field( - None, - pattern=re.compile(TWILIO_ALPHANUMERIC_SENDER_ID_RE), - min_length=2, - max_length=11, - description="Short display name for SMS", - ) - - host_regex: Annotated[re.Pattern, BeforeValidator(str.strip)] = Field( - ..., description="Host regex" - ) - - support_email: Annotated[ - LowerCaseEmailStr, - Field( - description="Main support email." - " Other support emails can be defined under 'support' field", - ), - ] - - product_owners_email: Annotated[ - LowerCaseEmailStr | None, - Field(description="Used e.g. for account requests forms"), - ] = None - - twilio_messaging_sid: str | None = Field( - default=None, min_length=34, max_length=34, description="Identifier for SMS" - ) - - vendor: Vendor | None = Field( - None, - description="Vendor information such as company name, address, copyright, ...", - ) - - issues: list[IssueTracker] | None = None - - manuals: list[Manual] | None = None - - support: list[Forum | EmailFeedback | WebFeedback] | None = Field(None) - - login_settings: ProductLoginSettingsDict = Field( - ..., - description="Product customization of login settings. " - "Note that these are NOT the final plugin settings but those are obtained from login.settings.get_plugin_settings", - ) - - registration_email_template: str | None = Field( - None, json_schema_extra={"x_template_name": "registration_email"} - ) - - max_open_studies_per_user: PositiveInt | None = Field( - default=None, - description="Limits the number of studies a user may have open concurently (disabled if NULL)", - ) - - group_id: int | None = Field( - default=None, description="Groups associated to this product" - ) - - is_payment_enabled: bool = Field( - default=False, - 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.", - ) - - min_payment_amount_usd: NonNegativeDecimal | None = Field( - default=None, - description="Price of the credits in this product given in credit/USD. None for free product.", - ) - - @field_validator("*", mode="before") - @classmethod - def _parse_empty_string_as_null(cls, v): - """Safe measure: database entries are sometimes left blank instead of null""" - if isinstance(v, str) and len(v.strip()) == 0: - return None - return v - - @field_validator("name", mode="before") - @classmethod - def _validate_name(cls, v): - if v not in FRONTEND_APPS_AVAILABLE: - msg = f"{v} is not in available front-end apps {FRONTEND_APPS_AVAILABLE}" - raise ValueError(msg) - return v - - @field_serializer("issues", "vendor") - @staticmethod - def _preserve_snake_case(v: Any) -> Any: - return v - - @property - def twilio_alpha_numeric_sender_id(self) -> str: - return self.short_name or self.display_name.replace(string.punctuation, "")[:11] - - model_config = ConfigDict( - alias_generator=snake_to_camel, - populate_by_name=True, - str_strip_whitespace=True, - frozen=True, - from_attributes=True, - extra="ignore", - json_schema_extra={ - "examples": [ - { - # fake mandatory - "name": "osparc", - "host_regex": r"([\.-]{0,1}osparc[\.-])", - "twilio_messaging_sid": "1" * 34, - "registration_email_template": "osparc_registration_email", - "login_settings": { - "LOGIN_2FA_REQUIRED": False, - }, - # defaults from sqlalchemy table - **{ - str(c.name): c.server_default.arg # type: ignore[union-attr] - for c in products.columns - if isinstance(c, Column) - and c.server_default - and isinstance(c.server_default.arg, str) # type: ignore[union-attr] - }, - }, - # Example of data in the dabase with a url set with blanks - { - "name": "tis", - "display_name": "TI PT", - "short_name": "TIPI", - "host_regex": r"(^tis[\.-])|(^ti-solutions\.)|(^ti-plan\.)", - "support_email": "support@foo.com", - "manual_url": "https://foo.com", - "issues_login_url": None, - "issues_new_url": "https://foo.com/new", - "feedback_form_url": "", # <-- blanks - "login_settings": { - "LOGIN_2FA_REQUIRED": False, - }, - }, - # full example - { - "name": "osparc", - "display_name": "o²S²PARC FOO", - "short_name": "osparcf", - "host_regex": "([\\.-]{0,1}osparcf[\\.-])", - "support_email": "foo@osparcf.io", - "vendor": { - "url": "https://acme.com", - "license_url": "https://acme.com/license", - "invitation_form": True, - "name": "ACME", - "copyright": "© ACME correcaminos", - }, - "issues": [ - { - "label": "github", - "login_url": "https://github.com/ITISFoundation/osparc-simcore", - "new_url": "https://github.com/ITISFoundation/osparc-simcore/issues/new/choose", - }, - { - "label": "fogbugz", - "login_url": "https://fogbugz.com/login", - "new_url": "https://fogbugz.com/new?project=123", - }, - ], - "manuals": [ - {"url": "doc.acme.com", "label": "main"}, - {"url": "yet-another-manual.acme.com", "label": "z43"}, - ], - "support": [ - { - "url": "forum.acme.com", - "kind": "forum", - "label": "forum", - }, - { - "kind": "email", - "email": "more-support@acme.com", - "label": "email", - }, - { - "url": "support.acme.com", - "kind": "web", - "label": "web-form", - }, - ], - "login_settings": { - "LOGIN_2FA_REQUIRED": False, - }, - "group_id": 12345, - "is_payment_enabled": False, - }, - ] - }, - ) - - # helpers ---- - - def to_statics(self) -> dict[str, Any]: - """ - Selects **public** fields from product's info - and prefixes it with its name to produce - items for statics.json (reachable by front-end) - """ - - # SECURITY WARNING: do not expose sensitive information here - # keys will be named as e.g. displayName, supportEmail, ... - return self.model_dump( - include={ - "display_name": True, - "support_email": True, - "vendor": True, - "issues": True, - "manuals": True, - "support": True, - "is_payment_enabled": True, - "is_dynamic_services_telemetry_enabled": True, - }, - exclude_none=True, - exclude_unset=True, - by_alias=True, - ) - - def get_template_name_for(self, filename: str) -> str | None: - """Checks for field marked with 'x_template_name' that fits the argument""" - template_name = filename.removesuffix(".jinja2") - for name, field in self.model_fields.items(): - if ( - field.json_schema_extra - and field.json_schema_extra.get("x_template_name") == template_name # type: ignore[union-attr] - ): - template_name_attribute: str = getattr(self, name) - return template_name_attribute - return None diff --git a/services/web/server/src/simcore_service_webserver/products/_models.py b/services/web/server/src/simcore_service_webserver/products/_models.py new file mode 100644 index 00000000000..22caaa8408f --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/products/_models.py @@ -0,0 +1,313 @@ +import logging +import re +import string +from typing import Annotated, Any + +from models_library.basic_regex import ( + 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 +from pydantic import ( + BaseModel, + BeforeValidator, + ConfigDict, + Field, + PositiveInt, + field_serializer, + field_validator, +) +from pydantic.config import JsonDict +from simcore_postgres_database.models.products import ( + EmailFeedback, + Forum, + IssueTracker, + Manual, + ProductLoginSettingsDict, + Vendor, + WebFeedback, + products, +) + +from ..constants import FRONTEND_APPS_AVAILABLE + +_logger = logging.getLogger(__name__) + + +class Product(BaseModel): + """Model used to parse a row of pg product's table + + The info in this model is static and read-only + + SEE descriptions in packages/postgres-database/src/simcore_postgres_database/models/products.py + """ + + name: Annotated[ + ProductName, + Field(pattern=PUBLIC_VARIABLE_NAME_RE, validate_default=True), + ] + + display_name: Annotated[str, Field(..., description="Long display name")] + short_name: Annotated[ + str | None, + Field( + None, + pattern=re.compile(TWILIO_ALPHANUMERIC_SENDER_ID_RE), + min_length=2, + max_length=11, + description="Short display name for SMS", + ), + ] + + host_regex: Annotated[ + re.Pattern, BeforeValidator(str.strip), Field(..., description="Host regex") + ] + + support_email: Annotated[ + LowerCaseEmailStr, + Field( + description="Main support email." + " Other support emails can be defined under 'support' field", + ), + ] + + product_owners_email: Annotated[ + LowerCaseEmailStr | None, + Field(description="Used e.g. for account requests forms"), + ] = None + + twilio_messaging_sid: Annotated[ + str | None, + Field(min_length=34, max_length=34, description="Identifier for SMS"), + ] = None + + vendor: Annotated[ + Vendor | None, + Field( + description="Vendor information such as company name, address, copyright, ...", + ), + ] = None + + issues: list[IssueTracker] | None = None + + manuals: list[Manual] | None = None + + support: list[Forum | EmailFeedback | WebFeedback] | None = Field(None) + + login_settings: Annotated[ + ProductLoginSettingsDict, + Field( + description="Product customization of login settings. " + "Note that these are NOT the final plugin settings but those are obtained from login.settings.get_plugin_settings", + ), + ] + + registration_email_template: Annotated[ + str | None, Field(json_schema_extra={"x_template_name": "registration_email"}) + ] = None + + max_open_studies_per_user: Annotated[ + PositiveInt | None, + Field( + description="Limits the number of studies a user may have open concurently (disabled if NULL)", + ), + ] = None + + group_id: Annotated[ + int | None, Field(description="Groups associated to this product") + ] = None + + is_payment_enabled: Annotated[ + bool, + Field( + description="True if this product offers credits", + ), + ] = False + + credits_per_usd: Annotated[ + NonNegativeDecimal | None, + Field( + description="Price of the credits in this product given in credit/USD. None for free product.", + ), + ] = None + + min_payment_amount_usd: Annotated[ + NonNegativeDecimal | None, + Field( + description="Price of the credits in this product given in credit/USD. None for free product.", + ), + ] = None + + ## Guarantees when loaded from a database --------------- + + @field_validator("*", mode="before") + @classmethod + def _parse_empty_string_as_null(cls, v): + """Safe measure: database entries are sometimes left blank instead of null""" + if isinstance(v, str) and len(v.strip()) == 0: + return None + return v + + @field_validator("name", mode="before") + @classmethod + def _check_is_valid_product_name(cls, v): + if v not in FRONTEND_APPS_AVAILABLE: + msg = f"{v} is not in available front-end apps {FRONTEND_APPS_AVAILABLE}" + raise ValueError(msg) + return v + + @field_serializer("issues", "vendor") + @staticmethod + def _preserve_snake_case(v: Any) -> Any: + return v + + @property + def twilio_alpha_numeric_sender_id(self) -> str: + return self.short_name or self.display_name.replace(string.punctuation, "")[:11] + + @staticmethod + def _update_json_schema_extra(schema: JsonDict) -> None: + from sqlalchemy import Column + + schema.update( + { + "examples": [ + { + # fake mandatory + "name": "osparc", + "host_regex": r"([\.-]{0,1}osparc[\.-])", + "twilio_messaging_sid": "1" * 34, + "registration_email_template": "osparc_registration_email", + "login_settings": { + "LOGIN_2FA_REQUIRED": False, + }, + # defaults from sqlalchemy table + **{ + str(c.name): c.server_default.arg # type: ignore[union-attr] + for c in products.columns + if isinstance(c, Column) + and c.server_default + and isinstance(c.server_default.arg, str) # type: ignore[union-attr] + }, + }, + # Example of data in the dabase with a url set with blanks + { + "name": "tis", + "display_name": "TI PT", + "short_name": "TIPI", + "host_regex": r"(^tis[\.-])|(^ti-solutions\.)|(^ti-plan\.)", + "support_email": "support@foo.com", + "manual_url": "https://foo.com", + "issues_login_url": None, + "issues_new_url": "https://foo.com/new", + "feedback_form_url": "", # <-- blanks + "login_settings": { + "LOGIN_2FA_REQUIRED": False, + }, + }, + # Full example + { + "name": "osparc", + "display_name": "o²S²PARC FOO", + "short_name": "osparcf", + "host_regex": "([\\.-]{0,1}osparcf[\\.-])", + "support_email": "foo@osparcf.io", + "vendor": { + "url": "https://acme.com", + "license_url": "https://acme.com/license", + "invitation_form": True, + "name": "ACME", + "copyright": "© ACME correcaminos", + }, + "issues": [ + { + "label": "github", + "login_url": "https://github.com/ITISFoundation/osparc-simcore", + "new_url": "https://github.com/ITISFoundation/osparc-simcore/issues/new/choose", + }, + { + "label": "fogbugz", + "login_url": "https://fogbugz.com/login", + "new_url": "https://fogbugz.com/new?project=123", + }, + ], + "manuals": [ + {"url": "doc.acme.com", "label": "main"}, + {"url": "yet-another-manual.acme.com", "label": "z43"}, + ], + "support": [ + { + "url": "forum.acme.com", + "kind": "forum", + "label": "forum", + }, + { + "kind": "email", + "email": "more-support@acme.com", + "label": "email", + }, + { + "url": "support.acme.com", + "kind": "web", + "label": "web-form", + }, + ], + "login_settings": { + "LOGIN_2FA_REQUIRED": False, + }, + "group_id": 12345, + "is_payment_enabled": False, + }, + ] + }, + ) + + model_config = ConfigDict( + alias_generator=snake_to_camel, + populate_by_name=True, + str_strip_whitespace=True, + frozen=True, + from_attributes=True, + extra="ignore", + json_schema_extra=_update_json_schema_extra, + ) + + def to_statics(self) -> dict[str, Any]: + """ + Selects **public** fields from product's info + and prefixes it with its name to produce + items for statics.json (reachable by front-end) + """ + + # SECURITY WARNING: do not expose sensitive information here + # keys will be named as e.g. displayName, supportEmail, ... + return self.model_dump( + include={ + "display_name": True, + "support_email": True, + "vendor": True, + "issues": True, + "manuals": True, + "support": True, + "is_payment_enabled": True, + "is_dynamic_services_telemetry_enabled": True, + }, + exclude_none=True, + exclude_unset=True, + by_alias=True, + ) + + def get_template_name_for(self, filename: str) -> str | None: + """Checks for field marked with 'x_template_name' that fits the argument""" + template_name = filename.removesuffix(".jinja2") + for name, field in self.model_fields.items(): + if ( + field.json_schema_extra + and field.json_schema_extra.get("x_template_name") == template_name # type: ignore[union-attr] + ): + template_name_attribute: str = getattr(self, name) + return template_name_attribute + return None diff --git a/services/web/server/src/simcore_service_webserver/products/_db.py b/services/web/server/src/simcore_service_webserver/products/_repository.py similarity index 98% rename from services/web/server/src/simcore_service_webserver/products/_db.py rename to services/web/server/src/simcore_service_webserver/products/_repository.py index 311d90bba06..7a45e7ebb39 100644 --- a/services/web/server/src/simcore_service_webserver/products/_db.py +++ b/services/web/server/src/simcore_service_webserver/products/_repository.py @@ -1,6 +1,7 @@ import logging +from collections.abc import AsyncIterator from decimal import Decimal -from typing import Any, AsyncIterator, NamedTuple +from typing import Any, NamedTuple import sqlalchemy as sa from aiopg.sa.connection import SAConnection @@ -16,7 +17,7 @@ from ..db.base_repository import BaseRepository from ..db.models import products -from ._model import Product +from .models import Product _logger = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/products/_handlers.py b/services/web/server/src/simcore_service_webserver/products/_rest.py similarity index 53% rename from services/web/server/src/simcore_service_webserver/products/_handlers.py rename to services/web/server/src/simcore_service_webserver/products/_rest.py index c05bda185a2..621eca41798 100644 --- a/services/web/server/src/simcore_service_webserver/products/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/products/_rest.py @@ -1,27 +1,21 @@ import logging -from typing import Literal from aiohttp import web from models_library.api_schemas_webserver.product import ( - GetCreditPrice, + CreditPriceGet, ProductGet, ProductUIGet, ) -from models_library.basic_types import IDStr -from models_library.rest_base import RequestParameters, StrictRequestParameters -from models_library.users import UserID -from pydantic import Field from servicelib.aiohttp.requests_validation import parse_request_path_parameters_as -from servicelib.request_keys import RQT_USERID_KEY -from simcore_service_webserver.products._db import ProductRepository -from .._constants import RQ_PRODUCT_KEY from .._meta import API_VTAG as VTAG from ..login.decorators import login_required from ..security.decorators import permission_required from ..utils_aiohttp import envelope_json_response -from . import _api, api -from ._model import Product +from . import _service, products_web +from ._repository import ProductRepository +from ._rest_schemas import ProductsRequestContext, ProductsRequestParams +from .models import Product routes = web.RouteTableDef() @@ -29,19 +23,14 @@ _logger = logging.getLogger(__name__) -class _ProductsRequestContext(RequestParameters): - user_id: UserID = Field(..., alias=RQT_USERID_KEY) # type: ignore[literal-required] - product_name: str = Field(..., alias=RQ_PRODUCT_KEY) # type: ignore[literal-required] - - @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): - req_ctx = _ProductsRequestContext.model_validate(request) - price_info = await _api.get_current_product_credit_price_info(request) + req_ctx = ProductsRequestContext.model_validate(request) + price_info = await products_web.get_current_product_credit_price_info(request) - credit_price = GetCreditPrice( + credit_price = CreditPriceGet( product_name=req_ctx.product_name, usd_per_credit=price_info.usd_per_credit if price_info else None, min_payment_amount_usd=( @@ -53,16 +42,12 @@ async def _get_current_product_price(request: web.Request): return envelope_json_response(credit_price) -class _ProductsRequestParams(StrictRequestParameters): - product_name: IDStr | Literal["current"] - - @routes.get(f"/{VTAG}/products/{{product_name}}", name="get_product") @login_required @permission_required("product.details.*") async def _get_product(request: web.Request): - req_ctx = _ProductsRequestContext.model_validate(request) - path_params = parse_request_path_parameters_as(_ProductsRequestParams, request) + req_ctx = ProductsRequestContext.model_validate(request) + path_params = parse_request_path_parameters_as(ProductsRequestParams, request) if path_params.product_name == "current": product_name = req_ctx.product_name @@ -70,7 +55,7 @@ async def _get_product(request: web.Request): product_name = path_params.product_name try: - product: Product = api.get_product(request.app, product_name=product_name) + product: Product = _service.get_product(request.app, product_name=product_name) except KeyError as err: raise web.HTTPNotFound(reason=f"{product_name=} not found") from err @@ -84,32 +69,12 @@ async def _get_product(request: web.Request): @login_required @permission_required("product.ui.read") async def _get_current_product_ui(request: web.Request): - req_ctx = _ProductsRequestContext.model_validate(request) + req_ctx = ProductsRequestContext.model_validate(request) product_name = req_ctx.product_name - ui = await api.get_product_ui( + ui = await _service.get_product_ui( ProductRepository.create_from_request(request), product_name=product_name ) data = ProductUIGet(product_name=product_name, ui=ui) return envelope_json_response(data) - - -class _ProductTemplateParams(_ProductsRequestParams): - template_id: IDStr - - -@routes.put( - f"/{VTAG}/products/{{product_name}}/templates/{{template_id}}", - name="update_product_template", -) -@login_required -@permission_required("product.details.*") -async def update_product_template(request: web.Request): - req_ctx = _ProductsRequestContext.model_validate(request) - path_params = parse_request_path_parameters_as(_ProductTemplateParams, request) - - assert req_ctx # nosec - assert path_params # nosec - - raise NotImplementedError diff --git a/services/web/server/src/simcore_service_webserver/products/_rest_schemas.py b/services/web/server/src/simcore_service_webserver/products/_rest_schemas.py new file mode 100644 index 00000000000..5aa0938bba0 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/products/_rest_schemas.py @@ -0,0 +1,26 @@ +import logging +from typing import Annotated, Literal + +from aiohttp import web +from models_library.basic_types import IDStr +from models_library.products import ProductName +from models_library.rest_base import RequestParameters, StrictRequestParameters +from models_library.users import UserID +from pydantic import Field +from servicelib.request_keys import RQT_USERID_KEY + +from ..constants import RQ_PRODUCT_KEY + +routes = web.RouteTableDef() + + +_logger = logging.getLogger(__name__) + + +class ProductsRequestContext(RequestParameters): + user_id: Annotated[UserID, Field(alias=RQT_USERID_KEY)] + product_name: Annotated[ProductName, Field(..., alias=RQ_PRODUCT_KEY)] + + +class ProductsRequestParams(StrictRequestParameters): + product_name: IDStr | Literal["current"] diff --git a/services/web/server/src/simcore_service_webserver/products/_rpc.py b/services/web/server/src/simcore_service_webserver/products/_rpc.py index 4a4ee46a655..b3e0329f1a8 100644 --- a/services/web/server/src/simcore_service_webserver/products/_rpc.py +++ b/services/web/server/src/simcore_service_webserver/products/_rpc.py @@ -6,7 +6,7 @@ from servicelib.rabbitmq import RPCRouter from ..rabbitmq import get_rabbitmq_rpc_server -from . import _api +from . import _service router = RPCRouter() @@ -18,7 +18,7 @@ async def get_credit_amount( dollar_amount: Decimal, product_name: ProductName, ) -> CreditResultGet: - credit_result_get: CreditResultGet = await _api.get_credit_amount( + credit_result_get: CreditResultGet = await _service.get_credit_amount( app, dollar_amount=dollar_amount, product_name=product_name ) return credit_result_get diff --git a/services/web/server/src/simcore_service_webserver/products/_service.py b/services/web/server/src/simcore_service_webserver/products/_service.py new file mode 100644 index 00000000000..b5c17669e28 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/products/_service.py @@ -0,0 +1,111 @@ +from decimal import Decimal +from typing import Any, cast + +from aiohttp import web +from models_library.products import CreditResultGet, ProductName, ProductStripeInfoGet +from simcore_postgres_database.utils_products_prices import ProductPriceInfo + +from ..constants import APP_PRODUCTS_KEY +from ._repository import ProductRepository +from .errors import ( + BelowMinimumPaymentError, + ProductNotFoundError, + ProductPriceNotDefinedError, + ProductTemplateNotFoundError, +) +from .models import Product + + +def get_product(app: web.Application, product_name: ProductName) -> Product: + product: Product = app[APP_PRODUCTS_KEY][product_name] + return product + + +def list_products(app: web.Application) -> list[Product]: + products: list[Product] = list(app[APP_PRODUCTS_KEY].values()) + return products + + +async def list_products_names(app: web.Application) -> list[ProductName]: + repo = ProductRepository.create_from_app(app) + names: list[ProductName] = await repo.list_products_names() + return names + + +async def get_credit_price_info( + app: web.Application, product_name: ProductName +) -> ProductPriceInfo | None: + repo = ProductRepository.create_from_app(app) + return cast( # mypy: not sure why + ProductPriceInfo | None, + await repo.get_product_latest_price_info_or_none(product_name), + ) + + +async def get_product_ui( + repo: ProductRepository, product_name: ProductName +) -> dict[str, Any]: + ui = await repo.get_product_ui(product_name=product_name) + if ui is not None: + return ui + + raise ProductNotFoundError(product_name=product_name) + + +async def get_credit_amount( + app: web.Application, + *, + dollar_amount: Decimal, + product_name: ProductName, +) -> CreditResultGet: + """For provided dollars and product gets credit amount. + + NOTE: Contrary to other product api functions (e.g. get_current_product) this function + gets the latest update from the database. Otherwise, products are loaded + on startup and cached therefore in those cases would require a restart + of the service for the latest changes to take effect. + + Raises: + ProductPriceNotDefinedError + BelowMinimumPaymentError + + """ + repo = ProductRepository.create_from_app(app) + price_info = await repo.get_product_latest_price_info_or_none(product_name) + if price_info is None or not price_info.usd_per_credit: + # '0 or None' should raise + raise ProductPriceNotDefinedError( + reason=f"Product {product_name} usd_per_credit is either not defined or zero" + ) + + if dollar_amount < price_info.min_payment_amount_usd: + raise BelowMinimumPaymentError( + amount_usd=dollar_amount, + min_payment_amount_usd=price_info.min_payment_amount_usd, + ) + + credit_amount = dollar_amount / price_info.usd_per_credit + return CreditResultGet(product_name=product_name, credit_amount=credit_amount) + + +async def get_product_stripe_info( + app: web.Application, *, product_name: ProductName +) -> ProductStripeInfoGet: + repo = ProductRepository.create_from_app(app) + product_stripe_info = await repo.get_product_stripe_info(product_name) + if ( + not product_stripe_info + or "missing!!" in product_stripe_info.stripe_price_id + or "missing!!" in product_stripe_info.stripe_tax_rate_id + ): + msg = f"Missing product stripe for product {product_name}" + raise ValueError(msg) + return cast(ProductStripeInfoGet, product_stripe_info) # mypy: not sure why + + +async def get_template_content(app: web.Application, *, template_name: str): + repo = ProductRepository.create_from_app(app) + content = await repo.get_template_content(template_name) + if not content: + raise ProductTemplateNotFoundError(template_name=template_name) + return content diff --git a/services/web/server/src/simcore_service_webserver/products/_events.py b/services/web/server/src/simcore_service_webserver/products/_web_events.py similarity index 94% rename from services/web/server/src/simcore_service_webserver/products/_events.py rename to services/web/server/src/simcore_service_webserver/products/_web_events.py index 836e43a902f..5f14cafca88 100644 --- a/services/web/server/src/simcore_service_webserver/products/_events.py +++ b/services/web/server/src/simcore_service_webserver/products/_web_events.py @@ -13,11 +13,10 @@ get_or_create_product_group, ) -from .._constants import APP_PRODUCTS_KEY +from ..constants import APP_PRODUCTS_KEY, FRONTEND_APP_DEFAULT, FRONTEND_APPS_AVAILABLE from ..db.plugin import get_database_engine -from ..statics._constants import FRONTEND_APP_DEFAULT, FRONTEND_APPS_AVAILABLE -from ._db import get_product_payment_fields, iter_products -from ._model import Product +from ._repository import get_product_payment_fields, iter_products +from .models import Product _logger = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/products/_web_helpers.py b/services/web/server/src/simcore_service_webserver/products/_web_helpers.py new file mode 100644 index 00000000000..a1990aeb213 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/products/_web_helpers.py @@ -0,0 +1,107 @@ +from pathlib import Path + +import aiofiles +from aiohttp import web +from models_library.products import ProductName +from simcore_postgres_database.utils_products_prices import ProductPriceInfo + +from .._resources import webserver_resources +from ..constants import RQ_PRODUCT_KEY +from . import _service +from ._web_events import APP_PRODUCTS_TEMPLATES_DIR_KEY +from .models import Product + + +def get_product_name(request: web.Request) -> str: + """Returns product name in request but might be undefined""" + product_name: str = request[RQ_PRODUCT_KEY] + return product_name + + +def get_current_product(request: web.Request) -> Product: + """Returns product associated to current request""" + product_name: ProductName = get_product_name(request) + current_product: Product = _service.get_product( + request.app, product_name=product_name + ) + return current_product + + +async def get_current_product_credit_price_info( + request: web.Request, +) -> ProductPriceInfo | None: + """Gets latest credit price for this product. + + NOTE: Contrary to other product api functions (e.g. get_current_product) this function + gets the latest update from the database. Otherwise, products are loaded + on startup and cached therefore in those cases would require a restart + of the service for the latest changes to take effect. + """ + current_product_name = get_product_name(request) + return await _service.get_credit_price_info( + request.app, product_name=current_product_name + ) + + +def _themed(dirname: str, template: str) -> Path: + path: Path = webserver_resources.get_path(f"{Path(dirname) / template}") + return path + + +def _get_current_product_or_none(request: web.Request) -> Product | None: + try: + product: Product = get_current_product(request) + return product + except KeyError: + return None + + +async def _get_common_template_path(filename: str) -> Path: + common_template = _themed("templates/common", filename) + if not common_template.exists(): + msg = f"{filename} is not part of the templates/common" + raise ValueError(msg) + return common_template + + +async def _cache_template_content( + request: web.Request, template_path: Path, template_name: str +) -> None: + content = await _service.get_template_content( + request.app, template_name=template_name + ) + try: + async with aiofiles.open(template_path, "w") as fh: + await fh.write(content) + except Exception: + if template_path.exists(): + template_path.unlink() + raise + + +async def _get_product_specific_template_path( + request: web.Request, product: Product, filename: str +) -> Path | None: + if template_name := product.get_template_name_for(filename): + template_dir: Path = request.app[APP_PRODUCTS_TEMPLATES_DIR_KEY] + template_path = template_dir / template_name + if not template_path.exists(): + await _cache_template_content(request, template_path, template_name) + return template_path + + template_path = _themed(f"templates/{product.name}", filename) + if template_path.exists(): + return template_path + + return None + + +async def get_product_template_path(request: web.Request, filename: str) -> Path: + if (product := _get_current_product_or_none(request)) and ( + template_path := await _get_product_specific_template_path( + request, product, filename + ) + ): + return template_path + + return await _get_common_template_path(filename) diff --git a/services/web/server/src/simcore_service_webserver/products/_middlewares.py b/services/web/server/src/simcore_service_webserver/products/_web_middlewares.py similarity index 97% rename from services/web/server/src/simcore_service_webserver/products/_middlewares.py rename to services/web/server/src/simcore_service_webserver/products/_web_middlewares.py index 5a962e25ef7..e82a1a54f5b 100644 --- a/services/web/server/src/simcore_service_webserver/products/_middlewares.py +++ b/services/web/server/src/simcore_service_webserver/products/_web_middlewares.py @@ -6,9 +6,9 @@ from servicelib.aiohttp.typing_extension import Handler from servicelib.rest_constants import X_PRODUCT_NAME_HEADER -from .._constants import APP_PRODUCTS_KEY, RQ_PRODUCT_KEY from .._meta import API_VTAG -from ._model import Product +from ..constants import APP_PRODUCTS_KEY, RQ_PRODUCT_KEY +from .models import Product _logger = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/products/api.py b/services/web/server/src/simcore_service_webserver/products/api.py deleted file mode 100644 index 39487d89c3d..00000000000 --- a/services/web/server/src/simcore_service_webserver/products/api.py +++ /dev/null @@ -1,28 +0,0 @@ -from models_library.products import ProductName - -from ._api import ( - get_credit_amount, - get_current_product, - get_product, - get_product_name, - get_product_stripe_info, - get_product_template_path, - get_product_ui, - list_products, -) -from ._model import Product - -__all__: tuple[str, ...] = ( - "get_credit_amount", - "get_current_product", - "get_product_name", - "get_product_stripe_info", - "get_product_template_path", - "get_product_ui", - "get_product", - "list_products", - "Product", - "ProductName", -) - -# nopycln: file diff --git a/services/web/server/src/simcore_service_webserver/products/errors.py b/services/web/server/src/simcore_service_webserver/products/errors.py index 828d813542d..a4a42542f58 100644 --- a/services/web/server/src/simcore_service_webserver/products/errors.py +++ b/services/web/server/src/simcore_service_webserver/products/errors.py @@ -1,8 +1,3 @@ -""" - API plugin errors -""" - - from ..errors import WebServerBaseError @@ -20,3 +15,7 @@ class ProductPriceNotDefinedError(ProductError): class BelowMinimumPaymentError(ProductError): msg_template = "Payment of {amount_usd} USD is below the required minimum of {min_payment_amount_usd} USD" + + +class ProductTemplateNotFoundError(ProductError): + msg_template = "Missing template {template_name} for product" diff --git a/services/web/server/src/simcore_service_webserver/products/models.py b/services/web/server/src/simcore_service_webserver/products/models.py new file mode 100644 index 00000000000..29204d49c47 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/products/models.py @@ -0,0 +1,9 @@ +from models_library.products import ProductName + +from ._models import Product + +__all__: tuple[str, ...] = ( + "Product", + "ProductName", +) +# nopycln: file diff --git a/services/web/server/src/simcore_service_webserver/products/plugin.py b/services/web/server/src/simcore_service_webserver/products/plugin.py index 70483623419..062032e4221 100644 --- a/services/web/server/src/simcore_service_webserver/products/plugin.py +++ b/services/web/server/src/simcore_service_webserver/products/plugin.py @@ -8,22 +8,11 @@ """ - import logging from aiohttp import web from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup -from .._constants import APP_SETTINGS_KEY -from ..rabbitmq import setup_rabbitmq -from . import _handlers, _invitations_handlers, _rpc -from ._events import ( - auto_create_products_groups, - load_products_on_startup, - setup_product_templates, -) -from ._middlewares import discover_product_middleware - _logger = logging.getLogger(__name__) @@ -35,24 +24,31 @@ logger=_logger, ) def setup_products(app: web.Application): + # + # NOTE: internal import speeds up booting app + # specially if this plugin is not set up to be loaded + # + from ..constants import APP_SETTINGS_KEY + from ..rabbitmq import setup_rabbitmq + from . import _rest, _rpc, _web_events, _web_middlewares + assert app[APP_SETTINGS_KEY].WEBSERVER_PRODUCTS is True # nosec - # middlewares - app.middlewares.append(discover_product_middleware) + # set middlewares + app.middlewares.append(_web_middlewares.discover_product_middleware) - # routes - app.router.add_routes(_handlers.routes) - app.router.add_routes(_invitations_handlers.routes) + # setup rest + app.router.add_routes(_rest.routes) - # rpc api + # setup rpc setup_rabbitmq(app) if app[APP_SETTINGS_KEY].WEBSERVER_RABBITMQ: app.on_startup.append(_rpc.register_rpc_routes_on_startup) - # events + # setup events app.on_startup.append( # NOTE: must go BEFORE load_products_on_startup - auto_create_products_groups + _web_events.auto_create_products_groups ) - app.on_startup.append(load_products_on_startup) - app.cleanup_ctx.append(setup_product_templates) + app.on_startup.append(_web_events.load_products_on_startup) + app.cleanup_ctx.append(_web_events.setup_product_templates) diff --git a/services/web/server/src/simcore_service_webserver/products/products_service.py b/services/web/server/src/simcore_service_webserver/products/products_service.py new file mode 100644 index 00000000000..d21a0e9a27e --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/products/products_service.py @@ -0,0 +1,19 @@ +from ._service import ( + get_credit_amount, + get_product, + get_product_stripe_info, + get_product_ui, + list_products, + list_products_names, +) + +__all__: tuple[str, ...] = ( + "get_credit_amount", + "get_product", + "get_product_stripe_info", + "get_product_ui", + "list_products", + "list_products_names", +) + +# nopycln: file diff --git a/services/web/server/src/simcore_service_webserver/products/products_web.py b/services/web/server/src/simcore_service_webserver/products/products_web.py new file mode 100644 index 00000000000..38ddb1634ec --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/products/products_web.py @@ -0,0 +1,14 @@ +from ._web_helpers import ( + get_current_product, + get_current_product_credit_price_info, + get_product_name, + get_product_template_path, +) + +__all__: tuple[str, ...] = ( + "get_current_product", + "get_current_product_credit_price_info", + "get_product_name", + "get_product_template_path", +) +# nopycln: file diff --git a/services/web/server/src/simcore_service_webserver/projects/_access_rights_api.py b/services/web/server/src/simcore_service_webserver/projects/_access_rights_api.py index 805b46fa65e..05071ca5992 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_access_rights_api.py +++ b/services/web/server/src/simcore_service_webserver/projects/_access_rights_api.py @@ -2,11 +2,11 @@ from models_library.products import ProductName from models_library.projects import ProjectID from models_library.users import UserID -from simcore_service_webserver.projects._db_utils import PermissionStr from ..db.plugin import get_database_engine from ..workspaces.api import get_workspace from ._access_rights_db import get_project_owner +from ._db_utils import PermissionStr from .db import APP_PROJECT_DBAPI, ProjectDBAPI from .exceptions import ProjectInvalidRightsError, ProjectNotFoundError from .models import UserProjectAccessRightsWithWorkspace diff --git a/services/web/server/src/simcore_service_webserver/projects/_crud_api_read.py b/services/web/server/src/simcore_service_webserver/projects/_crud_api_read.py index 24a51ea4142..d83d2265bfe 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_crud_api_read.py +++ b/services/web/server/src/simcore_service_webserver/projects/_crud_api_read.py @@ -18,14 +18,12 @@ from servicelib.utils import logged_gather from simcore_postgres_database.models.projects import ProjectType from simcore_postgres_database.webserver_models import ProjectType as ProjectTypeDB -from simcore_service_webserver.projects._projects_db import ( - batch_get_trashed_by_primary_gid, -) from ..catalog.client import get_services_for_user_in_product from ..folders import _folders_repository from ..workspaces._workspaces_service import check_user_workspace_access from . import projects_service +from ._projects_db import batch_get_trashed_by_primary_gid from .db import ProjectDBAPI from .models import ProjectDict, ProjectTypeAPI diff --git a/services/web/server/src/simcore_service_webserver/projects/_crud_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_crud_handlers.py index f121be0a0ee..6c41e94384a 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_crud_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_crud_handlers.py @@ -32,8 +32,6 @@ X_SIMCORE_USER_AGENT, ) from servicelib.redis import get_project_locked_state -from simcore_service_webserver.projects.models import ProjectDict -from simcore_service_webserver.utils_aiohttp import envelope_json_response from .._meta import API_VTAG as VTAG from ..catalog.client import get_services_for_user_in_product @@ -43,6 +41,7 @@ from ..security.api import check_user_permission from ..security.decorators import permission_required from ..users.api import get_user_fullname +from ..utils_aiohttp import envelope_json_response from . import _crud_api_create, _crud_api_read, _crud_handlers_utils, projects_service from ._common.exceptions_handlers import handle_plugin_requests_exceptions from ._common.models import ProjectPathParams, RequestContext @@ -55,6 +54,7 @@ ProjectsSearchQueryParams, ) from ._permalink_service import update_or_pop_permalink_in_project +from .models import ProjectDict from .utils import get_project_unavailable_services, project_uses_available_services # When the user requests a project with a repo, the working copy might differ from diff --git a/services/web/server/src/simcore_service_webserver/projects/_ports_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_ports_handlers.py index 7fefd71ce0c..45de957d2e7 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_ports_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_ports_handlers.py @@ -24,12 +24,12 @@ parse_request_body_as, parse_request_path_parameters_as, ) -from simcore_service_webserver.utils_aiohttp import envelope_json_response from .._meta import API_VTAG as VTAG from ..login.decorators import login_required from ..projects._access_rights_api import check_user_project_permission from ..security.decorators import permission_required +from ..utils_aiohttp import envelope_json_response from . import _ports_api, projects_service from ._common.exceptions_handlers import handle_plugin_requests_exceptions from ._common.models import ProjectPathParams, RequestContext diff --git a/services/web/server/src/simcore_service_webserver/projects/_states_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_states_handlers.py index ecac998f854..0ccb6be1737 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_states_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_states_handlers.py @@ -25,7 +25,8 @@ from ..director_v2.exceptions import DirectorServiceError from ..login.decorators import login_required from ..notifications import project_logs -from ..products.api import Product, get_current_product +from ..products import products_web +from ..products.models import Product from ..security.decorators import permission_required from ..users import api from ..utils_aiohttp import envelope_json_response @@ -94,7 +95,7 @@ async def open_project(request: web.Request) -> web.Response: product_name=req_ctx.product_name, ) - product: Product = get_current_product(request) + product: Product = products_web.get_current_product(request) if not await projects_service.try_open_project_for_user( req_ctx.user_id, diff --git a/services/web/server/src/simcore_service_webserver/projects/_tags_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_tags_handlers.py index 49ce6650ca0..1c8b11d968d 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_tags_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_tags_handlers.py @@ -7,11 +7,11 @@ from aiohttp import web from models_library.projects import ProjectID from servicelib.request_keys import RQT_USERID_KEY -from simcore_service_webserver.utils_aiohttp import envelope_json_response from .._meta import API_VTAG from ..login.decorators import login_required from ..security.decorators import permission_required +from ..utils_aiohttp import envelope_json_response from . import _tags_api as tags_api from ._common.exceptions_handlers import handle_plugin_requests_exceptions diff --git a/services/web/server/src/simcore_service_webserver/projects/_trash_rest.py b/services/web/server/src/simcore_service_webserver/projects/_trash_rest.py index daa6010d0f4..ec75d337a93 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_trash_rest.py +++ b/services/web/server/src/simcore_service_webserver/projects/_trash_rest.py @@ -15,7 +15,7 @@ to_exceptions_handlers_map, ) from ..login.decorators import get_user_id, login_required -from ..products.api import get_product_name +from ..products import products_web from ..security.decorators import permission_required from . import _trash_service from ._common.exceptions_handlers import handle_plugin_requests_exceptions @@ -51,7 +51,7 @@ @_handle_local_request_exceptions async def trash_project(request: web.Request): user_id = get_user_id(request) - product_name = get_product_name(request) + product_name = products_web.get_product_name(request) path_params = parse_request_path_parameters_as(ProjectPathParams, request) query_params: RemoveQueryParams = parse_request_query_parameters_as( RemoveQueryParams, request @@ -76,7 +76,7 @@ async def trash_project(request: web.Request): @_handle_local_request_exceptions async def untrash_project(request: web.Request): user_id = get_user_id(request) - product_name = get_product_name(request) + product_name = products_web.get_product_name(request) path_params = parse_request_path_parameters_as(ProjectPathParams, request) await _trash_service.untrash_project( diff --git a/services/web/server/src/simcore_service_webserver/projects/_wallets_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_wallets_handlers.py index 79a0e6d0cf3..dfa5d4cb4b1 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_wallets_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_wallets_handlers.py @@ -16,11 +16,11 @@ parse_request_body_as, parse_request_path_parameters_as, ) -from simcore_service_webserver.utils_aiohttp import envelope_json_response from .._meta import API_VTAG from ..login.decorators import login_required from ..security.decorators import permission_required +from ..utils_aiohttp import envelope_json_response from . import _wallets_api as wallets_api from . import projects_service from ._common.exceptions_handlers import handle_plugin_requests_exceptions diff --git a/services/web/server/src/simcore_service_webserver/projects/plugin.py b/services/web/server/src/simcore_service_webserver/projects/plugin.py index 5cba65b8a2b..b563d381021 100644 --- a/services/web/server/src/simcore_service_webserver/projects/plugin.py +++ b/services/web/server/src/simcore_service_webserver/projects/plugin.py @@ -8,7 +8,7 @@ from aiohttp import web from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup -from .._constants import APP_SETTINGS_KEY +from ..constants import APP_SETTINGS_KEY from . import ( _comments_handlers, _crud_handlers, 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 c324a5e41c6..76ba458b3ff 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 @@ -97,8 +97,7 @@ from ..catalog import client as catalog_client from ..director_v2 import api as director_v2_api from ..dynamic_scheduler import api as dynamic_scheduler_api -from ..products import api as products_api -from ..products.api import get_product_name +from ..products import products_web from ..rabbitmq import get_rabbitmq_rpc_client from ..redis import get_redis_lock_manager_client_sdk from ..resource_manager.user_sessions import ( @@ -651,7 +650,7 @@ async def _() -> None: # Get wallet/pricing/hardware information wallet_info, pricing_info, hardware_info = None, None, None - product = products_api.get_current_product(request) + product = products_web.get_current_product(request) app_settings = get_application_settings(request.app) if ( product.is_payment_enabled @@ -967,7 +966,7 @@ async def delete_project_node( assert db # nosec await db.remove_project_node(user_id, project_uuid, NodeID(node_uuid)) # also ensure the project is updated by director-v2 since services - product_name = get_product_name(request) + product_name = products_web.get_product_name(request) await director_v2_api.create_or_update_pipeline( request.app, user_id, project_uuid, product_name ) diff --git a/services/web/server/src/simcore_service_webserver/projects/settings.py b/services/web/server/src/simcore_service_webserver/projects/settings.py index ace29385602..198afae90a9 100644 --- a/services/web/server/src/simcore_service_webserver/projects/settings.py +++ b/services/web/server/src/simcore_service_webserver/projects/settings.py @@ -4,7 +4,7 @@ from pydantic import ByteSize, Field, NonNegativeInt, TypeAdapter from settings_library.base import BaseCustomSettings -from .._constants import APP_SETTINGS_KEY +from ..constants import APP_SETTINGS_KEY class ProjectsSettings(BaseCustomSettings): diff --git a/services/web/server/src/simcore_service_webserver/publications/_handlers.py b/services/web/server/src/simcore_service_webserver/publications/_handlers.py index 2653bba1390..b1ccad24ed2 100644 --- a/services/web/server/src/simcore_service_webserver/publications/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/publications/_handlers.py @@ -14,7 +14,7 @@ from ..login.decorators import login_required from ..login.storage import AsyncpgStorage, get_plugin_storage from ..login.utils_email import AttachmentTuple, send_email_from_template, themed -from ..products.api import get_current_product +from ..products import products_web _logger = logging.getLogger(__name__) @@ -26,7 +26,7 @@ @routes.post(f"/{VTAG}/publications/service-submission", name="service_submission") @login_required async def service_submission(request: web.Request): - product = get_current_product(request) + product = products_web.get_current_product(request) reader = MultipartReader.from_response(request) # type: ignore[arg-type] # PC, IP Whoever is in charge of this. please have a look. this looks very weird data = None filename = None diff --git a/services/web/server/src/simcore_service_webserver/rabbitmq_settings.py b/services/web/server/src/simcore_service_webserver/rabbitmq_settings.py index a05929f1c1b..79a85b69d5c 100644 --- a/services/web/server/src/simcore_service_webserver/rabbitmq_settings.py +++ b/services/web/server/src/simcore_service_webserver/rabbitmq_settings.py @@ -8,7 +8,7 @@ from aiohttp.web import Application from settings_library.rabbit import RabbitSettings -from ._constants import APP_SETTINGS_KEY +from .constants import APP_SETTINGS_KEY def get_plugin_settings(app: Application) -> RabbitSettings: diff --git a/services/web/server/src/simcore_service_webserver/redis.py b/services/web/server/src/simcore_service_webserver/redis.py index 5caebe02c53..cd66a4e004d 100644 --- a/services/web/server/src/simcore_service_webserver/redis.py +++ b/services/web/server/src/simcore_service_webserver/redis.py @@ -6,8 +6,8 @@ from servicelib.redis import RedisClientSDK, RedisClientsManager, RedisManagerDBConfig from settings_library.redis import RedisDatabase, RedisSettings -from ._constants import APP_SETTINGS_KEY from ._meta import APP_NAME +from .constants import APP_SETTINGS_KEY _logger = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/resource_usage/settings.py b/services/web/server/src/simcore_service_webserver/resource_usage/settings.py index 70687177fcb..db1df4b8bca 100644 --- a/services/web/server/src/simcore_service_webserver/resource_usage/settings.py +++ b/services/web/server/src/simcore_service_webserver/resource_usage/settings.py @@ -7,7 +7,7 @@ from aiohttp import web from settings_library.resource_usage_tracker import ResourceUsageTrackerSettings -from .._constants import APP_SETTINGS_KEY +from ..constants import APP_SETTINGS_KEY def get_plugin_settings(app: web.Application) -> ResourceUsageTrackerSettings: diff --git a/services/web/server/src/simcore_service_webserver/rest/_handlers.py b/services/web/server/src/simcore_service_webserver/rest/_handlers.py index b874d441db0..5425d7341e4 100644 --- a/services/web/server/src/simcore_service_webserver/rest/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/rest/_handlers.py @@ -11,10 +11,10 @@ from pydantic import BaseModel from servicelib.aiohttp import status -from .._constants import APP_PUBLIC_CONFIG_PER_PRODUCT, APP_SETTINGS_KEY from .._meta import API_VTAG +from ..constants import APP_PUBLIC_CONFIG_PER_PRODUCT, APP_SETTINGS_KEY from ..login.decorators import login_required -from ..products.api import get_product_name +from ..products import products_web from ..redis import get_redis_scheduled_maintenance_client from ..utils_aiohttp import envelope_json_response from .healthcheck import HealthCheck, HealthCheckError @@ -76,7 +76,7 @@ async def get_config(request: web.Request): """ app_public_config: dict[str, Any] = request.app[APP_SETTINGS_KEY].public_dict() - product_name = get_product_name(request=request) + product_name = products_web.get_product_name(request=request) product_public_config = request.app.get(APP_PUBLIC_CONFIG_PER_PRODUCT, {}).get( product_name, {} ) diff --git a/services/web/server/src/simcore_service_webserver/rest/healthcheck.py b/services/web/server/src/simcore_service_webserver/rest/healthcheck.py index fd4b5045215..dc31678c3ef 100644 --- a/services/web/server/src/simcore_service_webserver/rest/healthcheck.py +++ b/services/web/server/src/simcore_service_webserver/rest/healthcheck.py @@ -55,7 +55,7 @@ TypedDict, ) -from .._constants import APP_SETTINGS_KEY +from ..constants import APP_SETTINGS_KEY _HealthCheckSlot = Callable[[web.Application], Awaitable[None]] diff --git a/services/web/server/src/simcore_service_webserver/rest/settings.py b/services/web/server/src/simcore_service_webserver/rest/settings.py index d061af13d5c..3f3047d7fb0 100644 --- a/services/web/server/src/simcore_service_webserver/rest/settings.py +++ b/services/web/server/src/simcore_service_webserver/rest/settings.py @@ -1,7 +1,7 @@ from aiohttp import web from settings_library.base import BaseCustomSettings -from .._constants import APP_SETTINGS_KEY +from ..constants import APP_SETTINGS_KEY class RestSettings(BaseCustomSettings): diff --git a/services/web/server/src/simcore_service_webserver/scicrunch/settings.py b/services/web/server/src/simcore_service_webserver/scicrunch/settings.py index 0bf88e69b05..e265f4b5323 100644 --- a/services/web/server/src/simcore_service_webserver/scicrunch/settings.py +++ b/services/web/server/src/simcore_service_webserver/scicrunch/settings.py @@ -2,7 +2,7 @@ from pydantic import Field, HttpUrl, SecretStr, TypeAdapter from settings_library.base import BaseCustomSettings -from .._constants import APP_SETTINGS_KEY +from ..constants import APP_SETTINGS_KEY # TODO: read https://www.force11.org/group/resource-identification-initiative SCICRUNCH_DEFAULT_URL = "https://scicrunch.org" diff --git a/services/web/server/src/simcore_service_webserver/session/settings.py b/services/web/server/src/simcore_service_webserver/session/settings.py index 74a7f18f2e9..4e1c99dac68 100644 --- a/services/web/server/src/simcore_service_webserver/session/settings.py +++ b/services/web/server/src/simcore_service_webserver/session/settings.py @@ -7,7 +7,7 @@ from settings_library.base import BaseCustomSettings from settings_library.utils_session import MixinSessionSettings -from .._constants import APP_SETTINGS_KEY +from ..constants import APP_SETTINGS_KEY _MINUTE: Final[int] = 60 # secs diff --git a/services/web/server/src/simcore_service_webserver/socketio/_handlers.py b/services/web/server/src/simcore_service_webserver/socketio/_handlers.py index 078c22e8cf7..85a618f15d1 100644 --- a/services/web/server/src/simcore_service_webserver/socketio/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/socketio/_handlers.py @@ -19,7 +19,8 @@ from ..groups.api import list_user_groups_ids_with_read_access from ..login.decorators import login_required -from ..products.api import Product, get_current_product +from ..products import products_web +from ..products.models import Product from ..resource_manager.user_sessions import managed_resource from ._utils import EnvironDict, SocketID, get_socket_server, register_socketio_handler from .messages import SOCKET_IO_HEARTBEAT_EVENT, send_message_to_user @@ -51,7 +52,7 @@ async def _handler(request: web.Request) -> tuple[UserID, ProductName]: app = request.app user_id = UserID(request.get(RQT_USERID_KEY, _ANONYMOUS_USER_ID)) client_session_id = request.query.get("client_session_id", None) - product: Product = get_current_product(request) + product: Product = products_web.get_current_product(request) _logger.debug( "client %s,%s authenticated", f"{user_id=}", f"{client_session_id=}" diff --git a/services/web/server/src/simcore_service_webserver/socketio/plugin.py b/services/web/server/src/simcore_service_webserver/socketio/plugin.py index 20ceef31053..86d19aceeac 100644 --- a/services/web/server/src/simcore_service_webserver/socketio/plugin.py +++ b/services/web/server/src/simcore_service_webserver/socketio/plugin.py @@ -9,7 +9,7 @@ from aiohttp import web from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup -from .._constants import APP_SETTINGS_KEY +from ..constants import APP_SETTINGS_KEY from ..rabbitmq import setup_rabbitmq from ._observer import setup_socketio_observer_events from .server import setup_socketio_server diff --git a/services/web/server/src/simcore_service_webserver/statics/_constants.py b/services/web/server/src/simcore_service_webserver/statics/_constants.py index ec85d8114a5..72f4b298276 100644 --- a/services/web/server/src/simcore_service_webserver/statics/_constants.py +++ b/services/web/server/src/simcore_service_webserver/statics/_constants.py @@ -1,22 +1,4 @@ -# these are the apps built right now by static-webserver/client - -FRONTEND_APPS_AVAILABLE = frozenset( - { - "osparc", - "tis", - "tiplite", - "s4l", - "s4llite", - "s4lacad", - "s4lengine", - "s4ldesktop", - "s4ldesktopacad", - } -) -FRONTEND_APP_DEFAULT = "osparc" - -assert FRONTEND_APP_DEFAULT in FRONTEND_APPS_AVAILABLE # nosec - +from ..constants import FRONTEND_APP_DEFAULT, FRONTEND_APPS_AVAILABLE STATIC_DIRNAMES = FRONTEND_APPS_AVAILABLE | {"resource", "transpiled"} @@ -24,3 +6,11 @@ APP_FRONTEND_CACHED_STATICS_JSON_KEY = f"{__name__}.cached_statics_json" APP_CLIENTAPPS_SETTINGS_KEY = f"{__file__}.client_apps_settings" + + +__all__: tuple[str, ...] = ( + "FRONTEND_APPS_AVAILABLE", + "FRONTEND_APP_DEFAULT", +) + +# nopycln: file diff --git a/services/web/server/src/simcore_service_webserver/statics/_events.py b/services/web/server/src/simcore_service_webserver/statics/_events.py index b34f7e8948a..3c2d0a58aea 100644 --- a/services/web/server/src/simcore_service_webserver/statics/_events.py +++ b/services/web/server/src/simcore_service_webserver/statics/_events.py @@ -15,9 +15,9 @@ from tenacity.wait import wait_fixed from yarl import URL -from .._constants import APP_PRODUCTS_KEY from ..application_settings import ApplicationSettings, get_application_settings -from ..products.api import Product +from ..constants import APP_PRODUCTS_KEY +from ..products.models import Product from ._constants import ( APP_FRONTEND_CACHED_INDEXES_KEY, APP_FRONTEND_CACHED_STATICS_JSON_KEY, diff --git a/services/web/server/src/simcore_service_webserver/statics/_handlers.py b/services/web/server/src/simcore_service_webserver/statics/_handlers.py index ecda8a0a83e..0f37438e69c 100644 --- a/services/web/server/src/simcore_service_webserver/statics/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/statics/_handlers.py @@ -3,7 +3,7 @@ from aiohttp import web from servicelib.mimetype_constants import MIMETYPE_TEXT_HTML -from ..products.api import get_product_name +from ..products import products_web from ._constants import ( APP_FRONTEND_CACHED_INDEXES_KEY, APP_FRONTEND_CACHED_STATICS_JSON_KEY, @@ -14,7 +14,7 @@ async def get_cached_frontend_index(request: web.Request): - product_name = get_product_name(request) + product_name = products_web.get_product_name(request) assert ( # nosec product_name in FRONTEND_APPS_AVAILABLE @@ -38,7 +38,7 @@ async def get_cached_frontend_index(request: web.Request): async def get_statics_json(request: web.Request): - product_name = get_product_name(request) + product_name = products_web.get_product_name(request) return web.Response( body=request.app[APP_FRONTEND_CACHED_STATICS_JSON_KEY].get(product_name, None), diff --git a/services/web/server/src/simcore_service_webserver/statics/plugin.py b/services/web/server/src/simcore_service_webserver/statics/plugin.py index 4178325851f..07c30033fe8 100644 --- a/services/web/server/src/simcore_service_webserver/statics/plugin.py +++ b/services/web/server/src/simcore_service_webserver/statics/plugin.py @@ -11,7 +11,7 @@ from aiohttp import web from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup -from .._constants import INDEX_RESOURCE_NAME +from ..constants import INDEX_RESOURCE_NAME from ..products.plugin import setup_products from ._events import create_and_cache_statics_json, create_cached_indexes from ._handlers import get_cached_frontend_index, get_statics_json diff --git a/services/web/server/src/simcore_service_webserver/statics/settings.py b/services/web/server/src/simcore_service_webserver/statics/settings.py index 32c3b740220..3915c59a156 100644 --- a/services/web/server/src/simcore_service_webserver/statics/settings.py +++ b/services/web/server/src/simcore_service_webserver/statics/settings.py @@ -13,7 +13,7 @@ TypedDict, ) -from .._constants import APP_SETTINGS_KEY +from ..constants import APP_SETTINGS_KEY class ThirdPartyInfoDict(TypedDict): diff --git a/services/web/server/src/simcore_service_webserver/storage/_exception_handlers.py b/services/web/server/src/simcore_service_webserver/storage/_exception_handlers.py index d6cecb75d9e..4c68394b6a2 100644 --- a/services/web/server/src/simcore_service_webserver/storage/_exception_handlers.py +++ b/services/web/server/src/simcore_service_webserver/storage/_exception_handlers.py @@ -4,7 +4,8 @@ InvalidFileIdentifierError, ) from servicelib.aiohttp import status -from simcore_service_webserver.exception_handling import ( + +from ..exception_handling import ( ExceptionToHttpErrorMap, HttpErrorInfo, exception_handling_decorator, diff --git a/services/web/server/src/simcore_service_webserver/storage/plugin.py b/services/web/server/src/simcore_service_webserver/storage/plugin.py index d1db7c23c57..e0c17eb8b0f 100644 --- a/services/web/server/src/simcore_service_webserver/storage/plugin.py +++ b/services/web/server/src/simcore_service_webserver/storage/plugin.py @@ -7,7 +7,7 @@ from aiohttp import web from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup -from .._constants import APP_SETTINGS_KEY +from ..constants import APP_SETTINGS_KEY from ..rest.plugin import setup_rest from . import _rest diff --git a/services/web/server/src/simcore_service_webserver/storage/settings.py b/services/web/server/src/simcore_service_webserver/storage/settings.py index 04ac00f61c3..38d9befd914 100644 --- a/services/web/server/src/simcore_service_webserver/storage/settings.py +++ b/services/web/server/src/simcore_service_webserver/storage/settings.py @@ -6,7 +6,7 @@ from settings_library.utils_service import DEFAULT_AIOHTTP_PORT, MixinServiceSettings from yarl import URL -from .._constants import APP_SETTINGS_KEY +from ..constants import APP_SETTINGS_KEY class StorageSettings(BaseCustomSettings, MixinServiceSettings): diff --git a/services/web/server/src/simcore_service_webserver/studies_dispatcher/_redirects_handlers.py b/services/web/server/src/simcore_service_webserver/studies_dispatcher/_redirects_handlers.py index 060cea75a4d..859551ed5d0 100644 --- a/services/web/server/src/simcore_service_webserver/studies_dispatcher/_redirects_handlers.py +++ b/services/web/server/src/simcore_service_webserver/studies_dispatcher/_redirects_handlers.py @@ -19,7 +19,7 @@ from servicelib.logging_errors import create_troubleshotting_log_kwargs from ..dynamic_scheduler import api as dynamic_scheduler_api -from ..products.api import get_product_name +from ..products import products_web from ..utils import compose_support_error_msg from ..utils_aiohttp import create_redirect_to_page_response from ._catalog import ValidService, validate_requested_service @@ -205,6 +205,7 @@ def ensure_extension_upper_and_dotless(cls, v): | ServiceQueryParams ) + # # API HANDLERS # @@ -250,7 +251,7 @@ async def get_redirection_to_viewer(request: web.Request): user, viewer, file_params.download_link, - product_name=get_product_name(request), + product_name=products_web.get_product_name(request), ) await dynamic_scheduler_api.update_projects_networks( request.app, project_id=project_id @@ -281,7 +282,7 @@ async def get_redirection_to_viewer(request: web.Request): request.app, user, service_info=_create_service_info_from(valid_service), - product_name=get_product_name(request), + product_name=products_web.get_product_name(request), ) await dynamic_scheduler_api.update_projects_networks( request.app, project_id=project_id @@ -319,7 +320,7 @@ async def get_redirection_to_viewer(request: web.Request): project_thumbnail=get_plugin_settings( app=request.app ).STUDIES_DEFAULT_FILE_THUMBNAIL, - product_name=get_product_name(request), + product_name=products_web.get_product_name(request), ) await dynamic_scheduler_api.update_projects_networks( request.app, project_id=project_id diff --git a/services/web/server/src/simcore_service_webserver/studies_dispatcher/_rest_handlers.py b/services/web/server/src/simcore_service_webserver/studies_dispatcher/_rest_handlers.py index b003ad55963..943893972fe 100644 --- a/services/web/server/src/simcore_service_webserver/studies_dispatcher/_rest_handlers.py +++ b/services/web/server/src/simcore_service_webserver/studies_dispatcher/_rest_handlers.py @@ -19,7 +19,7 @@ from pydantic.networks import HttpUrl from .._meta import API_VTAG -from ..products.api import get_product_name +from ..products import products_web from ..utils_aiohttp import envelope_json_response from ._catalog import ServiceMetaData, iter_latest_product_services from ._core import list_viewers_info @@ -163,7 +163,7 @@ def remove_dot_prefix_from_extension(cls, v): @routes.get(f"/{API_VTAG}/services", name="list_latest_services") async def list_latest_services(request: Request): """Returns a list latest version of services""" - product_name = get_product_name(request) + product_name = products_web.get_product_name(request) services = [] async for service_data in iter_latest_product_services( diff --git a/services/web/server/src/simcore_service_webserver/studies_dispatcher/_studies_access.py b/services/web/server/src/simcore_service_webserver/studies_dispatcher/_studies_access.py index 1dec4c84956..de80153e66e 100644 --- a/services/web/server/src/simcore_service_webserver/studies_dispatcher/_studies_access.py +++ b/services/web/server/src/simcore_service_webserver/studies_dispatcher/_studies_access.py @@ -26,10 +26,10 @@ from servicelib.aiohttp.typing_extension import Handler from servicelib.logging_errors import create_troubleshotting_log_kwargs -from .._constants import INDEX_RESOURCE_NAME +from ..constants import INDEX_RESOURCE_NAME from ..director_v2._core_computations import create_or_update_pipeline from ..dynamic_scheduler import api as dynamic_scheduler_api -from ..products.api import get_current_product, get_product_name +from ..products import products_web from ..projects._groups_db import get_project_group from ..projects.api import check_user_project_permission from ..projects.db import ProjectDBAPI @@ -117,7 +117,7 @@ async def _get_published_template_project( err.debug_message(), ) - support_email = get_current_product(request).support_email + support_email = products_web.get_current_product(request).support_email if only_public_projects: raise RedirectToFrontEndPageError( MSG_PUBLIC_PROJECT_NOT_PUBLISHED.format(support_email=support_email), @@ -185,7 +185,7 @@ async def copy_study_to_account( substitute_parameterized_inputs(project, template_parameters) or project ) # add project model + copy data TODO: guarantee order and atomicity - product_name = get_product_name(request) + product_name = products_web.get_product_name(request) await db.insert_project( project, user["id"], diff --git a/services/web/server/src/simcore_service_webserver/studies_dispatcher/_users.py b/services/web/server/src/simcore_service_webserver/studies_dispatcher/_users.py index 531759b062f..ea7b8fecf6c 100644 --- a/services/web/server/src/simcore_service_webserver/studies_dispatcher/_users.py +++ b/services/web/server/src/simcore_service_webserver/studies_dispatcher/_users.py @@ -29,7 +29,7 @@ from ..groups.api import auto_add_user_to_product_group from ..login.storage import AsyncpgStorage, get_plugin_storage from ..login.utils import ACTIVE, GUEST -from ..products.api import get_product_name +from ..products import products_web from ..redis import get_redis_lock_manager_client from ..security.api import ( check_user_authorized, @@ -103,7 +103,7 @@ async def create_temporary_guest_user(request: web.Request): db: AsyncpgStorage = get_plugin_storage(request.app) redis_locks_client: aioredis.Redis = get_redis_lock_manager_client(request.app) settings: StudiesDispatcherSettings = get_plugin_settings(app=request.app) - product_name = get_product_name(request) + product_name = products_web.get_product_name(request) random_user_name = "".join( secrets.choice(string.ascii_lowercase) for _ in range(10) diff --git a/services/web/server/src/simcore_service_webserver/tags/_rest.py b/services/web/server/src/simcore_service_webserver/tags/_rest.py index 7550c8343ed..ea39edd6c2a 100644 --- a/services/web/server/src/simcore_service_webserver/tags/_rest.py +++ b/services/web/server/src/simcore_service_webserver/tags/_rest.py @@ -8,11 +8,6 @@ TagNotFoundError, TagOperationNotAllowedError, ) -from simcore_service_webserver.tags.errors import ( - InsufficientTagShareAccessError, - ShareTagWithEveryoneNotAllowedError, - ShareTagWithProductGroupNotAllowedError, -) from .._meta import API_VTAG as VTAG from ..exception_handling import ( @@ -25,6 +20,11 @@ from ..security.decorators import permission_required from ..utils_aiohttp import envelope_json_response from . import _service +from .errors import ( + InsufficientTagShareAccessError, + ShareTagWithEveryoneNotAllowedError, + ShareTagWithProductGroupNotAllowedError, +) from .schemas import ( TagCreate, TagGroupCreate, diff --git a/services/web/server/src/simcore_service_webserver/tags/_service.py b/services/web/server/src/simcore_service_webserver/tags/_service.py index be73441f224..0c28c2a462f 100644 --- a/services/web/server/src/simcore_service_webserver/tags/_service.py +++ b/services/web/server/src/simcore_service_webserver/tags/_service.py @@ -11,7 +11,7 @@ from simcore_postgres_database.utils_tags import TagAccessRightsDict, TagsRepo from sqlalchemy.ext.asyncio import AsyncEngine -from ..products.api import list_products +from ..products import products_service from ..users.api import get_user_role from .errors import ( InsufficientTagShareAccessError, @@ -70,7 +70,7 @@ async def delete_tag(app: web.Application, user_id: UserID, tag_id: IdInt): def _is_product_group(app: web.Application, group_id: GroupID): - products = list_products(app) + products = products_service.list_products(app) return any(group_id == p.group_id for p in products) diff --git a/services/web/server/src/simcore_service_webserver/tracing.py b/services/web/server/src/simcore_service_webserver/tracing.py index 23041d95238..d07757106e8 100644 --- a/services/web/server/src/simcore_service_webserver/tracing.py +++ b/services/web/server/src/simcore_service_webserver/tracing.py @@ -5,8 +5,8 @@ from servicelib.aiohttp.tracing import setup_tracing from settings_library.tracing import TracingSettings -from ._constants import APP_SETTINGS_KEY from ._meta import APP_NAME +from .constants import APP_SETTINGS_KEY log = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/trash/_rest.py b/services/web/server/src/simcore_service_webserver/trash/_rest.py index ed68704639a..045593f6218 100644 --- a/services/web/server/src/simcore_service_webserver/trash/_rest.py +++ b/services/web/server/src/simcore_service_webserver/trash/_rest.py @@ -14,7 +14,7 @@ to_exceptions_handlers_map, ) from ..login.decorators import get_user_id, login_required -from ..products.api import get_product_name +from ..products import products_web from ..projects.exceptions import ProjectRunningConflictError, ProjectStoppingError from ..security.decorators import permission_required from . import _service @@ -48,7 +48,7 @@ @_handle_exceptions async def empty_trash(request: web.Request): user_id = get_user_id(request) - product_name = get_product_name(request) + product_name = products_web.get_product_name(request) is_fired = asyncio.Event() diff --git a/services/web/server/src/simcore_service_webserver/trash/_service.py b/services/web/server/src/simcore_service_webserver/trash/_service.py index 42a5f6f0626..8f7dd70d0be 100644 --- a/services/web/server/src/simcore_service_webserver/trash/_service.py +++ b/services/web/server/src/simcore_service_webserver/trash/_service.py @@ -8,9 +8,9 @@ from models_library.users import UserID from servicelib.logging_errors import create_troubleshotting_log_kwargs from servicelib.logging_utils import log_context -from simcore_service_webserver.products import _api as products_service from ..folders import folders_trash_service +from ..products import products_service from ..projects import projects_trash_service from .settings import get_plugin_settings diff --git a/services/web/server/src/simcore_service_webserver/trash/plugin.py b/services/web/server/src/simcore_service_webserver/trash/plugin.py index a4cde641596..977a1c74884 100644 --- a/services/web/server/src/simcore_service_webserver/trash/plugin.py +++ b/services/web/server/src/simcore_service_webserver/trash/plugin.py @@ -8,7 +8,7 @@ from aiohttp import web from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup -from .._constants import APP_SETTINGS_KEY +from ..constants import APP_SETTINGS_KEY from ..folders.plugin import setup_folders from ..projects.plugin import setup_projects from ..workspaces.plugin import setup_workspaces diff --git a/services/web/server/src/simcore_service_webserver/trash/settings.py b/services/web/server/src/simcore_service_webserver/trash/settings.py index 38d4f91fdcb..f51832b9aa7 100644 --- a/services/web/server/src/simcore_service_webserver/trash/settings.py +++ b/services/web/server/src/simcore_service_webserver/trash/settings.py @@ -2,7 +2,7 @@ from pydantic import Field, NonNegativeInt from settings_library.base import BaseCustomSettings -from .._constants import APP_SETTINGS_KEY +from ..constants import APP_SETTINGS_KEY class TrashSettings(BaseCustomSettings): diff --git a/services/web/server/src/simcore_service_webserver/users/_common/schemas.py b/services/web/server/src/simcore_service_webserver/users/_common/schemas.py index 04946e21fcc..a76326182ae 100644 --- a/services/web/server/src/simcore_service_webserver/users/_common/schemas.py +++ b/services/web/server/src/simcore_service_webserver/users/_common/schemas.py @@ -18,7 +18,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from servicelib.request_keys import RQT_USERID_KEY -from ..._constants import RQ_PRODUCT_KEY +from ...constants import RQ_PRODUCT_KEY class UsersRequestContext(BaseModel): diff --git a/services/web/server/src/simcore_service_webserver/users/_notifications_rest.py b/services/web/server/src/simcore_service_webserver/users/_notifications_rest.py index 2e243d4da90..65c427bf7b0 100644 --- a/services/web/server/src/simcore_service_webserver/users/_notifications_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_notifications_rest.py @@ -15,7 +15,7 @@ from .._meta import API_VTAG from ..login.decorators import login_required -from ..products.api import get_product_name +from ..products import products_web from ..redis import get_redis_user_notifications_client from ..security.decorators import permission_required from ..utils_aiohttp import envelope_json_response @@ -62,7 +62,7 @@ async def _get_user_notifications( async def list_user_notifications(request: web.Request) -> web.Response: redis_client = get_redis_user_notifications_client(request.app) req_ctx = UsersRequestContext.model_validate(request) - product_name = get_product_name(request) + product_name = products_web.get_product_name(request) notifications = await _get_user_notifications( redis_client, req_ctx.user_id, product_name ) diff --git a/services/web/server/src/simcore_service_webserver/users/_users_rest.py b/services/web/server/src/simcore_service_webserver/users/_users_rest.py index d2ece688514..b83c33db600 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_rest.py @@ -15,8 +15,6 @@ parse_request_query_parameters_as, ) from servicelib.rest_constants import RESPONSE_MODEL_POLICY -from simcore_service_webserver.products._api import get_current_product -from simcore_service_webserver.products._model import Product from .._meta import API_VTAG from ..exception_handling import ( @@ -28,6 +26,8 @@ from ..groups import api as groups_api from ..groups.exceptions import GroupNotFoundError from ..login.decorators import login_required +from ..products import products_web +from ..products.models import Product from ..security.decorators import permission_required from ..utils_aiohttp import envelope_json_response from . import _users_service @@ -81,7 +81,7 @@ @login_required @_handle_users_exceptions async def get_my_profile(request: web.Request) -> web.Response: - product: Product = get_current_product(request) + product: Product = products_web.get_current_product(request) req_ctx = UsersRequestContext.model_validate(request) groups_by_type = await groups_api.list_user_groups_with_read_access( diff --git a/services/web/server/src/simcore_service_webserver/users/settings.py b/services/web/server/src/simcore_service_webserver/users/settings.py index 2b6b9f101ac..3800f55d635 100644 --- a/services/web/server/src/simcore_service_webserver/users/settings.py +++ b/services/web/server/src/simcore_service_webserver/users/settings.py @@ -3,7 +3,7 @@ from settings_library.base import BaseCustomSettings from settings_library.utils_service import MixinServiceSettings -from .._constants import APP_SETTINGS_KEY +from ..constants import APP_SETTINGS_KEY class UsersSettings(BaseCustomSettings, MixinServiceSettings): diff --git a/services/web/server/src/simcore_service_webserver/utils_aiohttp.py b/services/web/server/src/simcore_service_webserver/utils_aiohttp.py index bb60b8a1b8f..5a13e108201 100644 --- a/services/web/server/src/simcore_service_webserver/utils_aiohttp.py +++ b/services/web/server/src/simcore_service_webserver/utils_aiohttp.py @@ -14,7 +14,7 @@ from servicelib.rest_constants import RESPONSE_MODEL_POLICY from yarl import URL -from ._constants import INDEX_RESOURCE_NAME +from .constants import INDEX_RESOURCE_NAME _logger = logging.getLogger(__name__) 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 5e881ebdae5..3aea74cdb83 100644 --- a/services/web/server/src/simcore_service_webserver/wallets/_events.py +++ b/services/web/server/src/simcore_service_webserver/wallets/_events.py @@ -7,7 +7,7 @@ from pydantic import PositiveInt from servicelib.aiohttp.observer import register_observer, setup_observer_registry -from ..products.api import get_product +from ..products import products_service from ..resource_usage.service import add_credits_to_wallet from ..users import preferences_api from ..users.api import get_user_display_and_id_names @@ -27,7 +27,7 @@ async def _auto_add_default_wallet( app, user_id=user_id, product_name=product_name ): user = await get_user_display_and_id_names(app, user_id=user_id) - product = get_product(app, product_name) + product = products_service.get_product(app, product_name) wallet = await create_wallet( app, diff --git a/services/web/server/src/simcore_service_webserver/wallets/_handlers.py b/services/web/server/src/simcore_service_webserver/wallets/_handlers.py index 9afcdb7c437..cc2833ecfd2 100644 --- a/services/web/server/src/simcore_service_webserver/wallets/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/wallets/_handlers.py @@ -21,9 +21,9 @@ from servicelib.logging_errors import create_troubleshotting_log_kwargs from servicelib.request_keys import RQT_USERID_KEY -from .._constants import RQ_PRODUCT_KEY from .._meta import API_VTAG as VTAG from ..application_settings_utils import requires_dev_feature_enabled +from ..constants import RQ_PRODUCT_KEY from ..login.decorators import login_required from ..payments.errors import ( InvalidPaymentMethodError, diff --git a/services/web/server/src/simcore_service_webserver/wallets/_payments_handlers.py b/services/web/server/src/simcore_service_webserver/wallets/_payments_handlers.py index 66c73b5a293..e0b4baddd91 100644 --- a/services/web/server/src/simcore_service_webserver/wallets/_payments_handlers.py +++ b/services/web/server/src/simcore_service_webserver/wallets/_payments_handlers.py @@ -42,7 +42,7 @@ pay_with_payment_method, replace_wallet_payment_autorecharge, ) -from ..products.api import get_credit_amount +from ..products import products_service from ..security.decorators import permission_required from ..utils_aiohttp import envelope_json_response from ._handlers import ( @@ -79,7 +79,7 @@ async def _create_payment(request: web.Request): log_duration=True, extra=get_log_record_extra(user_id=req_ctx.user_id), ): - credit_result: CreditResultGet = await get_credit_amount( + credit_result: CreditResultGet = await products_service.get_credit_amount( request.app, dollar_amount=body_params.price_dollars, product_name=req_ctx.product_name, @@ -351,7 +351,7 @@ async def _pay_with_payment_method(request: web.Request): log_duration=True, extra=get_log_record_extra(user_id=req_ctx.user_id), ): - credit_result: CreditResultGet = await get_credit_amount( + credit_result: CreditResultGet = await products_service.get_credit_amount( request.app, dollar_amount=body_params.price_dollars, product_name=req_ctx.product_name, @@ -420,7 +420,7 @@ async def _get_wallet_autorecharge(request: web.Request): ) # NOTE: just to check that top_up is under limit. Guaranteed by _validate_prices_in_product_settings - assert await get_credit_amount( # nosec + assert await products_service.get_credit_amount( # nosec request.app, dollar_amount=auto_recharge.top_up_amount_in_usd, product_name=req_ctx.product_name, @@ -441,7 +441,7 @@ async def _replace_wallet_autorecharge(request: web.Request): path_params = parse_request_path_parameters_as(WalletsPathParams, request) body_params = await parse_request_body_as(ReplaceWalletAutoRecharge, request) - await get_credit_amount( + await products_service.get_credit_amount( request.app, dollar_amount=body_params.top_up_amount_in_usd, product_name=req_ctx.product_name, diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_common/models.py b/services/web/server/src/simcore_service_webserver/workspaces/_common/models.py index a94ec063f15..05d962a30d5 100644 --- a/services/web/server/src/simcore_service_webserver/workspaces/_common/models.py +++ b/services/web/server/src/simcore_service_webserver/workspaces/_common/models.py @@ -18,7 +18,7 @@ from pydantic import BaseModel, BeforeValidator, ConfigDict, Field from servicelib.request_keys import RQT_USERID_KEY -from ..._constants import RQ_PRODUCT_KEY +from ...constants import RQ_PRODUCT_KEY _logger = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_trash_rest.py b/services/web/server/src/simcore_service_webserver/workspaces/_trash_rest.py index fd7b708c1dd..41776bc57a6 100644 --- a/services/web/server/src/simcore_service_webserver/workspaces/_trash_rest.py +++ b/services/web/server/src/simcore_service_webserver/workspaces/_trash_rest.py @@ -9,7 +9,7 @@ from .._meta import API_VTAG as VTAG from ..login.decorators import get_user_id, login_required -from ..products.api import get_product_name +from ..products import products_web from ..security.decorators import permission_required from . import _trash_services from ._common.exceptions_handlers import handle_plugin_requests_exceptions @@ -27,7 +27,7 @@ @handle_plugin_requests_exceptions async def trash_workspace(request: web.Request): user_id = get_user_id(request) - product_name = get_product_name(request) + product_name = products_web.get_product_name(request) path_params = parse_request_path_parameters_as(WorkspacesPathParams, request) query_params: WorkspaceTrashQueryParams = parse_request_query_parameters_as( WorkspaceTrashQueryParams, request @@ -50,7 +50,7 @@ async def trash_workspace(request: web.Request): @handle_plugin_requests_exceptions async def untrash_workspace(request: web.Request): user_id = get_user_id(request) - product_name = get_product_name(request) + product_name = products_web.get_product_name(request) path_params = parse_request_path_parameters_as(WorkspacesPathParams, request) await _trash_services.untrash_workspace( diff --git a/services/web/server/tests/unit/isolated/conftest.py b/services/web/server/tests/unit/isolated/conftest.py index 77a4b7ca567..eccad058e53 100644 --- a/services/web/server/tests/unit/isolated/conftest.py +++ b/services/web/server/tests/unit/isolated/conftest.py @@ -102,6 +102,8 @@ def mock_env_devel_environment( monkeypatch, envs={ "WEBSERVER_DEV_FEATURES_ENABLED": "1", + "TRACING_OPENTELEMETRY_COLLECTOR_ENDPOINT": "null", + "TRACING_OPENTELEMETRY_COLLECTOR_PORT": "null", }, ) @@ -251,7 +253,7 @@ def mocked_login_required(mocker: MockerFixture): ) mocker.patch( - "simcore_service_webserver.login.decorators.get_product_name", + "simcore_service_webserver.login.decorators.products_web.get_product_name", spec=True, return_value="osparc", ) diff --git a/services/web/server/tests/unit/isolated/products/conftest.py b/services/web/server/tests/unit/isolated/products/conftest.py new file mode 100644 index 00000000000..8fe754e9307 --- /dev/null +++ b/services/web/server/tests/unit/isolated/products/conftest.py @@ -0,0 +1,48 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments + + +import json +import re +from typing import Any + +import pytest +from faker import Faker +from models_library.products import ProductName +from pytest_simcore.helpers.faker_factories import random_product +from simcore_postgres_database.models.products import products as products_table +from simcore_service_webserver.constants import FRONTEND_APP_DEFAULT +from sqlalchemy import String +from sqlalchemy.dialects import postgresql + + +@pytest.fixture(scope="session") +def product_name() -> ProductName: + return ProductName(FRONTEND_APP_DEFAULT) + + +@pytest.fixture +def product_db_server_defaults() -> dict[str, Any]: + server_defaults = {} + for c in products_table.columns: + if c.server_default is not None: + if isinstance(c.type, String): + server_defaults[c.name] = c.server_default.arg + elif isinstance(c.type, postgresql.JSONB): + m = re.match(r"^'(.+)'::jsonb$", c.server_default.arg.text) + if m: + server_defaults[c.name] = json.loads(m.group(1)) + return server_defaults + + +@pytest.fixture +def fake_product_from_db( + faker: Faker, product_name: ProductName, product_db_server_defaults: dict[str, Any] +) -> dict[str, Any]: + return random_product( + name=product_name, + fake=faker, + **product_db_server_defaults, + ) diff --git a/services/web/server/tests/unit/isolated/test_products_middlewares.py b/services/web/server/tests/unit/isolated/products/test_products_middlewares.py similarity index 74% rename from services/web/server/tests/unit/isolated/test_products_middlewares.py rename to services/web/server/tests/unit/isolated/products/test_products_middlewares.py index 8dbf517492d..08dc4f6e013 100644 --- a/services/web/server/tests/unit/isolated/test_products_middlewares.py +++ b/services/web/server/tests/unit/isolated/products/test_products_middlewares.py @@ -8,43 +8,44 @@ import pytest from aiohttp import web from aiohttp.test_utils import make_mocked_request +from faker import Faker +from pytest_simcore.helpers.faker_factories import random_product from servicelib.aiohttp import status from servicelib.rest_constants import X_PRODUCT_NAME_HEADER -from simcore_postgres_database.models.products import LOGIN_SETTINGS_DEFAULT -from simcore_postgres_database.webserver_models import products -from simcore_service_webserver.products._events import _set_app_state -from simcore_service_webserver.products._middlewares import discover_product_middleware -from simcore_service_webserver.products._model import Product -from simcore_service_webserver.products.api import get_product_name +from simcore_service_webserver.products import products_web +from simcore_service_webserver.products._web_events import _set_app_state +from simcore_service_webserver.products._web_middlewares import ( + discover_product_middleware, +) +from simcore_service_webserver.products.models import Product from simcore_service_webserver.statics._constants import FRONTEND_APP_DEFAULT from yarl import URL -@pytest.fixture() -def mock_postgres_product_table(): - # NOTE: try here your product's host_regex before adding them in the database! - column_defaults: dict[str, Any] = { - c.name: f"{c.server_default.arg}" for c in products.columns if c.server_default - } - - column_defaults["login_settings"] = LOGIN_SETTINGS_DEFAULT +@pytest.fixture +def mock_product_db_get_data( + faker: Faker, product_db_server_defaults: dict[str, Any] +) -> list[dict[str, Any]]: _SUBDOMAIN_PREFIX = r"[\w-]+\." return [ - dict( + random_product( name="osparc", host_regex=rf"^({_SUBDOMAIN_PREFIX})*osparc[\.-]", - **column_defaults, + fake=faker, + **product_db_server_defaults, ), - dict( + random_product( name="s4l", host_regex=rf"^({_SUBDOMAIN_PREFIX})*(s4l|sim4life)[\.-]", - **column_defaults, + fake=faker, + **product_db_server_defaults, ), - dict( + random_product( name="tis", host_regex=rf"^({_SUBDOMAIN_PREFIX})*(tis|^ti-solutions)[\.-]", + fake=faker, vendor={ "name": "ACME", "address": "sesame street", @@ -52,18 +53,20 @@ def mock_postgres_product_table(): "url": "https://acme.com", "forum_url": "https://forum.acme.com", }, - **column_defaults, + **product_db_server_defaults, ), ] @pytest.fixture -def mock_app(mock_postgres_product_table: dict[str, Any]) -> web.Application: +def mock_app(mock_product_db_get_data: list[dict[str, Any]]) -> web.Application: app = web.Application() app_products: dict[str, Product] = { - entry["name"]: Product(**entry) for entry in mock_postgres_product_table + product_db_get["name"]: Product.model_validate(product_db_get) + for product_db_get in mock_product_db_get_data } + default_product_name = next(iter(app_products.keys())) _set_app_state(app, app_products, default_product_name) @@ -124,5 +127,5 @@ async def _mock_handler(_request: web.Request): response = await discover_product_middleware(mock_request, _mock_handler) # checks - assert get_product_name(mock_request) == expected_product + assert products_web.get_product_name(mock_request) == expected_product assert response.status == status.HTTP_200_OK diff --git a/services/web/server/tests/unit/isolated/products/test_products_model.py b/services/web/server/tests/unit/isolated/products/test_products_model.py new file mode 100644 index 00000000000..291383be932 --- /dev/null +++ b/services/web/server/tests/unit/isolated/products/test_products_model.py @@ -0,0 +1,187 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments + + +import re +from typing import Any + +import pytest +import simcore_service_webserver.products +import sqlalchemy as sa +from faker import Faker +from models_library.basic_regex import TWILIO_ALPHANUMERIC_SENDER_ID_RE +from models_library.products import ProductName +from pydantic import BaseModel, ValidationError +from pytest_simcore.helpers.faker_factories import random_product +from pytest_simcore.pydantic_models import ( + assert_validation_model, + walk_model_examples_in_package, +) +from simcore_postgres_database.models.products import products as products_table +from simcore_service_webserver.products.models import Product + + +@pytest.mark.parametrize( + "model_cls, example_name, example_data", + walk_model_examples_in_package(simcore_service_webserver.products), +) +def test_all_products_models_examples( + model_cls: type[BaseModel], example_name: str, example_data: Any +): + model_instance = assert_validation_model( + model_cls, example_name=example_name, example_data=example_data + ) + + # Some extra checks for Products + if isinstance(model_instance, Product): + assert model_instance.to_statics() + if "registration_email_template" in example_data: + assert model_instance.get_template_name_for("registration_email.jinja2") + + +def test_product_to_static(): + + product = Product.model_validate(Product.model_json_schema()["examples"][0]) + assert product.to_statics() == { + "displayName": "o²S²PARC", + "supportEmail": "support@osparc.io", + } + + product = Product.model_validate(Product.model_json_schema()["examples"][2]) + + assert product.to_statics() == { + "displayName": "o²S²PARC FOO", + "supportEmail": "foo@osparcf.io", + "vendor": { + "copyright": "© ACME correcaminos", + "name": "ACME", + "url": "https://acme.com", + "license_url": "https://acme.com/license", + "invitation_form": True, + }, + "issues": [ + { + "label": "github", + "login_url": "https://github.com/ITISFoundation/osparc-simcore", + "new_url": "https://github.com/ITISFoundation/osparc-simcore/issues/new/choose", + }, + { + "label": "fogbugz", + "login_url": "https://fogbugz.com/login", + "new_url": "https://fogbugz.com/new?project=123", + }, + ], + "manuals": [ + {"label": "main", "url": "doc.acme.com"}, + {"label": "z43", "url": "yet-another-manual.acme.com"}, + ], + "support": [ + {"kind": "forum", "label": "forum", "url": "forum.acme.com"}, + {"kind": "email", "label": "email", "email": "more-support@acme.com"}, + {"kind": "web", "label": "web-form", "url": "support.acme.com"}, + ], + "isPaymentEnabled": False, + } + + +def test_product_host_regex_with_spaces(): + data = Product.model_json_schema()["examples"][2] + + # with leading and trailing spaces and uppercase (tests anystr_strip_whitespace ) + data["support_email"] = " fOO@BaR.COM " + + # with leading trailing spaces (tests validator("host_regex", pre=True)) + expected = r"([\.-]{0,1}osparc[\.-])".strip() + data["host_regex"] = expected + " " + + # parsing should strip all whitespaces and normalize email + product = Product.model_validate(data) + + assert product.host_regex.pattern == expected + assert product.host_regex.search("osparc.bar.com") + + assert product.support_email == "foo@bar.com" + + +def test_safe_load_empty_blanks_on_string_cols_from_db( + fake_product_from_db: dict[str, Any] +): + nullable_strings_column_names = [ + c.name + for c in products_table.columns + if isinstance(c.type, sa.String) and c.nullable + ] + + fake_product_from_db.update( + {name: " " * len(name) for name in nullable_strings_column_names} + ) + + product = Product.model_validate(fake_product_from_db) + + assert product.model_dump(include=set(nullable_strings_column_names)) == { + name: None for name in nullable_strings_column_names + } + + +@pytest.mark.parametrize( + "expected_product_name", + [ + "osparc", + "s4l", + "s4lacad", + "s4ldesktop", + "s4ldesktopacad", + "s4lengine", + "s4llite", + "tiplite", + "tis", + ], +) +def test_product_name_needs_front_end( + faker: Faker, + expected_product_name: ProductName, + product_db_server_defaults: dict[str, Any], +): + product_from_db = random_product( + name=expected_product_name, + fake=faker, + **product_db_server_defaults, + ) + product = Product.model_validate(product_from_db) + assert product.name == expected_product_name + + +def test_product_name_invalid(fake_product_from_db: dict[str, Any]): + # Test with an invalid name + fake_product_from_db.update(name="invalid name") + with pytest.raises(ValidationError): + Product.model_validate(fake_product_from_db) + + +def test_twilio_sender_id_is_truncated(fake_product_from_db: dict[str, Any]): + fake_product_from_db.update(short_name=None, display_name="very long name" * 12) + product = Product.model_validate(fake_product_from_db) + + assert re.match( + TWILIO_ALPHANUMERIC_SENDER_ID_RE, product.twilio_alpha_numeric_sender_id + ) + + +def test_template_names_from_file(fake_product_from_db: dict[str, Any]): + fake_product_from_db.update(registration_email_template="some_template_name_id") + product = Product.model_validate(fake_product_from_db) + + assert ( + product.get_template_name_for(filename="registration_email.jinja2") + == "some_template_name_id" + ) + assert product.get_template_name_for(filename="other_template.jinja2") is None + + fake_product_from_db.update(registration_email_template=None) + product = Product.model_validate(fake_product_from_db) + assert ( + product.get_template_name_for(filename="registration_email_template.jinja2") + is None + ) diff --git a/services/web/server/tests/unit/isolated/test_diagnostics_healthcheck.py b/services/web/server/tests/unit/isolated/test_diagnostics_healthcheck.py index 01626715b48..924f5d55575 100644 --- a/services/web/server/tests/unit/isolated/test_diagnostics_healthcheck.py +++ b/services/web/server/tests/unit/isolated/test_diagnostics_healthcheck.py @@ -19,8 +19,8 @@ from pytest_simcore.helpers.typing_env import EnvVarsDict from servicelib.aiohttp import status from servicelib.aiohttp.application import create_safe_application -from simcore_service_webserver._constants import APP_SETTINGS_KEY from simcore_service_webserver.application_settings import setup_settings +from simcore_service_webserver.constants import APP_SETTINGS_KEY from simcore_service_webserver.diagnostics._healthcheck import ( HEALTH_LATENCY_PROBE, HealthCheckError, diff --git a/services/web/server/tests/unit/isolated/test_products_model.py b/services/web/server/tests/unit/isolated/test_products_model.py deleted file mode 100644 index 147540adce6..00000000000 --- a/services/web/server/tests/unit/isolated/test_products_model.py +++ /dev/null @@ -1,100 +0,0 @@ -# pylint: disable=redefined-outer-name -# pylint: disable=unused-argument -# pylint: disable=unused-variable - - -from typing import Any - -import pytest -from common_library.json_serialization import json_dumps -from pydantic import BaseModel -from simcore_service_webserver.products._db import Product - - -@pytest.mark.parametrize( - "model_cls", - [ - Product, - ], -) -def test_product_examples( - model_cls: type[BaseModel], model_cls_examples: dict[str, dict[str, Any]] -): - for name, example in model_cls_examples.items(): - print(name, ":", json_dumps(example, indent=1)) - model_instance = model_cls(**example) - assert model_instance, f"Failed with {name}" - - if isinstance(model_instance, Product): - assert model_instance.to_statics() - - if "registration_email_template" in example: - assert model_instance.get_template_name_for("registration_email.jinja2") - - -def test_product_to_static(): - - product = Product.model_validate( - Product.model_config["json_schema_extra"]["examples"][0] - ) - assert product.to_statics() == { - "displayName": "o²S²PARC", - "supportEmail": "support@osparc.io", - } - - product = Product.model_validate( - Product.model_config["json_schema_extra"]["examples"][2] - ) - - assert product.to_statics() == { - "displayName": "o²S²PARC FOO", - "supportEmail": "foo@osparcf.io", - "vendor": { - "copyright": "© ACME correcaminos", - "name": "ACME", - "url": "https://acme.com", - "license_url": "https://acme.com/license", - "invitation_form": True, - }, - "issues": [ - { - "label": "github", - "login_url": "https://github.com/ITISFoundation/osparc-simcore", - "new_url": "https://github.com/ITISFoundation/osparc-simcore/issues/new/choose", - }, - { - "label": "fogbugz", - "login_url": "https://fogbugz.com/login", - "new_url": "https://fogbugz.com/new?project=123", - }, - ], - "manuals": [ - {"label": "main", "url": "doc.acme.com"}, - {"label": "z43", "url": "yet-another-manual.acme.com"}, - ], - "support": [ - {"kind": "forum", "label": "forum", "url": "forum.acme.com"}, - {"kind": "email", "label": "email", "email": "more-support@acme.com"}, - {"kind": "web", "label": "web-form", "url": "support.acme.com"}, - ], - "isPaymentEnabled": False, - } - - -def test_product_host_regex_with_spaces(): - data = Product.model_config["json_schema_extra"]["examples"][2] - - # with leading and trailing spaces and uppercase (tests anystr_strip_whitespace ) - data["support_email"] = " fOO@BaR.COM " - - # with leading trailing spaces (tests validator("host_regex", pre=True)) - expected = r"([\.-]{0,1}osparc[\.-])".strip() - data["host_regex"] = expected + " " - - # parsing should strip all whitespaces and normalize email - product = Product.model_validate(data) - - assert product.host_regex.pattern == expected - assert product.host_regex.search("osparc.bar.com") - - assert product.support_email == "foo@bar.com" diff --git a/services/web/server/tests/unit/isolated/test_rest.py b/services/web/server/tests/unit/isolated/test_rest.py index 31fdba39eac..335350c5468 100644 --- a/services/web/server/tests/unit/isolated/test_rest.py +++ b/services/web/server/tests/unit/isolated/test_rest.py @@ -59,7 +59,7 @@ async def test_frontend_config( assert client.app # avoids having to start database etc... mocker.patch( - "simcore_service_webserver.rest._handlers.get_product_name", + "simcore_service_webserver.rest._handlers.products_web.get_product_name", spec=True, return_value="osparc", ) diff --git a/services/web/server/tests/unit/isolated/test_security_api.py b/services/web/server/tests/unit/isolated/test_security_api.py index b913d95e5a7..dd10eb4fee5 100644 --- a/services/web/server/tests/unit/isolated/test_security_api.py +++ b/services/web/server/tests/unit/isolated/test_security_api.py @@ -25,9 +25,11 @@ from simcore_postgres_database.models.users import UserRole from simcore_service_webserver._meta import API_VTAG from simcore_service_webserver.login.decorators import login_required -from simcore_service_webserver.products._events import _set_app_state -from simcore_service_webserver.products._middlewares import discover_product_middleware -from simcore_service_webserver.products._model import Product +from simcore_service_webserver.products._web_events import _set_app_state +from simcore_service_webserver.products._web_middlewares import ( + discover_product_middleware, +) +from simcore_service_webserver.products.models import Product from simcore_service_webserver.security.api import ( check_user_authorized, clean_auth_policy_cache, diff --git a/services/web/server/tests/unit/with_dbs/01/test_groups_classifiers.py b/services/web/server/tests/unit/with_dbs/01/test_groups_classifiers.py index 98fa573cd08..c77f1335015 100644 --- a/services/web/server/tests/unit/with_dbs/01/test_groups_classifiers.py +++ b/services/web/server/tests/unit/with_dbs/01/test_groups_classifiers.py @@ -7,7 +7,7 @@ import pytest import sqlalchemy as sa from servicelib.common_aiopg_utils import DataSourceName, create_pg_engine -from simcore_service_webserver._constants import APP_AIOPG_ENGINE_KEY +from simcore_service_webserver.constants import APP_AIOPG_ENGINE_KEY from simcore_service_webserver.groups._classifiers_service import ( GroupClassifierRepository, ) diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py b/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py index 8368dd6bd11..25d48013e13 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py @@ -39,7 +39,7 @@ from simcore_service_webserver.groups._groups_service import get_product_group_for_user from simcore_service_webserver.groups.api import auto_add_user_to_product_group from simcore_service_webserver.groups.exceptions import GroupNotFoundError -from simcore_service_webserver.products.api import get_product +from simcore_service_webserver.products.products_service import get_product from simcore_service_webserver.projects.models import ProjectDict from simcore_service_webserver.projects.projects_permalink_service import ( ProjectPermalink, diff --git a/services/web/server/tests/unit/with_dbs/03/invitations/conftest.py b/services/web/server/tests/unit/with_dbs/03/invitations/conftest.py index e5171ac1d1d..475dc12812b 100644 --- a/services/web/server/tests/unit/with_dbs/03/invitations/conftest.py +++ b/services/web/server/tests/unit/with_dbs/03/invitations/conftest.py @@ -29,7 +29,8 @@ InvitationsSettings, get_plugin_settings, ) -from simcore_service_webserver.products.api import Product, list_products +from simcore_service_webserver.products import products_service +from simcore_service_webserver.products.models import Product from yarl import URL @@ -52,7 +53,7 @@ def invitations_service_openapi_specs( @pytest.fixture def current_product(client: TestClient) -> Product: assert client.app - products = list_products(client.app) + products = products_service.list_products(client.app) assert products assert products[0].name == "osparc" return products[0] 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 ad31dda87c3..030a88e55cc 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 @@ -20,7 +20,7 @@ InvalidInvitationError, InvitationsServiceUnavailableError, ) -from simcore_service_webserver.products.api import Product +from simcore_service_webserver.products.models import Product from yarl import URL diff --git a/services/web/server/tests/unit/with_dbs/03/invitations/test_products__invitations_handlers.py b/services/web/server/tests/unit/with_dbs/03/invitations/test_products_rest_invitations.py similarity index 98% rename from services/web/server/tests/unit/with_dbs/03/invitations/test_products__invitations_handlers.py rename to services/web/server/tests/unit/with_dbs/03/invitations/test_products_rest_invitations.py index 9f347239acd..473ba0c33c6 100644 --- a/services/web/server/tests/unit/with_dbs/03/invitations/test_products__invitations_handlers.py +++ b/services/web/server/tests/unit/with_dbs/03/invitations/test_products_rest_invitations.py @@ -12,7 +12,7 @@ from aiohttp.test_utils import TestClient from faker import Faker from models_library.api_schemas_webserver.product import ( - GenerateInvitation, + InvitationGenerate, InvitationGenerated, ) from models_library.invitations import _MAX_LEN @@ -83,7 +83,7 @@ async def test_product_owner_generates_invitation( ): before_dt = datetime.now(tz=UTC) - request_model = GenerateInvitation( + request_model = InvitationGenerate( guest=guest_email, trial_account_days=trial_account_days, extra_credits_in_usd=extra_credits_in_usd, @@ -146,7 +146,7 @@ async def test_pre_registration_and_invitation_workflow( "country": faker.country(), } - invitation = GenerateInvitation( + invitation = InvitationGenerate( guest=guest_email, trial_account_days=None, extra_credits_in_usd=10, diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py index 29324b2af23..62cddd9b688 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_2fa.py @@ -35,7 +35,8 @@ MSG_2FA_UNAVAILABLE_OEC, ) from simcore_service_webserver.login.storage import AsyncpgStorage -from simcore_service_webserver.products.api import Product, get_current_product +from simcore_service_webserver.products import products_web +from simcore_service_webserver.products.models import Product from simcore_service_webserver.users import preferences_api as user_preferences_api from twilio.base.exceptions import TwilioRestException @@ -371,7 +372,7 @@ async def test_send_email_code( with pytest.raises(KeyError): # NOTE: this is a fake request and did not go through middlewares - get_current_product(request) + products_web.get_current_product(request) user_email = faker.email() support_email = faker.email() diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_auth.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_auth.py index 7d16e912414..97042d6ed1c 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_auth.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_auth.py @@ -14,7 +14,7 @@ from pytest_simcore.helpers.webserver_login import NewUser from servicelib.aiohttp import status from settings_library.utils_session import DEFAULT_SESSION_COOKIE_NAME -from simcore_service_webserver._constants import APP_SETTINGS_KEY +from simcore_service_webserver.constants import APP_SETTINGS_KEY from simcore_service_webserver.db.models import UserStatus from simcore_service_webserver.login._constants import ( MSG_ACTIVATION_REQUIRED, diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_change_email.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_change_email.py index 77b6bbd0b0e..c256edb25cb 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_change_email.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_change_email.py @@ -7,7 +7,7 @@ from pytest_simcore.helpers.assert_checks import assert_status from pytest_simcore.helpers.webserver_login import LoggedUser, NewUser, parse_link from servicelib.aiohttp import status -from simcore_service_webserver._constants import INDEX_RESOURCE_NAME +from simcore_service_webserver.constants import INDEX_RESOURCE_NAME from simcore_service_webserver.login._constants import ( MSG_CHANGE_EMAIL_REQUESTED, MSG_LOGGED_IN, diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_registration_handlers.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_registration_handlers.py index 03b543e9038..5d019f4fb57 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_registration_handlers.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_registration_handlers.py @@ -18,7 +18,7 @@ from servicelib.aiohttp import status from simcore_postgres_database.models.users import UserRole from simcore_service_webserver.login._constants import MSG_USER_DELETED -from simcore_service_webserver.products.api import get_product +from simcore_service_webserver.products.products_service import get_product @pytest.mark.parametrize( diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_utils_emails.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_utils_emails.py index e5e417bb8fc..54cfd476d0e 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_utils_emails.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_utils_emails.py @@ -14,8 +14,8 @@ from json2html import json2html from pytest_mock import MockerFixture from pytest_simcore.helpers.typing_env import EnvVarsDict -from simcore_service_webserver._constants import RQ_PRODUCT_KEY from simcore_service_webserver.application_settings import setup_settings +from simcore_service_webserver.constants import RQ_PRODUCT_KEY from simcore_service_webserver.email.plugin import setup_email from simcore_service_webserver.login.plugin import setup_login from simcore_service_webserver.login.utils_email import ( diff --git a/services/web/server/tests/unit/with_dbs/03/tags/test_tags.py b/services/web/server/tests/unit/with_dbs/03/tags/test_tags.py index 0f70c98856c..9669f1eea90 100644 --- a/services/web/server/tests/unit/with_dbs/03/tags/test_tags.py +++ b/services/web/server/tests/unit/with_dbs/03/tags/test_tags.py @@ -30,7 +30,7 @@ from simcore_postgres_database.models.tags import tags from simcore_service_webserver.db.models import UserRole from simcore_service_webserver.db.plugin import get_database_engine -from simcore_service_webserver.products._api import get_product +from simcore_service_webserver.products._service import get_product from simcore_service_webserver.projects.models import ProjectDict diff --git a/services/web/server/tests/unit/with_dbs/03/test_session_access_policies.py b/services/web/server/tests/unit/with_dbs/03/test_session_access_policies.py index dd79e1a9b6c..88970409a8e 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_session_access_policies.py +++ b/services/web/server/tests/unit/with_dbs/03/test_session_access_policies.py @@ -13,8 +13,8 @@ from aiohttp.test_utils import TestClient from pytest_simcore.helpers.typing_env import EnvVarsDict from servicelib.aiohttp import status -from simcore_service_webserver._constants import APP_SETTINGS_KEY from simcore_service_webserver.application_settings import ApplicationSettings +from simcore_service_webserver.constants import APP_SETTINGS_KEY from simcore_service_webserver.login._constants import ( MAX_2FA_CODE_RESEND, MAX_2FA_CODE_TRIALS, diff --git a/services/web/server/tests/unit/with_dbs/03/test_users__preferences_models.py b/services/web/server/tests/unit/with_dbs/03/test_users__preferences_models.py index bdfe1af8d81..0a33d8f8921 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users__preferences_models.py +++ b/services/web/server/tests/unit/with_dbs/03/test_users__preferences_models.py @@ -14,8 +14,8 @@ PreferenceName, ) from pytest_simcore.helpers.monkeypatch_envs import EnvVarsDict, setenvs_from_dict -from simcore_service_webserver._constants import APP_SETTINGS_KEY from simcore_service_webserver.application_settings import ApplicationSettings +from simcore_service_webserver.constants import APP_SETTINGS_KEY from simcore_service_webserver.users._preferences_models import ( ALL_FRONTEND_PREFERENCES, TelemetryLowDiskSpaceWarningThresholdFrontendUserPreference, diff --git a/services/web/server/tests/unit/with_dbs/04/products/conftest.py b/services/web/server/tests/unit/with_dbs/04/products/conftest.py index 99f086477a5..236ce3ec224 100644 --- a/services/web/server/tests/unit/with_dbs/04/products/conftest.py +++ b/services/web/server/tests/unit/with_dbs/04/products/conftest.py @@ -4,8 +4,15 @@ import pytest +from models_library.products import ProductName from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict from pytest_simcore.helpers.typing_env import EnvVarsDict +from simcore_service_webserver.constants import FRONTEND_APP_DEFAULT + + +@pytest.fixture +def default_product_name() -> ProductName: + return FRONTEND_APP_DEFAULT @pytest.fixture diff --git a/services/web/server/tests/unit/with_dbs/04/products/test_products_db.py b/services/web/server/tests/unit/with_dbs/04/products/test_products_db.py deleted file mode 100644 index bd399948c14..00000000000 --- a/services/web/server/tests/unit/with_dbs/04/products/test_products_db.py +++ /dev/null @@ -1,153 +0,0 @@ -# pylint: disable=redefined-outer-name -# pylint: disable=unused-argument -# pylint: disable=unused-variable -# pylint: disable=too-many-arguments - - -from typing import Any - -import pytest -import sqlalchemy as sa -from aiohttp import web -from aiohttp.test_utils import TestClient -from aiopg.sa.result import RowProxy -from pytest_mock import MockerFixture -from simcore_postgres_database import utils_products -from simcore_postgres_database.models.products import ( - EmailFeedback, - Forum, - IssueTracker, - Manual, - Vendor, - WebFeedback, - products, -) -from simcore_service_webserver.db.plugin import APP_AIOPG_ENGINE_KEY -from simcore_service_webserver.products._db import ProductRepository -from simcore_service_webserver.products._middlewares import _get_default_product_name -from simcore_service_webserver.products._model import Product - - -@pytest.fixture -def app(client: TestClient) -> web.Application: - assert client.app - return client.app - - -@pytest.fixture -async def product_row(app: web.Application, product_data: dict[str, Any]) -> RowProxy: - """Injects product_data in products table and returns the associated table's database row - - Note that product_data is a SUBSET of product_row (e.g. modified dattimes etc)! - """ - engine = app[APP_AIOPG_ENGINE_KEY] - assert engine - - async with engine.acquire() as conn: - # writes - insert_stmt = ( - products.insert().values(**product_data).returning(products.c.name) - ) - name = await conn.scalar(insert_stmt) - - # reads - select_stmt = sa.select(products).where(products.c.name == name) - row = await (await conn.execute(select_stmt)).fetchone() - assert row - - return row - - -@pytest.fixture -async def product_repository( - app: web.Application, mocker: MockerFixture -) -> ProductRepository: - assert product_row - - fake_request = mocker.MagicMock() - fake_request.app = app - - return ProductRepository.create_from_request(request=fake_request) - - -@pytest.mark.parametrize( - "product_data", - [ - # DATA introduced by operator e.g. in adminer - { - "name": "tis", - "display_name": "COMPLETE example", - "short_name": "dummy", - "host_regex": r"([\.-]{0,1}dummy[\.-])", - "support_email": "foo@osparc.io", - "twilio_messaging_sid": None, - "vendor": Vendor( - name="ACME", - copyright="© ACME correcaminos", - url="https://acme.com", - license_url="http://docs.acme.app/#/license-terms", - invitation_url="http://docs.acme.app/#/how-to-request-invitation", - ), - "issues": [ - IssueTracker( - label="github", - login_url="https://github.com/ITISFoundation/osparc-simcore", - new_url="https://github.com/ITISFoundation/osparc-simcore/issues/new/choose", - ), - IssueTracker( - label="fogbugz", - login_url="https://fogbugz.com/login", - new_url="https://fogbugz.com/new?project=123", - ), - ], - "manuals": [ - Manual(label="main", url="doc.acme.com"), - Manual(label="z43", url="yet-another-manual.acme.com"), - ], - "support": [ - Forum(label="forum", kind="forum", url="forum.acme.com"), - EmailFeedback(label="email", kind="email", email="support@acme.com"), - WebFeedback(label="web-form", kind="web", url="support.acme.com"), - ], - }, - # Minimal - { - "name": "s4llite", - "display_name": "MINIMAL example", - "short_name": "dummy", - "host_regex": "([\\.-]{0,1}osparc[\\.-])", - "support_email": "support@osparc.io", - }, - ], - ids=lambda d: d["display_name"], -) -async def test_product_repository_get_product( - product_repository: ProductRepository, - product_data: dict[str, Any], - product_row: RowProxy, - app: web.Application, - mocker: MockerFixture, -): - - # check differences between the original product_data and the product_row in database - assert set(product_data.keys()).issubset(set(product_row.keys())) - - common_keys = set(product_data.keys()).intersection(set(product_row.keys())) - assert {k: product_data[k] for k in common_keys} == { - k: product_row[k] for k in common_keys - } - - # check RowProxy -> pydantic's Product - product = Product.model_validate(product_row) - - print(product.model_dump_json(indent=1)) - - # product repo - assert product_repository.engine - - assert await product_repository.get_product(product.name) == product - - # tests definitions of default from utle_products and web-server.products are in sync - async with product_repository.engine.acquire() as conn: - default_product = await utils_products.get_default_product_name(conn) - assert default_product == _get_default_product_name(app) diff --git a/services/web/server/tests/unit/with_dbs/04/products/test_products_repository.py b/services/web/server/tests/unit/with_dbs/04/products/test_products_repository.py new file mode 100644 index 00000000000..963ffaf2ea5 --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/04/products/test_products_repository.py @@ -0,0 +1,262 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments + +import contextlib +from collections.abc import Iterable +from decimal import Decimal +from typing import Any + +import aiopg.sa +import pytest +import sqlalchemy as sa +from aiohttp import web +from aiohttp.test_utils import TestClient, make_mocked_request +from models_library.products import ProductName, ProductStripeInfoGet +from pytest_simcore.helpers.faker_factories import random_product, random_product_price +from pytest_simcore.helpers.postgres_tools import sync_insert_and_get_row_lifespan +from simcore_postgres_database import utils_products +from simcore_postgres_database.models.products import ( + EmailFeedback, + Forum, + IssueTracker, + Manual, + Vendor, + WebFeedback, + products, +) +from simcore_postgres_database.models.products_prices import products_prices +from simcore_postgres_database.utils_products_prices import ProductPriceInfo +from simcore_service_webserver.constants import ( + FRONTEND_APP_DEFAULT, + FRONTEND_APPS_AVAILABLE, +) +from simcore_service_webserver.products._repository import ProductRepository +from simcore_service_webserver.products._web_middlewares import ( + _get_default_product_name, +) + + +@pytest.fixture(scope="module") +def products_raw_data() -> dict[ProductName, dict[str, Any]]: + adminer_example = { + # DATA introduced by operator e.g. in adminer + "name": "tis", + "display_name": "COMPLETE example", + "short_name": "dummy", + "host_regex": r"([\.-]{0,1}dummy[\.-])", + "support_email": "foo@osparc.io", + "twilio_messaging_sid": None, + "vendor": Vendor( + name="ACME", + copyright="© ACME correcaminos", + url="https://acme.com", + license_url="http://docs.acme.app/#/license-terms", + invitation_url="http://docs.acme.app/#/how-to-request-invitation", + ), + "issues": [ + IssueTracker( + label="github", + login_url="https://github.com/ITISFoundation/osparc-simcore", + new_url="https://github.com/ITISFoundation/osparc-simcore/issues/new/choose", + ), + IssueTracker( + label="fogbugz", + login_url="https://fogbugz.com/login", + new_url="https://fogbugz.com/new?project=123", + ), + ], + "manuals": [ + Manual(label="main", url="doc.acme.com"), + Manual(label="z43", url="yet-another-manual.acme.com"), + ], + "support": [ + Forum(label="forum", kind="forum", url="forum.acme.com"), + EmailFeedback(label="email", kind="email", email="support@acme.com"), + WebFeedback(label="web-form", kind="web", url="support.acme.com"), + ], + } + + minimal_example = { + "name": "s4llite", + "display_name": "MINIMAL example", + "short_name": "dummy", + "host_regex": "([\\.-]{0,1}osparc[\\.-])", + "support_email": "support@osparc.io", + } + + examples = {} + + def _add(data): + assert data["name"] not in examples + assert data.get("group_id") is None # note that group is not assigned + examples.update({data["name"]: data}) + + _add(adminer_example) + _add(minimal_example) + + for name in FRONTEND_APPS_AVAILABLE: + if name not in examples and name != FRONTEND_APP_DEFAULT: + _add(random_product(name=name)) + + return examples + + +@pytest.fixture(scope="module") +def products_prices_raw_data() -> dict[ProductName, dict[str, Any]]: + + return { + "osparc": random_product_price( + product_name="osparc", + # free of charge + usd_per_credit=Decimal(0), + ), + "tis": random_product_price( + product_name="tis", + usd_per_credit=Decimal(0), + ), + } + + +@pytest.fixture(scope="module") +def db_products_table_with_data_before_app( + postgres_db: sa.engine.Engine, + products_raw_data: dict[ProductName, dict[str, Any]], + products_prices_raw_data: dict[ProductName, dict[str, Any]], +) -> Iterable[dict[ProductName, dict[str, Any]]]: + """ + All tests in this module are reading from the database + and the database for products are setup before the app is started + + This fixture replicate those two conditions + """ + + with contextlib.ExitStack() as fixture_stack: + product_to_row: dict[ProductName, dict[str, Any]] = {} + + for product_name, product_values in products_raw_data.items(): + product_row = fixture_stack.enter_context( + sync_insert_and_get_row_lifespan( + postgres_db, + table=products, + values=product_values, + pk_col=products.c.name, + pk_value=product_name, + ) + ) + product_to_row[product_name] = product_row + + if prices := products_prices_raw_data.get(product_name): + fixture_stack.enter_context( + sync_insert_and_get_row_lifespan( + postgres_db, + table=products_prices, + values=prices, + pk_col=products_prices.c.product_name, + pk_value=product_name, + ) + ) + + yield product_to_row + + # will rm products + + +@pytest.fixture +def app( + db_products_table_with_data_before_app: dict[ProductName, dict[str, Any]], + client: TestClient, +) -> web.Application: + assert db_products_table_with_data_before_app + assert client.app + return client.app + + +@pytest.fixture +async def product_repository(app: web.Application) -> ProductRepository: + repo = ProductRepository.create_from_request( + request=make_mocked_request("GET", "/fake", app=app) + ) + assert repo.engine + + return repo + + +async def test_utils_products_and_webserver_default_product_in_sync( + app: web.Application, + product_repository: ProductRepository, + aiopg_engine: aiopg.sa.engine.Engine, +): + # tests definitions of default from utle_products and web-server.products are in sync + async with aiopg_engine.acquire() as conn: + default_product_name = await utils_products.get_default_product_name(conn) + assert default_product_name == _get_default_product_name(app) + + default_product = await product_repository.get_product(default_product_name) + assert default_product + assert default_product.name == default_product_name + + +async def test_product_repository_get_product( + product_repository: ProductRepository, +): + product_name = "tis" + + product = await product_repository.get_product(product_name) + assert product + assert product.name == product_name + + assert await product_repository.get_product("undefined") is None + + +async def test_product_repository_list_products_names( + product_repository: ProductRepository, +): + product_names = await product_repository.list_products_names() + assert isinstance(product_names, list) + assert all(isinstance(name, str) for name in product_names) + + +async def test_product_repository_get_product_latest_price_info_or_none( + product_repository: ProductRepository, +): + product_name = "tis" + price_info = await product_repository.get_product_latest_price_info_or_none( + product_name + ) + assert price_info is None or isinstance(price_info, ProductPriceInfo) + + +async def test_product_repository_get_product_stripe_info( + product_repository: ProductRepository, +): + product_name = "tis" + stripe_info = await product_repository.get_product_stripe_info(product_name) + assert isinstance(stripe_info, ProductStripeInfoGet) + + product_name = "s4l" + with pytest.raises(ValueError, match=product_name): + stripe_info = await product_repository.get_product_stripe_info(product_name) + + +async def test_product_repository_get_template_content( + product_repository: ProductRepository, +): + template_name = "some_template" + content = await product_repository.get_template_content(template_name) + assert content is None or isinstance(content, str) + + +async def test_product_repository_get_product_template_content( + product_repository: ProductRepository, +): + product_name = "tis" + content = await product_repository.get_product_template_content(product_name) + assert content is None or isinstance(content, str) + + +async def test_product_repository_get_product_ui(product_repository: ProductRepository): + product_name = "tis" + ui = await product_repository.get_product_ui(product_name) + assert ui is None or isinstance(ui, dict) diff --git a/services/web/server/tests/unit/with_dbs/04/products/test_products_handlers.py b/services/web/server/tests/unit/with_dbs/04/products/test_products_rest.py similarity index 100% rename from services/web/server/tests/unit/with_dbs/04/products/test_products_handlers.py rename to services/web/server/tests/unit/with_dbs/04/products/test_products_rest.py diff --git a/services/web/server/tests/unit/with_dbs/04/products/test_products_service.py b/services/web/server/tests/unit/with_dbs/04/products/test_products_service.py new file mode 100644 index 00000000000..4309c1f2aa8 --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/04/products/test_products_service.py @@ -0,0 +1,64 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments + + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestServer +from models_library.products import ProductName +from simcore_service_webserver.products import products_service +from simcore_service_webserver.products._repository import ProductRepository +from simcore_service_webserver.products.errors import ProductPriceNotDefinedError + + +@pytest.fixture +def app( + web_server: TestServer, +) -> web.Application: + # app initialized and server running + assert web_server.app + return web_server.app + + +async def test_get_product(app: web.Application, default_product_name: ProductName): + + product = products_service.get_product(app, product_name=default_product_name) + assert product.name == default_product_name + + products = products_service.list_products(app) + assert len(products) == 1 + assert products[0] == product + + +async def test_get_product_ui(app: web.Application, default_product_name: ProductName): + # this feature is currently setup from adminer by an operator + + repo = ProductRepository.create_from_app(app) + ui = await products_service.get_product_ui(repo, product_name=default_product_name) + assert ui == {}, "Expected empty by default" + + +async def test_get_product_stripe_info( + app: web.Application, default_product_name: ProductName +): + # this feature is currently setup from adminer by an operator + + # default is not configured + with pytest.raises(ValueError, match=default_product_name): + await products_service.get_product_stripe_info( + app, product_name=default_product_name + ) + + +async def test_get_credit_amount( + app: web.Application, default_product_name: ProductName +): + # this feature is currently setup from adminer by an operator + + # default is not configured + with pytest.raises(ProductPriceNotDefinedError): + await products_service.get_credit_amount( + app, dollar_amount=1, product_name=default_product_name + ) diff --git a/services/web/server/tests/unit/with_dbs/04/products/test_products_web.py b/services/web/server/tests/unit/with_dbs/04/products/test_products_web.py new file mode 100644 index 00000000000..4db0e38867c --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/04/products/test_products_web.py @@ -0,0 +1,158 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments + + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient, make_mocked_request +from models_library.products import ProductName +from pytest_mock import MockerFixture, MockType +from servicelib.rest_constants import X_PRODUCT_NAME_HEADER +from simcore_service_webserver._meta import API_VTAG +from simcore_service_webserver.products import products_web +from simcore_service_webserver.products.plugin import setup_products + + +@pytest.fixture +def setup_products_mocked(mocker: MockerFixture) -> MockType: + def _wrap(app: web.Application): + setup_products(app) + + # register test handlers + app.router.add_get( + f"/{API_VTAG}/test-helpers", + _test_helpers_handler, + name=_test_helpers_handler.__name__, + ) + app.router.add_get( + f"/{API_VTAG}/test-product-template-helpers", + _test_product_template_handler, + name=_test_product_template_handler.__name__, + ) + + return True + + return mocker.patch( + "simcore_service_webserver.application.setup_products", + autospec=True, + side_effect=_wrap, + ) + + +@pytest.fixture +def client( + setup_products_mocked: MockType, # keep before client fixture! + client: TestClient, +) -> TestClient: + assert setup_products_mocked.called + + assert client.app + assert client.app.router + + registered_routes = { + route.resource.canonical + for route in client.app.router.routes() + if route.resource + } + assert f"/{API_VTAG}/test-helpers" in registered_routes + + return client + + +async def _test_helpers_handler(request: web.Request): + product_name = products_web.get_product_name(request) + current_product = products_web.get_current_product(request) + + assert current_product.name == product_name + + credit_price_info = await products_web.get_current_product_credit_price_info( + request + ) + assert credit_price_info is None + + return web.json_response( + { + "current_product": current_product.model_dump(mode="json"), + "product_name": product_name, + "credit_price_info": credit_price_info, + } + ) + + +async def test_request_helpers(client: TestClient, default_product_name: ProductName): + + resp = await client.get( + f"/{API_VTAG}/test-helpers", + headers={X_PRODUCT_NAME_HEADER: default_product_name}, + ) + + assert resp.ok, f"Got {await resp.text()}" + + got = await resp.json() + assert got["product_name"] == default_product_name + + +async def _test_product_template_handler(request: web.Request): + product_name = products_web.get_product_name(request) + + # if no product, it should return common + + # if no template for product, it should return common + # template/common/close_account.jinja2" + template_path = await products_web.get_product_template_path( + request, filename="close_account.jinja2" + ) + assert template_path.exists() + assert template_path.name == "close_account.jinja2" + assert "common/" in f"{template_path.resolve().absolute()}" + + # if specific template, it gets and caches in file + # "templates/osparc/registration_email.jinja2" + template_path = await products_web.get_product_template_path( + request, filename="registration_email.jinja2" + ) + assert template_path.exists() + assert template_path.name == "registration_email.jinja2" + assert f"{product_name}/" in f"{template_path.resolve().absolute()}" + + # get again and should use file + + for _ in range(2): + got = await products_web.get_product_template_path( + request, filename="registration_email.jinja2" + ) + assert got == template_path + + with pytest.raises(ValueError, match="not part of the templates/common"): + await products_web.get_product_template_path( + request, filename="invalid-template-name.jinja" + ) + + return web.json_response() + + +async def test_product_template_helpers( + client: TestClient, default_product_name: ProductName +): + + resp = await client.get( + f"/{API_VTAG}/test-product-template-helpers", + headers={X_PRODUCT_NAME_HEADER: default_product_name}, + ) + + assert resp.ok, f"Got {await resp.text()}" + + +async def test_get_product_template_path_without_product(): + fake_request = make_mocked_request("GET", "/fake", app=web.Application()) + + # if no product, it should return common + template_path = await products_web.get_product_template_path( + fake_request, filename="close_account.jinja2" + ) + + assert template_path.exists() + assert template_path.name == "close_account.jinja2" + assert "common/" in f"{template_path.resolve().absolute()}" diff --git a/services/web/server/tests/unit/with_dbs/04/wallets/test_wallets.py b/services/web/server/tests/unit/with_dbs/04/wallets/test_wallets.py index b5ddcaf6f31..1a615af2551 100644 --- a/services/web/server/tests/unit/with_dbs/04/wallets/test_wallets.py +++ b/services/web/server/tests/unit/with_dbs/04/wallets/test_wallets.py @@ -28,7 +28,7 @@ from servicelib.aiohttp import status 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.products.products_service import get_product from simcore_service_webserver.projects.models import ProjectDict from simcore_service_webserver.users.api import UserDisplayAndIdNamesTuple from simcore_service_webserver.wallets._events import ( diff --git a/services/web/server/tests/unit/with_dbs/conftest.py b/services/web/server/tests/unit/with_dbs/conftest.py index 5b40152bea5..87d80398658 100644 --- a/services/web/server/tests/unit/with_dbs/conftest.py +++ b/services/web/server/tests/unit/with_dbs/conftest.py @@ -66,9 +66,9 @@ get_default_product_name, get_or_create_product_group, ) -from simcore_service_webserver._constants import INDEX_RESOURCE_NAME from simcore_service_webserver.application import create_application from simcore_service_webserver.application_settings_utils import AppConfigDict +from simcore_service_webserver.constants import INDEX_RESOURCE_NAME from simcore_service_webserver.db.plugin import get_database_engine from simcore_service_webserver.projects.models import ProjectDict from simcore_service_webserver.statics._constants import (