From 73ceb61f085a3f6a69159b75395a0d0d6a307a62 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 12 Feb 2025 13:26:27 +0100 Subject: [PATCH 01/12] cleanup --- api/specs/web-server/_products.py | 5 ++- .../api_schemas_webserver/product.py | 37 ++++++++++--------- .../products/_handlers.py | 8 ++-- 3 files changed, 27 insertions(+), 23 deletions(-) diff --git a/api/specs/web-server/_products.py b/api/specs/web-server/_products.py index 58fdcc154777..8b248223ff6c 100644 --- a/api/specs/web-server/_products.py +++ b/api/specs/web-server/_products.py @@ -11,8 +11,8 @@ from models_library.api_schemas_webserver.product import ( GenerateInvitation, GetCreditPrice, - GetProduct, InvitationGenerated, + ProductGet, UpdateProductTemplate, ) from models_library.generics import Envelope @@ -40,7 +40,8 @@ async def get_current_product_price(): @router.get( "/products/{product_name}", - response_model=Envelope[GetProduct], + response_model=Envelope[ProductGet], + description="NOTE: `/products/current` is used to define current project w/o naming it", tags=[ "po", ], 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 1e747c554fb7..e2d723779e01 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 @@ -60,31 +60,34 @@ class UpdateProductTemplate(InputSchema): content: str -class GetProduct(OutputSchema): +class ProductGet(OutputSchema): name: ProductName display_name: str - short_name: str | None = Field( - default=None, description="Short display name for SMS" - ) - - vendor: dict | None = Field(default=None, description="vendor attributes") - issues: list[dict] | None = Field( - default=None, description="Reference to issues tracker" - ) - manuals: list[dict] | None = Field(default=None, description="List of manuals") - support: list[dict] | None = Field( - default=None, description="List of support resources" - ) + short_name: Annotated[ + str | None, Field(description="Short display name for SMS") + ] = None + + vendor: Annotated[dict | None, Field(description="vendor attributes")] = None + issues: Annotated[ + list[dict] | None, Field(description="Reference to issues tracker") + ] = None + manuals: Annotated[list[dict] | None, Field(description="List of manuals")] = None + support: Annotated[ + list[dict] | None, Field(description="List of support resources") + ] = None login_settings: dict max_open_studies_per_user: PositiveInt | None is_payment_enabled: bool credits_per_usd: NonNegativeDecimal | None - templates: list[GetProductTemplate] = Field( - default_factory=list, - description="List of templates available to this product for communications (e.g. emails, sms, etc)", - ) + templates: Annotated[ + list[GetProductTemplate], + Field( + description="List of templates available to this product for communications (e.g. emails, sms, etc)", + default_factory=list, + ), + ] ExtraCreditsUsdRangeInt: TypeAlias = Annotated[int, Field(ge=0, lt=500)] diff --git a/services/web/server/src/simcore_service_webserver/products/_handlers.py b/services/web/server/src/simcore_service_webserver/products/_handlers.py index 738dcd3c84fd..f5f8c0b2156c 100644 --- a/services/web/server/src/simcore_service_webserver/products/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/products/_handlers.py @@ -2,7 +2,7 @@ from typing import Literal from aiohttp import web -from models_library.api_schemas_webserver.product import GetCreditPrice, GetProduct +from models_library.api_schemas_webserver.product import GetCreditPrice, ProductGet from models_library.basic_types import IDStr from models_library.rest_base import RequestParameters, StrictRequestParameters from models_library.users import UserID @@ -69,9 +69,9 @@ async def _get_product(request: web.Request): except KeyError as err: raise web.HTTPNotFound(reason=f"{product_name=} not found") from err - assert "extra" in GetProduct.model_config # nosec - assert GetProduct.model_config["extra"] == "ignore" # nosec - data = GetProduct(**product.model_dump(), templates=[]) + assert "extra" in ProductGet.model_config # nosec + assert ProductGet.model_config["extra"] == "ignore" # nosec + data = ProductGet(**product.model_dump(), templates=[]) return envelope_json_response(data) From 5ca1ae5cbab46a4b9593d760f91fc941c4939edb Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 12 Feb 2025 13:31:56 +0100 Subject: [PATCH 02/12] drafts test --- .../04/products/test_products_handlers.py | 54 +++++++++++++++++-- 1 file changed, 50 insertions(+), 4 deletions(-) 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_handlers.py index a36fc493ad61..d34b1bd9c906 100644 --- 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_handlers.py @@ -10,12 +10,13 @@ import pytest from aiohttp.test_utils import TestClient -from models_library.api_schemas_webserver.product import GetProduct +from models_library.api_schemas_webserver.product import ProductGet from models_library.products import ProductName from pytest_simcore.helpers.assert_checks import assert_status from pytest_simcore.helpers.webserver_login import UserInfoDict from servicelib.aiohttp import status from servicelib.rest_constants import X_PRODUCT_NAME_HEADER +from servicelib.status_codes_utils import is_2xx_success from simcore_postgres_database.constants import QUANTIZE_EXP_ARG from simcore_service_webserver.db.models import UserRole from simcore_service_webserver.groups.api import auto_add_user_to_product_group @@ -99,17 +100,62 @@ async def test_get_product( response = await client.get("/v0/products/current", headers=current_project_headers) data, error = await assert_status(response, status.HTTP_200_OK) - got_product = GetProduct(**data) + got_product = ProductGet(**data) assert got_product.name == product_name assert got_product.credits_per_usd == expected_credits_per_usd assert not error response = await client.get(f"/v0/products/{product_name}") data, error = await assert_status(response, status.HTTP_200_OK) - assert got_product == GetProduct(**data) + assert got_product == ProductGet(**data) assert not error - response = await client.get("/v0/product/invalid") + response = await client.get("/v0/products/invalid") data, error = await assert_status(response, status.HTTP_404_NOT_FOUND) assert not data assert error + + +@pytest.mark.parametrize( + "user_role, expected_status_code", + [ + (UserRole.ANONYMOUS, status.HTTP_401_UNAUTHORIZED), + (UserRole.GUEST, status.HTTP_403_FORBIDDEN), + (UserRole.USER, status.HTTP_200_OK), + (UserRole.TESTER, status.HTTP_200_OK), + (UserRole.PRODUCT_OWNER, status.HTTP_200_OK), + (UserRole.ADMIN, status.HTTP_200_OK), + ], +) +async def test_get_current_product_ui( + product_name: ProductName, + logged_user: UserInfoDict, + client: TestClient, + user_role: UserRole, + expected_status_code: int, +): + assert logged_user["role"] == f"{user_role}" + + # give access to user to this product + assert client.app + await auto_add_user_to_product_group( + client.app, user_id=logged_user["id"], product_name=product_name + ) + + current_project_headers = {X_PRODUCT_NAME_HEADER: product_name} + response = await client.get( + "/v0/products/current/ui", headers=current_project_headers + ) + + data, error = await assert_status(response, expected_status_code) + + if is_2xx_success(expected_status_code): + # ui is something owned and fully controlled by the front-end + # Will be something like the data stored in this file + # https://github.com/itisfoundation/osparc-simcore/blob/1dcd369717959348099cc6241822a1f0aff0382c/services/static-webserver/client/source/resource/osparc/new_studies.json + assert not error + assert data is not None + assert isinstance(data, dict) + else: + assert error + assert not data From ebf044b4e3f878832bba18da92425733185382b8 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 12 Feb 2025 13:46:07 +0100 Subject: [PATCH 03/12] api --- api/specs/web-server/_products.py | 9 + .../api_schemas_webserver/product.py | 9 +- .../api/v0/openapi.yaml | 226 +++++++++++------- .../products/_handlers.py | 17 +- .../security/_authz_access_roles.py | 1 + .../04/products/test_products_handlers.py | 21 +- 6 files changed, 181 insertions(+), 102 deletions(-) diff --git a/api/specs/web-server/_products.py b/api/specs/web-server/_products.py index 8b248223ff6c..052fabcf3248 100644 --- a/api/specs/web-server/_products.py +++ b/api/specs/web-server/_products.py @@ -13,6 +13,7 @@ GetCreditPrice, InvitationGenerated, ProductGet, + ProductUIGet, UpdateProductTemplate, ) from models_library.generics import Envelope @@ -50,6 +51,14 @@ async def get_product(_params: Annotated[_ProductsRequestParams, Depends()]): ... +@router.get( + "/products/current/ui", + response_model=Envelope[ProductUIGet], +) +async def get_current_product_ui(): + ... + + @router.put( "/products/{product_name}/templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT, 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 e2d723779e01..66ff0cf67f2f 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 @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Annotated, TypeAlias +from typing import Annotated, Any, TypeAlias from pydantic import ( ConfigDict, @@ -90,6 +90,13 @@ class ProductGet(OutputSchema): ] +class ProductUIGet(OutputSchema): + product_name: ProductName + ui: Annotated[ + dict[str, Any], Field(description="Front-end owned ui product configuration") + ] + + ExtraCreditsUsdRangeInt: TypeAlias = Annotated[int, Field(ge=0, lt=500)] 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 6ffab8db1e7b..e20cec988067 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 @@ -1114,6 +1114,8 @@ paths: - products - po summary: Get Product + description: 'NOTE: `/products/current` is used to define current project w/o + naming it' operationId: get_product parameters: - name: product_name @@ -1133,7 +1135,20 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Envelope_GetProduct_' + $ref: '#/components/schemas/Envelope_ProductGet_' + /v0/products/current/ui: + get: + tags: + - products + summary: Get Current Product Ui + operationId: get_current_product_ui + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope_ProductUIGet_' /v0/products/{product_name}/templates/{template_id}: put: tags: @@ -7909,19 +7924,6 @@ components: title: Error type: object title: Envelope[GetCreditPrice] - Envelope_GetProduct_: - properties: - data: - anyOf: - - $ref: '#/components/schemas/GetProduct' - - type: 'null' - error: - anyOf: - - {} - - type: 'null' - title: Error - type: object - title: Envelope[GetProduct] Envelope_GetProjectInactivityResponse_: properties: data: @@ -8234,6 +8236,32 @@ components: title: Error type: object title: Envelope[PricingUnitGet] + Envelope_ProductGet_: + properties: + data: + anyOf: + - $ref: '#/components/schemas/ProductGet' + - type: 'null' + error: + anyOf: + - {} + - type: 'null' + title: Error + type: object + title: Envelope[ProductGet] + Envelope_ProductUIGet_: + properties: + data: + anyOf: + - $ref: '#/components/schemas/ProductUIGet' + - type: 'null' + error: + anyOf: + - {} + - type: 'null' + title: Error + type: object + title: Envelope[ProductUIGet] Envelope_ProjectGet_: properties: data: @@ -9711,84 +9739,6 @@ components: - usdPerCredit - minPaymentAmountUsd title: GetCreditPrice - GetProduct: - properties: - name: - type: string - title: Name - displayName: - type: string - title: Displayname - shortName: - anyOf: - - type: string - - type: 'null' - title: Shortname - description: Short display name for SMS - vendor: - anyOf: - - type: object - - type: 'null' - title: Vendor - description: vendor attributes - issues: - anyOf: - - items: - type: object - type: array - - type: 'null' - title: Issues - description: Reference to issues tracker - manuals: - anyOf: - - items: - type: object - type: array - - type: 'null' - title: Manuals - description: List of manuals - support: - anyOf: - - items: - type: object - type: array - - type: 'null' - title: Support - description: List of support resources - loginSettings: - type: object - title: Loginsettings - maxOpenStudiesPerUser: - anyOf: - - type: integer - exclusiveMinimum: true - minimum: 0 - - type: 'null' - title: Maxopenstudiesperuser - isPaymentEnabled: - type: boolean - title: Ispaymentenabled - creditsPerUsd: - anyOf: - - type: string - - type: 'null' - title: Creditsperusd - templates: - items: - $ref: '#/components/schemas/GetProductTemplate' - type: array - title: Templates - description: List of templates available to this product for communications - (e.g. emails, sms, etc) - type: object - required: - - name - - displayName - - loginSettings - - maxOpenStudiesPerUser - - isPaymentEnabled - - creditsPerUsd - title: GetProduct GetProductTemplate: properties: id: @@ -12060,6 +12010,98 @@ components: - currentCostPerUnit - default title: PricingUnitGet + ProductGet: + properties: + name: + type: string + title: Name + displayName: + type: string + title: Displayname + shortName: + anyOf: + - type: string + - type: 'null' + title: Shortname + description: Short display name for SMS + vendor: + anyOf: + - type: object + - type: 'null' + title: Vendor + description: vendor attributes + issues: + anyOf: + - items: + type: object + type: array + - type: 'null' + title: Issues + description: Reference to issues tracker + manuals: + anyOf: + - items: + type: object + type: array + - type: 'null' + title: Manuals + description: List of manuals + support: + anyOf: + - items: + type: object + type: array + - type: 'null' + title: Support + description: List of support resources + loginSettings: + type: object + title: Loginsettings + maxOpenStudiesPerUser: + anyOf: + - type: integer + exclusiveMinimum: true + minimum: 0 + - type: 'null' + title: Maxopenstudiesperuser + isPaymentEnabled: + type: boolean + title: Ispaymentenabled + creditsPerUsd: + anyOf: + - type: string + - type: 'null' + title: Creditsperusd + templates: + items: + $ref: '#/components/schemas/GetProductTemplate' + type: array + title: Templates + description: List of templates available to this product for communications + (e.g. emails, sms, etc) + type: object + required: + - name + - displayName + - loginSettings + - maxOpenStudiesPerUser + - isPaymentEnabled + - creditsPerUsd + title: ProductGet + ProductUIGet: + properties: + productName: + type: string + title: Productname + ui: + type: object + title: Ui + description: Front-end owned ui product configuration + type: object + required: + - productName + - ui + title: ProductUIGet ProjectCopyOverride: properties: name: diff --git a/services/web/server/src/simcore_service_webserver/products/_handlers.py b/services/web/server/src/simcore_service_webserver/products/_handlers.py index f5f8c0b2156c..dc60e712d6d3 100644 --- a/services/web/server/src/simcore_service_webserver/products/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/products/_handlers.py @@ -2,7 +2,11 @@ from typing import Literal from aiohttp import web -from models_library.api_schemas_webserver.product import GetCreditPrice, ProductGet +from models_library.api_schemas_webserver.product import ( + GetCreditPrice, + ProductGet, + ProductUIGet, +) from models_library.basic_types import IDStr from models_library.rest_base import RequestParameters, StrictRequestParameters from models_library.users import UserID @@ -75,6 +79,17 @@ async def _get_product(request: web.Request): return envelope_json_response(data) +@routes.get(f"/{VTAG}/products/current/ui", name="get_current_product_ui") +@login_required +@permission_required("product.ui.read") +async def _get_current_product_ui(request: web.Request): + req_ctx = _ProductsRequestContext.model_validate(request) + product_name = req_ctx.product_name + + data = ProductUIGet(product_name=product_name, ui={}) + return envelope_json_response(data) + + class _ProductTemplateParams(_ProductsRequestParams): template_id: IDStr diff --git a/services/web/server/src/simcore_service_webserver/security/_authz_access_roles.py b/services/web/server/src/simcore_service_webserver/security/_authz_access_roles.py index 08414fcca9e9..e0cc216e22b6 100644 --- a/services/web/server/src/simcore_service_webserver/security/_authz_access_roles.py +++ b/services/web/server/src/simcore_service_webserver/security/_authz_access_roles.py @@ -61,6 +61,7 @@ class PermissionDict(TypedDict, total=False): "groups.*", "catalog/licensed-items.*", "product.price.read", + "product.ui.read", "project.folders.*", "project.access_rights.update", "project.classifier.*", 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_handlers.py index d34b1bd9c906..5bca90648816 100644 --- 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_handlers.py @@ -10,7 +10,7 @@ import pytest from aiohttp.test_utils import TestClient -from models_library.api_schemas_webserver.product import ProductGet +from models_library.api_schemas_webserver.product import ProductGet, ProductUIGet from models_library.products import ProductName from pytest_simcore.helpers.assert_checks import assert_status from pytest_simcore.helpers.webserver_login import UserInfoDict @@ -96,8 +96,8 @@ async def test_get_product( client.app, user_id=logged_user["id"], product_name=product_name ) - current_project_headers = {X_PRODUCT_NAME_HEADER: product_name} - response = await client.get("/v0/products/current", headers=current_project_headers) + current_product_headers = {X_PRODUCT_NAME_HEADER: product_name} + response = await client.get("/v0/products/current", headers=current_product_headers) data, error = await assert_status(response, status.HTTP_200_OK) got_product = ProductGet(**data) @@ -134,7 +134,7 @@ async def test_get_current_product_ui( user_role: UserRole, expected_status_code: int, ): - assert logged_user["role"] == f"{user_role}" + assert logged_user["role"] == user_role.value # give access to user to this product assert client.app @@ -142,9 +142,12 @@ async def test_get_current_product_ui( client.app, user_id=logged_user["id"], product_name=product_name ) - current_project_headers = {X_PRODUCT_NAME_HEADER: product_name} + assert ( + client.app.router["get_current_product_ui"].url_for().path + == "/v0/products/current/ui" + ) response = await client.get( - "/v0/products/current/ui", headers=current_project_headers + "/v0/products/current/ui", headers={X_PRODUCT_NAME_HEADER: product_name} ) data, error = await assert_status(response, expected_status_code) @@ -154,8 +157,10 @@ async def test_get_current_product_ui( # Will be something like the data stored in this file # https://github.com/itisfoundation/osparc-simcore/blob/1dcd369717959348099cc6241822a1f0aff0382c/services/static-webserver/client/source/resource/osparc/new_studies.json assert not error - assert data is not None - assert isinstance(data, dict) + assert data + + product_ui = ProductUIGet.model_validate(data) + assert product_ui.product_name == product_name else: assert error assert not data From da4a7353c81137e29527b914ee138b1ee02c734d Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 12 Feb 2025 13:48:04 +0100 Subject: [PATCH 04/12] =?UTF-8?q?services/webserver=20api=20version:=200.5?= =?UTF-8?q?6.0=20=E2=86=92=200.57.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/web/server/VERSION | 2 +- services/web/server/setup.cfg | 2 +- .../server/src/simcore_service_webserver/api/v0/openapi.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/services/web/server/VERSION b/services/web/server/VERSION index 2f4c74eb2dda..46448c71b9df 100644 --- a/services/web/server/VERSION +++ b/services/web/server/VERSION @@ -1 +1 @@ -0.56.0 +0.57.0 diff --git a/services/web/server/setup.cfg b/services/web/server/setup.cfg index 9a020ce2932c..3892a06869ea 100644 --- a/services/web/server/setup.cfg +++ b/services/web/server/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.56.0 +current_version = 0.57.0 commit = True message = services/webserver api version: {current_version} → {new_version} tag = False diff --git a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml index e20cec988067..d00d90068a1b 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 @@ -2,7 +2,7 @@ openapi: 3.1.0 info: title: simcore-service-webserver description: Main service with an interface (http-API & websockets) to the web front-end - version: 0.56.0 + version: 0.57.0 servers: - url: '' description: webserver From e9683f6b3653efaa53d2d6d00f11b79dd42e597f Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 12 Feb 2025 14:04:53 +0100 Subject: [PATCH 05/12] adds col --- .../models/products.py | 9 +++++++++ .../pytest_simcore/helpers/faker_factories.py | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/packages/postgres-database/src/simcore_postgres_database/models/products.py b/packages/postgres-database/src/simcore_postgres_database/models/products.py index 8a1dd8cf29e9..a065b82946f1 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/products.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/products.py @@ -146,6 +146,7 @@ class ProductLoginSettingsDict(TypedDict, total=False): nullable=False, doc="Regular expression that matches product hostname from an url string", ), + # EMAILS -------------------- sa.Column( "support_email", sa.String, @@ -200,6 +201,13 @@ class ProductLoginSettingsDict(TypedDict, total=False): doc="Overrides simcore_service_webserver.login.settings.LoginSettings." "SEE LoginSettingsForProduct", ), + sa.Column( + "ui", + JSONB, + nullable=False, + server_default=sa.text("'{}'::jsonb"), + doc="Front-end owned UI configuration", + ), sa.Column( "registration_email_template", sa.String, @@ -212,6 +220,7 @@ class ProductLoginSettingsDict(TypedDict, total=False): nullable=True, doc="Custom jinja2 template for registration email", ), + # lifecycle sa.Column( "created", sa.DateTime(), diff --git a/packages/pytest-simcore/src/pytest_simcore/helpers/faker_factories.py b/packages/pytest-simcore/src/pytest_simcore/helpers/faker_factories.py index 6254f4e56eeb..187ac7d7ee86 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/faker_factories.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/faker_factories.py @@ -265,6 +265,24 @@ def random_product( "group_id": group_id, } + if ui := fake.random_element( + [ + None, + # Examples from https://github.com/itisfoundation/osparc-simcore/blob/1dcd369717959348099cc6241822a1f0aff0382c/services/static-webserver/client/source/resource/osparc/new_studies.json + { + "categories": [ + {"id": "precomputed", "title": "Precomputed"}, + { + "id": "personalized", + "title": "Personalized", + "description": fake.sentence(), + }, + ] + }, + ] + ): + data.update(ui=ui) + assert set(data.keys()).issubset({c.name for c in products.columns}) data.update(overrides) return data From 4cf2f1417281ce77a4b250bec9031662364b9cd1 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 12 Feb 2025 14:05:00 +0100 Subject: [PATCH 06/12] migration --- .../8ec5d2f28966_new_products_ui_column.py | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 packages/postgres-database/src/simcore_postgres_database/migration/versions/8ec5d2f28966_new_products_ui_column.py diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/8ec5d2f28966_new_products_ui_column.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/8ec5d2f28966_new_products_ui_column.py new file mode 100644 index 000000000000..961796762b72 --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/8ec5d2f28966_new_products_ui_column.py @@ -0,0 +1,50 @@ +"""new products ui column + +Revision ID: 8ec5d2f28966 +Revises: 68777fdf9539 +Create Date: 2025-02-12 13:00:37.615966+00:00 + +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "8ec5d2f28966" +down_revision = "68777fdf9539" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column( + "licensed_resources", + "licensed_resource_type", + existing_type=sa.VARCHAR(length=9), + type_=sa.Enum("VIP_MODEL", name="licensedresourcetype"), + existing_nullable=False, + ) + op.add_column( + "products", + sa.Column( + "ui", + postgresql.JSONB(astext_type=sa.Text()), + server_default=sa.text("'{}'::jsonb"), + nullable=False, + ), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("products", "ui") + op.alter_column( + "licensed_resources", + "licensed_resource_type", + existing_type=sa.Enum("VIP_MODEL", name="licensedresourcetype"), + type_=sa.VARCHAR(length=9), + existing_nullable=False, + ) + # ### end Alembic commands ### From d8ec193dc286537e12fea477904be95ede18bb55 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 12 Feb 2025 14:16:38 +0100 Subject: [PATCH 07/12] product repo --- .../products/_api.py | 19 +++++++++++++++++-- .../simcore_service_webserver/products/_db.py | 10 +++++++++- .../products/_handlers.py | 7 ++++++- .../simcore_service_webserver/products/api.py | 2 ++ .../products/errors.py | 4 ++++ 5 files changed, 38 insertions(+), 4 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/products/_api.py b/services/web/server/src/simcore_service_webserver/products/_api.py index ed5b08b5ee17..81a7e86215ce 100644 --- a/services/web/server/src/simcore_service_webserver/products/_api.py +++ b/services/web/server/src/simcore_service_webserver/products/_api.py @@ -1,6 +1,7 @@ from decimal import Decimal +from itertools import product from pathlib import Path -from typing import cast +from typing import Any, cast import aiofiles from aiohttp import web @@ -12,7 +13,11 @@ from ._db import ProductRepository from ._events import APP_PRODUCTS_TEMPLATES_DIR_KEY from ._model import Product -from .errors import BelowMinimumPaymentError, ProductPriceNotDefinedError +from .errors import ( + BelowMinimumPaymentError, + ProductNotFoundError, + ProductPriceNotDefinedError, +) def get_product_name(request: web.Request) -> str: @@ -56,6 +61,16 @@ async def get_current_product_credit_price_info( ) +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, *, diff --git a/services/web/server/src/simcore_service_webserver/products/_db.py b/services/web/server/src/simcore_service_webserver/products/_db.py index a59f6077dfca..a481c0f993e1 100644 --- a/services/web/server/src/simcore_service_webserver/products/_db.py +++ b/services/web/server/src/simcore_service_webserver/products/_db.py @@ -1,6 +1,6 @@ import logging from decimal import Decimal -from typing import AsyncIterator, NamedTuple +from typing import Any, AsyncIterator, NamedTuple import sqlalchemy as sa from aiopg.sa.connection import SAConnection @@ -151,3 +151,11 @@ async def get_product_template_content( .where(products.c.name == product_name) ) return f"{content}" if content else None + + async def get_product_ui(self, product_name: ProductName) -> dict[str, Any] | None: + async with self.engine.acquire() as conn: + result = await conn.execute( + sa.select(products.c.ui).where(products.c.name == product_name) + ) + row: RowProxy | None = await result.first() + return dict(**row.ui) if row else None diff --git a/services/web/server/src/simcore_service_webserver/products/_handlers.py b/services/web/server/src/simcore_service_webserver/products/_handlers.py index dc60e712d6d3..c05bda185a23 100644 --- a/services/web/server/src/simcore_service_webserver/products/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/products/_handlers.py @@ -13,6 +13,7 @@ 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 @@ -86,7 +87,11 @@ async def _get_current_product_ui(request: web.Request): req_ctx = _ProductsRequestContext.model_validate(request) product_name = req_ctx.product_name - data = ProductUIGet(product_name=product_name, ui={}) + ui = await api.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) diff --git a/services/web/server/src/simcore_service_webserver/products/api.py b/services/web/server/src/simcore_service_webserver/products/api.py index 81b7718dc5e8..39487d89c3d7 100644 --- a/services/web/server/src/simcore_service_webserver/products/api.py +++ b/services/web/server/src/simcore_service_webserver/products/api.py @@ -7,6 +7,7 @@ get_product_name, get_product_stripe_info, get_product_template_path, + get_product_ui, list_products, ) from ._model import Product @@ -17,6 +18,7 @@ "get_product_name", "get_product_stripe_info", "get_product_template_path", + "get_product_ui", "get_product", "list_products", "Product", 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 77c24849965f..828d813542d7 100644 --- a/services/web/server/src/simcore_service_webserver/products/errors.py +++ b/services/web/server/src/simcore_service_webserver/products/errors.py @@ -10,6 +10,10 @@ class ProductError(WebServerBaseError, ValueError): ... +class ProductNotFoundError(ProductError): + msg_template = "Undefined product '{product_name}'" + + class ProductPriceNotDefinedError(ProductError): msg_template = "Product price not defined. {reason}" From 9fe66f7d673d82d1ae6bd915944a7e80597aa3b3 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 12 Feb 2025 14:27:38 +0100 Subject: [PATCH 08/12] tests --- .../tests/unit/with_dbs/04/products/test_products_handlers.py | 2 ++ 1 file changed, 2 insertions(+) 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_handlers.py index 5bca90648816..3587285742d9 100644 --- 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_handlers.py @@ -128,6 +128,7 @@ async def test_get_product( ], ) async def test_get_current_product_ui( + all_products_names: list[ProductName], product_name: ProductName, logged_user: UserInfoDict, client: TestClient, @@ -135,6 +136,7 @@ async def test_get_current_product_ui( expected_status_code: int, ): assert logged_user["role"] == user_role.value + assert product_name in all_products_names # give access to user to this product assert client.app From ed27bfc598968282f729c4c0a71a53c9851e9b7f Mon Sep 17 00:00:00 2001 From: matusdrobuliak66 Date: Wed, 12 Feb 2025 14:09:25 +0100 Subject: [PATCH 09/12] fix --- .../versions/68777fdf9539_add_licensed_resources.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/68777fdf9539_add_licensed_resources.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/68777fdf9539_add_licensed_resources.py index a8a66d494365..745e7a2e74d3 100644 --- a/packages/postgres-database/src/simcore_postgres_database/migration/versions/68777fdf9539_add_licensed_resources.py +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/68777fdf9539_add_licensed_resources.py @@ -17,7 +17,9 @@ # Reuse the existing Enum type -existing_enum = sa.Enum("VIP_MODEL", name="licensedresourcetype", native_enum=False) +licensed_resource_type = postgresql.ENUM( + "VIP_MODEL", name="licensedresourcetype", create_type=False +) def upgrade(): @@ -34,7 +36,7 @@ def upgrade(): sa.Column("licensed_resource_name", sa.String(), nullable=False), sa.Column( "licensed_resource_type", - existing_enum, # Reuse existing Enum instead of redefining it + licensed_resource_type, # Reuse existing Enum instead of redefining it nullable=False, ), sa.Column( From 83c5e0d808e34495f2c9737e04dcfcc05ca8249f Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 12 Feb 2025 16:54:41 +0100 Subject: [PATCH 10/12] minor --- .../api_schemas_webserver/product.py | 103 ++++++++++-------- .../products/_api.py | 1 - 2 files changed, 60 insertions(+), 44 deletions(-) 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 66ff0cf67f2f..4c50e2bf2b4f 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 @@ -1,6 +1,7 @@ from datetime import datetime from typing import Annotated, Any, TypeAlias +from common_library.basic_types import DEFAULT_FACTORY from pydantic import ( ConfigDict, Field, @@ -10,6 +11,7 @@ PlainSerializer, PositiveInt, ) +from pydantic.config import JsonDict from ..basic_types import IDStr, NonNegativeDecimal from ..emails import LowerCaseEmailStr @@ -27,32 +29,40 @@ class GetCreditPrice(OutputSchema): description="Price of a credit in USD. " "If None, then this product's price is UNDEFINED", ) - min_payment_amount_usd: NonNegativeInt | None = Field( - ..., - description="Minimum amount (included) in USD that can be paid for this product" - "Can be None if this product's price is UNDEFINED", - ) + min_payment_amount_usd: Annotated[ + NonNegativeInt | None, + Field( + description="Minimum amount (included) in USD that can be paid for this product" + "Can be None if this product's price is UNDEFINED", + ), + ] + + @staticmethod + def _update_json_schema_extra(schema: JsonDict) -> None: + schema.update( + { + "examples": [ + { + "productName": "osparc", + "usdPerCredit": None, + "minPaymentAmountUsd": None, + }, + { + "productName": "osparc", + "usdPerCredit": "10", + "minPaymentAmountUsd": "10", + }, + ] + } + ) model_config = ConfigDict( - json_schema_extra={ - "examples": [ - { - "productName": "osparc", - "usdPerCredit": None, - "minPaymentAmountUsd": None, - }, - { - "productName": "osparc", - "usdPerCredit": "10", - "minPaymentAmountUsd": "10", - }, - ] - } + json_schema_extra=_update_json_schema_extra, ) class GetProductTemplate(OutputSchema): - id_: IDStr = Field(..., alias="id") + id_: Annotated[IDStr, Field(alias="id")] content: str @@ -87,13 +97,14 @@ class ProductGet(OutputSchema): description="List of templates available to this product for communications (e.g. emails, sms, etc)", default_factory=list, ), - ] + ] = DEFAULT_FACTORY class ProductUIGet(OutputSchema): product_name: ProductName ui: Annotated[ - dict[str, Any], Field(description="Front-end owned ui product configuration") + dict[str, Any], + Field(description="Front-end owned ui product configuration"), ] @@ -115,26 +126,32 @@ class InvitationGenerated(OutputSchema): created: datetime invitation_link: HttpUrl + @staticmethod + def _update_json_schema_extra(schema: JsonDict) -> None: + schema.update( + { + "examples": [ + { + "productName": "osparc", + "issuer": "john.doe", + "guest": "guest@example.com", + "trialAccountDays": 7, + "extraCreditsInUsd": 30, + "created": "2023-09-27T15:30:00", + "invitationLink": "https://example.com/invitation#1234", + }, + # w/o optional + { + "productName": "osparc", + "issuer": "john.doe@email.com", + "guest": "guest@example.com", + "created": "2023-09-27T15:30:00", + "invitationLink": "https://example.com/invitation#1234", + }, + ] + } + ) + model_config = ConfigDict( - json_schema_extra={ - "examples": [ - { - "productName": "osparc", - "issuer": "john.doe", - "guest": "guest@example.com", - "trialAccountDays": 7, - "extraCreditsInUsd": 30, - "created": "2023-09-27T15:30:00", - "invitationLink": "https://example.com/invitation#1234", - }, - # w/o optional - { - "productName": "osparc", - "issuer": "john.doe@email.com", - "guest": "guest@example.com", - "created": "2023-09-27T15:30:00", - "invitationLink": "https://example.com/invitation#1234", - }, - ] - } + json_schema_extra=_update_json_schema_extra, ) diff --git a/services/web/server/src/simcore_service_webserver/products/_api.py b/services/web/server/src/simcore_service_webserver/products/_api.py index 81a7e86215ce..ce2c03b87966 100644 --- a/services/web/server/src/simcore_service_webserver/products/_api.py +++ b/services/web/server/src/simcore_service_webserver/products/_api.py @@ -1,5 +1,4 @@ from decimal import Decimal -from itertools import product from pathlib import Path from typing import Any, cast From 41b02a67021411abc51980576d46513c35201912 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 12 Feb 2025 17:00:08 +0100 Subject: [PATCH 11/12] cleanup --- .../8ec5d2f28966_new_products_ui_column.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/8ec5d2f28966_new_products_ui_column.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/8ec5d2f28966_new_products_ui_column.py index 961796762b72..dbf846ebc5dc 100644 --- a/packages/postgres-database/src/simcore_postgres_database/migration/versions/8ec5d2f28966_new_products_ui_column.py +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/8ec5d2f28966_new_products_ui_column.py @@ -17,14 +17,6 @@ def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.alter_column( - "licensed_resources", - "licensed_resource_type", - existing_type=sa.VARCHAR(length=9), - type_=sa.Enum("VIP_MODEL", name="licensedresourcetype"), - existing_nullable=False, - ) op.add_column( "products", sa.Column( @@ -34,17 +26,7 @@ def upgrade(): nullable=False, ), ) - # ### end Alembic commands ### def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### op.drop_column("products", "ui") - op.alter_column( - "licensed_resources", - "licensed_resource_type", - existing_type=sa.Enum("VIP_MODEL", name="licensedresourcetype"), - type_=sa.VARCHAR(length=9), - existing_nullable=False, - ) - # ### end Alembic commands ### From 7af2bfddbfd8d9af151309a840d324fe593757cb Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 12 Feb 2025 17:07:52 +0100 Subject: [PATCH 12/12] fixes migration script --- ...olumn.py => 78f24aaf3f78_new_products_ui_column.py} | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) rename packages/postgres-database/src/simcore_postgres_database/migration/versions/{8ec5d2f28966_new_products_ui_column.py => 78f24aaf3f78_new_products_ui_column.py} (64%) diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/8ec5d2f28966_new_products_ui_column.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/78f24aaf3f78_new_products_ui_column.py similarity index 64% rename from packages/postgres-database/src/simcore_postgres_database/migration/versions/8ec5d2f28966_new_products_ui_column.py rename to packages/postgres-database/src/simcore_postgres_database/migration/versions/78f24aaf3f78_new_products_ui_column.py index dbf846ebc5dc..3c36394729de 100644 --- a/packages/postgres-database/src/simcore_postgres_database/migration/versions/8ec5d2f28966_new_products_ui_column.py +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/78f24aaf3f78_new_products_ui_column.py @@ -1,8 +1,8 @@ """new products ui column -Revision ID: 8ec5d2f28966 +Revision ID: 78f24aaf3f78 Revises: 68777fdf9539 -Create Date: 2025-02-12 13:00:37.615966+00:00 +Create Date: 2025-02-12 16:06:09.815111+00:00 """ import sqlalchemy as sa @@ -10,13 +10,14 @@ from sqlalchemy.dialects import postgresql # revision identifiers, used by Alembic. -revision = "8ec5d2f28966" +revision = "78f24aaf3f78" down_revision = "68777fdf9539" branch_labels = None depends_on = None def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### op.add_column( "products", sa.Column( @@ -26,7 +27,10 @@ def upgrade(): nullable=False, ), ) + # ### end Alembic commands ### def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### op.drop_column("products", "ui") + # ### end Alembic commands ###