diff --git a/.env-devel b/.env-devel index 2680af4d152..ff7235e2c77 100644 --- a/.env-devel +++ b/.env-devel @@ -135,8 +135,10 @@ FUNCTION_SERVICES_AUTHORS='{"UN": {"name": "Unknown", "email": "unknown@osparc.i WEBSERVER_LICENSES={} LICENSES_ITIS_VIP_SYNCER_ENABLED=false -LICENSES_ITIS_VIP_API_URL=https://some-api/{category} +LICENSES_ITIS_VIP_API_URL=https://replace-with-itis-api/{category} LICENSES_ITIS_VIP_CATEGORIES='{"HumanWholeBody": "Humans", "HumanBodyRegion": "Humans (Region)", "AnimalWholeBody": "Animal"}' +LICENSES_SPEAG_PHANTOMS_API_URL=https://replace-with-speag-api/{category} +LICENSES_SPEAG_PHANTOMS_CATEGORIES='{"ComputationalPhantom": "Phantom of the Opera"}' # Can use 'docker run -it itisfoundation/invitations:latest simcore-service-invitations generate-dotenv --auto-password' INVITATIONS_DEFAULT_PRODUCT=osparc diff --git a/packages/models-library/src/models_library/api_schemas_webserver/licensed_items.py b/packages/models-library/src/models_library/api_schemas_webserver/licensed_items.py index 45e7e20c1a7..8c422894b91 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/licensed_items.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/licensed_items.py @@ -53,6 +53,7 @@ class LicensedItemRpcGetPage(NamedTuple): class _ItisVipRestData(OutputSchema): + id: int description: str thumbnail: str features: FeaturesDict # NOTE: here there is a bit of coupling with domain model @@ -62,8 +63,9 @@ class _ItisVipRestData(OutputSchema): class _ItisVipResourceRestData(OutputSchema): category_id: IDStr category_display: str + category_icon: HttpUrl | None = None # NOTE: Placeholder until provide @odeimaiz source: _ItisVipRestData - terms_of_use_url: HttpUrl | None = None + terms_of_use_url: HttpUrl | None = None # NOTE: Placeholder until provided @mguidon class LicensedItemRestGet(OutputSchema): @@ -85,7 +87,6 @@ def _update_json_schema_extra(schema: JsonDict) -> None: { "licensedItemId": "0362b88b-91f8-4b41-867c-35544ad1f7a1", "displayName": "my best model", - "licensedResourceName": "best-model", "licensedResourceType": f"{LicensedResourceType.VIP_MODEL}", "licensedResourceData": cast( JsonDict, diff --git a/packages/models-library/src/models_library/licenses.py b/packages/models-library/src/models_library/licenses.py index fd89e6b8621..16f162fad85 100644 --- a/packages/models-library/src/models_library/licenses.py +++ b/packages/models-library/src/models_library/licenses.py @@ -53,7 +53,7 @@ class FeaturesDict(TypedDict): "doi": "10.1000/xyz123", "license_key": "ABC123XYZ", "license_version": "1.0", - "protection": "Encrypted", + "protection": "Code", "available_from_url": "https://example.com/download", "additional_field": "trimmed if rest", } diff --git a/packages/models-library/tests/test_licenses.py b/packages/models-library/tests/test_licenses.py index c5eaae26ff4..893f4fcba0d 100644 --- a/packages/models-library/tests/test_licenses.py +++ b/packages/models-library/tests/test_licenses.py @@ -1,20 +1,35 @@ from models_library.api_schemas_webserver.licensed_items import LicensedItemRestGet from models_library.licenses import LicensedItem +from pydantic import ConfigDict def test_licensed_item_from_domain_model(): for example in LicensedItem.model_json_schema()["examples"]: item = LicensedItem.model_validate(example) - payload = LicensedItemRestGet.from_domain_model(item) + got = LicensedItemRestGet.from_domain_model(item) - assert item.display_name == payload.display_name + assert item.display_name == got.display_name # nullable doi assert ( - payload.licensed_resource_data.source.doi + got.licensed_resource_data.source.doi == item.licensed_resource_data["source"]["doi"] ) # date is required - assert payload.licensed_resource_data.source.features["date"] + assert got.licensed_resource_data.source.features["date"] + + # + assert ( + got.licensed_resource_data.source.id + == item.licensed_resource_data["source"]["id"] + ) + + +def test_strict_check_of_examples(): + class TestLicensedItemRestGet(LicensedItemRestGet): + model_config = ConfigDict(extra="forbid") + + for example in LicensedItemRestGet.model_json_schema()["examples"]: + TestLicensedItemRestGet.model_validate(example) diff --git a/services/docker-compose.yml b/services/docker-compose.yml index ae2259d3773..f14005f7095 100644 --- a/services/docker-compose.yml +++ b/services/docker-compose.yml @@ -708,6 +708,9 @@ services: LICENSES_ITIS_VIP_SYNCER_ENABLED : ${LICENSES_ITIS_VIP_SYNCER_ENABLED} LICENSES_ITIS_VIP_API_URL: ${LICENSES_ITIS_VIP_API_URL} LICENSES_ITIS_VIP_CATEGORIES: ${LICENSES_ITIS_VIP_CATEGORIES} + LICENSES_SPEAG_PHANTOMS_API_URL: ${LICENSES_SPEAG_PHANTOMS_API_URL} + LICENSES_SPEAG_PHANTOMS_CATEGORIES: ${LICENSES_SPEAG_PHANTOMS_CATEGORIES} + WEBSERVER_LOGIN: ${WEBSERVER_LOGIN} LOGIN_ACCOUNT_DELETION_RETENTION_DAYS: ${LOGIN_ACCOUNT_DELETION_RETENTION_DAYS} diff --git a/services/web/server/VERSION b/services/web/server/VERSION index 4f9b378b40f..7f422a161ae 100644 --- a/services/web/server/VERSION +++ b/services/web/server/VERSION @@ -1 +1 @@ -0.52.0 +0.53.0 diff --git a/services/web/server/setup.cfg b/services/web/server/setup.cfg index 78ab9b59e60..a6fd24519ae 100644 --- a/services/web/server/setup.cfg +++ b/services/web/server/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.52.0 +current_version = 0.53.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 8ed4bd58b1f..5476c616d9e 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.52.0 + version: 0.53.0 servers: - url: '' description: webserver @@ -15518,6 +15518,11 @@ components: categoryDisplay: type: string title: Categorydisplay + categoryIcon: + anyOf: + - type: string + - type: 'null' + title: Categoryicon source: $ref: '#/components/schemas/_ItisVipRestData' termsOfUseUrl: @@ -15533,6 +15538,9 @@ components: title: _ItisVipResourceRestData _ItisVipRestData: properties: + id: + type: integer + title: Id description: type: string title: Description @@ -15548,6 +15556,7 @@ components: title: Doi type: object required: + - id - description - thumbnail - features diff --git a/services/web/server/src/simcore_service_webserver/licenses/_itis_vip_models.py b/services/web/server/src/simcore_service_webserver/licenses/_itis_vip_models.py index 604d135d3f2..20b39a10dba 100644 --- a/services/web/server/src/simcore_service_webserver/licenses/_itis_vip_models.py +++ b/services/web/server/src/simcore_service_webserver/licenses/_itis_vip_models.py @@ -1,16 +1,18 @@ import re -from typing import Annotated, Any, Literal, NamedTuple, TypeAlias +from typing import Annotated, Any, Literal, NamedTuple, TypeAlias, cast from models_library.basic_types import IDStr -from models_library.licenses import FeaturesDict +from models_library.licenses import VIP_DETAILS_EXAMPLE, FeaturesDict from pydantic import ( BaseModel, BeforeValidator, + ConfigDict, Field, HttpUrl, StringConstraints, TypeAdapter, ) +from pydantic.config import JsonDict _max_str_adapter: TypeAdapter[str] = TypeAdapter( Annotated[str, StringConstraints(strip_whitespace=True, max_length=1_000)] @@ -53,6 +55,31 @@ class ItisVipData(BaseModel): protection: Annotated[Literal["Code", "PayPal"], Field(alias="Protection")] available_from_url: Annotated[HttpUrl | None, Field(alias="AvailableFromURL")] + @staticmethod + def _update_json_schema_extra(schema: JsonDict) -> None: + schema.update( + { + "examples": [ + # complete + cast(JsonDict, VIP_DETAILS_EXAMPLE), + # minimal + { + "id": 1, + "description": "A detailed description of the VIP model", + "thumbnail": "https://example.com/thumbnail.jpg", + "features": {"date": "2013-02-01"}, + "doi": "null", + "license_key": "ABC123XYZ", + "license_version": "1.0", + "protection": "Code", + "available_from_url": "null", + }, + ] + } + ) + + model_config = ConfigDict(json_schema_extra=_update_json_schema_extra) + class ItisVipResourceData(BaseModel): category_id: IDStr diff --git a/services/web/server/src/simcore_service_webserver/licenses/_itis_vip_settings.py b/services/web/server/src/simcore_service_webserver/licenses/_itis_vip_settings.py index 5c5637fc2d2..e93ab7b4817 100644 --- a/services/web/server/src/simcore_service_webserver/licenses/_itis_vip_settings.py +++ b/services/web/server/src/simcore_service_webserver/licenses/_itis_vip_settings.py @@ -13,6 +13,19 @@ def _validate_url_contains_category(url: str) -> str: return url +def _to_categories( + api_url: str, category_map: dict[CategoryID, CategoryDisplay] +) -> list[CategoryTuple]: + return [ + CategoryTuple( + url=HttpUrl(api_url.format(category=category_id)), + id=category_id, + display=category_display, + ) + for category_id, category_display in category_map.items() + ] + + class ItisVipSettings(BaseCustomSettings): LICENSES_ITIS_VIP_API_URL: Annotated[ str, AfterValidator(_validate_url_contains_category) @@ -26,13 +39,20 @@ def get_urls(self) -> list[HttpUrl]: ] def to_categories(self) -> list[CategoryTuple]: - return [ - CategoryTuple( - url=HttpUrl( - self.LICENSES_ITIS_VIP_API_URL.format(category=category_id) - ), - id=category_id, - display=category_display, - ) - for category_id, category_display in self.LICENSES_ITIS_VIP_CATEGORIES.items() - ] + return _to_categories( + self.LICENSES_ITIS_VIP_API_URL, + self.LICENSES_ITIS_VIP_CATEGORIES, + ) + + +class SpeagPhantomsSettings(BaseCustomSettings): + LICENSES_SPEAG_PHANTOMS_API_URL: Annotated[ + str, AfterValidator(_validate_url_contains_category) + ] + LICENSES_SPEAG_PHANTOMS_CATEGORIES: dict[CategoryID, CategoryDisplay] + + def to_categories(self) -> list[CategoryTuple]: + return _to_categories( + self.LICENSES_SPEAG_PHANTOMS_API_URL, + self.LICENSES_SPEAG_PHANTOMS_CATEGORIES, + ) diff --git a/services/web/server/src/simcore_service_webserver/licenses/_itis_vip_syncer_service.py b/services/web/server/src/simcore_service_webserver/licenses/_itis_vip_syncer_service.py index 844bfa9aba9..9105b9e26e7 100644 --- a/services/web/server/src/simcore_service_webserver/licenses/_itis_vip_syncer_service.py +++ b/services/web/server/src/simcore_service_webserver/licenses/_itis_vip_syncer_service.py @@ -13,7 +13,6 @@ _itis_vip_service, _licensed_items_service, ) -from simcore_service_webserver.licenses._itis_vip_settings import ItisVipSettings from ..redis import get_redis_lock_manager_client_sdk, setup_redis from ._itis_vip_models import CategoryTuple, ItisVipData, ItisVipResourceData @@ -86,15 +85,10 @@ async def sync_resources_with_licensed_items( def setup_itis_vip_syncer( - app: web.Application, settings: ItisVipSettings, resync_after: datetime.timedelta + app: web.Application, + categories: list[CategoryTuple], + resync_after: datetime.timedelta, ): - categories = settings.to_categories() - if not categories: - _logger.warning( - "Skipping setup_itis_vip_syncer. %s did not provide any category", settings - ) - return - setup_redis(app) async def _lifespan(app_: web.Application): diff --git a/services/web/server/src/simcore_service_webserver/licenses/plugin.py b/services/web/server/src/simcore_service_webserver/licenses/plugin.py index aae7724f9dd..2911564007f 100644 --- a/services/web/server/src/simcore_service_webserver/licenses/plugin.py +++ b/services/web/server/src/simcore_service_webserver/licenses/plugin.py @@ -41,8 +41,21 @@ def setup_licenses(app: web.Application): app.on_startup.append(_rpc.register_rpc_routes_on_startup) if settings.LICENSES_ITIS_VIP_SYNCER_ENABLED and settings.LICENSES_ITIS_VIP: - _itis_vip_syncer_service.setup_itis_vip_syncer( - app, - settings=settings.LICENSES_ITIS_VIP, - resync_after=settings.LICENSES_ITIS_VIP_SYNCER_PERIODICITY, - ) + categories = [] + if settings.LICENSES_ITIS_VIP: + categories += settings.LICENSES_ITIS_VIP.to_categories() + + if settings.LICENSES_SPEAG_PHANTOMS: + categories += settings.LICENSES_SPEAG_PHANTOMS.to_categories() + + if categories: + _itis_vip_syncer_service.setup_itis_vip_syncer( + app, + categories=categories, + resync_after=settings.LICENSES_ITIS_VIP_SYNCER_PERIODICITY, + ) + else: + _logger.warning( + "Skipping setup_itis_vip_syncer. Did not provide any category in settings %s", + settings.model_dump_json(indent=1), + ) diff --git a/services/web/server/src/simcore_service_webserver/licenses/settings.py b/services/web/server/src/simcore_service_webserver/licenses/settings.py index ac050d96dbd..3882c88da1d 100644 --- a/services/web/server/src/simcore_service_webserver/licenses/settings.py +++ b/services/web/server/src/simcore_service_webserver/licenses/settings.py @@ -6,7 +6,7 @@ from servicelib.aiohttp.application_keys import APP_SETTINGS_KEY from settings_library.base import BaseCustomSettings -from ._itis_vip_settings import ItisVipSettings +from ._itis_vip_settings import ItisVipSettings, SpeagPhantomsSettings class LicensesSettings(BaseCustomSettings): @@ -14,7 +14,7 @@ class LicensesSettings(BaseCustomSettings): LICENSES_ITIS_VIP: Annotated[ ItisVipSettings | None, Field( - description="Settings for VIP license models", + description="Settings for VIP licensed models", json_schema_extra={"auto_default_from_env": True}, ), ] @@ -23,6 +23,15 @@ class LicensesSettings(BaseCustomSettings): days=1 ) + # SPEAG - PHANTOMS + LICENSES_SPEAG_PHANTOMS: Annotated[ + SpeagPhantomsSettings | None, + Field( + description="Settings for SPEAG licensed phantoms", + json_schema_extra={"auto_default_from_env": True}, + ), + ] + # other licensed resources come here ...