diff --git a/packages/models-library/src/models_library/api_schemas_catalog/_base.py b/packages/models-library/src/models_library/api_schemas_catalog/_base.py new file mode 100644 index 000000000000..359307235006 --- /dev/null +++ b/packages/models-library/src/models_library/api_schemas_catalog/_base.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel + + +class CatalogInputSchema(BaseModel): + ... + + +class CatalogOutputSchema(BaseModel): + ... diff --git a/packages/models-library/src/models_library/api_schemas_catalog/services.py b/packages/models-library/src/models_library/api_schemas_catalog/services.py index bf4fc35707d4..e4c39c4c1585 100644 --- a/packages/models-library/src/models_library/api_schemas_catalog/services.py +++ b/packages/models-library/src/models_library/api_schemas_catalog/services.py @@ -2,7 +2,7 @@ from typing import Any, TypeAlias from models_library.rpc_pagination import PageRpc -from pydantic import BaseModel, ConfigDict, Field, HttpUrl, NonNegativeInt +from pydantic import ConfigDict, Field, HttpUrl, NonNegativeInt from pydantic.config import JsonDict from ..boot_options import BootOptions @@ -21,6 +21,7 @@ from ..services_resources import ServiceResourcesDict from ..services_types import ServiceKey, ServiceVersion from ..utils.change_case import snake_to_camel +from ._base import CatalogInputSchema, CatalogOutputSchema _EXAMPLE_FILEPICKER: dict[str, Any] = { "name": "File Picker", @@ -71,6 +72,7 @@ "thumbnail": None, "description": "A service which awaits for time to pass, two times.", "description_ui": True, + "icon": "https://cdn-icons-png.flaticon.com/512/25/25231.png", "classifiers": [], "quality": {}, "accessRights": {"1": {"execute": True, "write": False}}, @@ -167,12 +169,14 @@ def _update_json_schema_extra(schema: JsonDict) -> None: ) -class ServiceGetV2(BaseModel): +class ServiceGetV2(CatalogOutputSchema): + # Model used in catalog's rpc and rest interfaces key: ServiceKey version: ServiceVersion name: str thumbnail: HttpUrl | None = None + icon: HttpUrl | None = None description: str description_ui: bool = False @@ -280,9 +284,10 @@ def _update_json_schema_extra(schema: JsonDict) -> None: ServiceResourcesGet: TypeAlias = ServiceResourcesDict -class ServiceUpdateV2(BaseModel): +class ServiceUpdateV2(CatalogInputSchema): name: str | None = None thumbnail: HttpUrl | None = None + icon: HttpUrl | None = None description: str | None = None description_ui: bool = False diff --git a/packages/models-library/src/models_library/services_base.py b/packages/models-library/src/models_library/services_base.py index 2ff59e0da07d..e9d4192838e6 100644 --- a/packages/models-library/src/models_library/services_base.py +++ b/packages/models-library/src/models_library/services_base.py @@ -1,6 +1,6 @@ from typing import Annotated -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, HttpUrl, field_validator from .services_types import ServiceKey, ServiceVersion from .utils.common_validators import empty_str_to_none_pre_validator @@ -41,6 +41,7 @@ class ServiceBaseDisplay(BaseModel): validate_default=True, ), ] = None + icon: Annotated[HttpUrl | None, Field(description="URL to the service icon")] = None description: Annotated[ str, Field( diff --git a/packages/models-library/src/models_library/services_metadata_editable.py b/packages/models-library/src/models_library/services_metadata_editable.py index 2bc5b0d97c44..c0acd484eb0a 100644 --- a/packages/models-library/src/models_library/services_metadata_editable.py +++ b/packages/models-library/src/models_library/services_metadata_editable.py @@ -3,7 +3,7 @@ from typing import Annotated, Any from common_library.basic_types import DEFAULT_FACTORY -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, HttpUrl from pydantic.config import JsonDict from .services_base import ServiceBaseDisplay @@ -22,18 +22,21 @@ class ServiceMetaDataEditable(ServiceBaseDisplay): # Overrides ServiceBaseDisplay fields to Optional for a partial update name: str | None # type: ignore[assignment] thumbnail: str | None + icon: HttpUrl | None description: str | None # type: ignore[assignment] description_ui: bool = False version_display: str | None = None # Below fields only in the database ---- - deprecated: datetime | None = Field( - default=None, - description="Owner can set the date to retire the service. Three possibilities:" - "If None, the service is marked as `published`;" - "If now=deprecated, the service is retired", - ) + deprecated: Annotated[ + datetime | None, + Field( + description="Owner can set the date to retire the service. Three possibilities:" + "If None, the service is marked as `published`;" + "If now=deprecated, the service is retired", + ), + ] = None classifiers: list[str] | None quality: Annotated[ dict[str, Any], Field(default_factory=dict, json_schema_extra={"default": {}}) @@ -49,6 +52,7 @@ def _update_json_schema_extra(schema: JsonDict) -> None: "name": "sim4life", "description": "s4l web", "thumbnail": "https://thumbnailit.org/image", + "icon": "https://cdn-icons-png.flaticon.com/512/25/25231.png", "quality": { "enabled": True, "tsr_target": { diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/3fe27ff48f73_new_icon_table.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/3fe27ff48f73_new_icon_table.py new file mode 100644 index 000000000000..3899ddb9787e --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/3fe27ff48f73_new_icon_table.py @@ -0,0 +1,27 @@ +"""new icon table + +Revision ID: 3fe27ff48f73 +Revises: 611f956aa3e3 +Create Date: 2025-02-05 16:50:02.419293+00:00 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "3fe27ff48f73" +down_revision = "611f956aa3e3" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("services_meta_data", sa.Column("icon", sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("services_meta_data", "icon") + # ### end Alembic commands ### diff --git a/packages/postgres-database/src/simcore_postgres_database/models/services.py b/packages/postgres-database/src/simcore_postgres_database/models/services.py index 30fbf5af6967..ec12f0f3ca87 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/services.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/services.py @@ -68,6 +68,12 @@ nullable=True, doc="Link to image to us as service thumbnail (editable)", ), + sa.Column( + "icon", + sa.String, + nullable=True, + doc="Link to icon (editable)", + ), sa.Column( "version_display", sa.String, 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 9e7ac7da9ecd..281318eba519 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/faker_factories.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/faker_factories.py @@ -27,6 +27,14 @@ DEFAULT_FAKER: Final = faker.Faker() +def random_icon_url(fake: Faker): + return fake.image_url(width=16, height=16) + + +def random_thumbnail_url(fake: Faker): + return fake.image_url(width=32, height=32) + + def _compute_hash(password: str) -> str: try: # 'passlib' will be used only if already installed. @@ -396,7 +404,8 @@ def random_service_meta_data( # optional "description_ui": fake.pybool(), "owner": owner_primary_gid, - "thumbnail": _pick_from([fake.image_url(), None]), # nullable + "thumbnail": _pick_from([random_thumbnail_url(fake), None]), # nullable + "icon": _pick_from([random_icon_url(fake), None]), # nullable "version_display": _pick_from([f"v{_version}", None]), # nullable "classifiers": [], # has default "quality": {}, # has default diff --git a/services/catalog/VERSION b/services/catalog/VERSION index bcaffe19b5bb..8adc70fdd9d6 100644 --- a/services/catalog/VERSION +++ b/services/catalog/VERSION @@ -1 +1 @@ -0.7.0 \ No newline at end of file +0.8.0 \ No newline at end of file diff --git a/services/catalog/openapi.json b/services/catalog/openapi.json index 5fe2b0a51da5..9fbd49878986 100644 --- a/services/catalog/openapi.json +++ b/services/catalog/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "simcore-service-catalog", "description": "Manages and maintains a catalog of all published components (e.g. macro-algorithms, scripts, etc)", - "version": "0.7.0" + "version": "0.8.0" }, "paths": { "/": { @@ -2618,6 +2618,21 @@ "title": "Thumbnail", "description": "URL to the service thumbnail" }, + "icon": { + "anyOf": [ + { + "type": "string", + "maxLength": 2083, + "minLength": 1, + "format": "uri" + }, + { + "type": "null" + } + ], + "title": "Icon", + "description": "URL to the service icon" + }, "description": { "type": "string", "title": "Description", diff --git a/services/catalog/setup.cfg b/services/catalog/setup.cfg index 401431f420ad..031198b2fdd1 100644 --- a/services/catalog/setup.cfg +++ b/services/catalog/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.7.0 +current_version = 0.8.0 commit = True message = services/catalog version: {current_version} → {new_version} tag = False diff --git a/services/catalog/src/simcore_service_catalog/api/rest/_services.py b/services/catalog/src/simcore_service_catalog/api/rest/_services.py index bdf12be74f14..ec1b5dca6b14 100644 --- a/services/catalog/src/simcore_service_catalog/api/rest/_services.py +++ b/services/catalog/src/simcore_service_catalog/api/rest/_services.py @@ -22,7 +22,7 @@ ) from ...db.repositories.groups import GroupsRepository from ...db.repositories.services import ServicesRepository -from ...models.services_db import ServiceAccessRightsAtDB, ServiceMetaDataAtDB +from ...models.services_db import ServiceAccessRightsAtDB, ServiceMetaDataDBGet from ...services.director import DirectorApi from ..dependencies.database import get_repository from ..dependencies.director import get_director_api @@ -35,7 +35,7 @@ def _compose_service_details( service_in_registry: dict[str, Any], # published part - service_in_db: ServiceMetaDataAtDB, # editable part + service_in_db: ServiceMetaDataDBGet, # editable part service_access_rights_in_db: list[ServiceAccessRightsAtDB], service_owner: str | None, ) -> ServiceGet | None: diff --git a/services/catalog/src/simcore_service_catalog/core/background_tasks.py b/services/catalog/src/simcore_service_catalog/core/background_tasks.py index 5e5132467325..cb269ee39196 100644 --- a/services/catalog/src/simcore_service_catalog/core/background_tasks.py +++ b/services/catalog/src/simcore_service_catalog/core/background_tasks.py @@ -28,7 +28,7 @@ from ..db.repositories.groups import GroupsRepository from ..db.repositories.projects import ProjectsRepository from ..db.repositories.services import ServicesRepository -from ..models.services_db import ServiceAccessRightsAtDB, ServiceMetaDataAtDB +from ..models.services_db import ServiceAccessRightsAtDB, ServiceMetaDataDBCreate from ..services import access_rights _logger = logging.getLogger(__name__) @@ -89,7 +89,9 @@ def _by_version(t: tuple[ServiceKey, ServiceVersion]) -> Version: # set the service in the DB await services_repo.create_or_update_service( - ServiceMetaDataAtDB(**service_metadata.model_dump(), owner=owner_gid), + ServiceMetaDataDBCreate( + **service_metadata.model_dump(exclude_unset=True), owner=owner_gid + ), service_access_rights, ) diff --git a/services/catalog/src/simcore_service_catalog/db/repositories/_services_sql.py b/services/catalog/src/simcore_service_catalog/db/repositories/_services_sql.py index 971e9339eb9e..bd4828063a67 100644 --- a/services/catalog/src/simcore_service_catalog/db/repositories/_services_sql.py +++ b/services/catalog/src/simcore_service_catalog/db/repositories/_services_sql.py @@ -4,11 +4,13 @@ from models_library.products import ProductName from models_library.services_types import ServiceKey, ServiceVersion from models_library.users import UserID +from simcore_postgres_database.utils_repos import get_columns_from_db_model from sqlalchemy.dialects.postgresql import ARRAY, INTEGER, array_agg from sqlalchemy.sql import and_, or_ from sqlalchemy.sql.expression import func from sqlalchemy.sql.selectable import Select +from ...models.services_db import ServiceMetaDataDBGet from ..tables import ( services_access_rights, services_compatibility, @@ -17,6 +19,10 @@ users, ) +SERVICES_META_DATA_COLS = get_columns_from_db_model( + services_meta_data, ServiceMetaDataDBGet +) + def list_services_stmt( *, @@ -26,7 +32,7 @@ def list_services_stmt( combine_access_with_and: bool | None = True, product_name: str | None = None, ) -> Select: - stmt = sa.select(services_meta_data) + stmt = sa.select(SERVICES_META_DATA_COLS) if gids or execute_access or write_access: conditions: list[Any] = [] @@ -50,13 +56,9 @@ def list_services_stmt( if product_name: conditions.append(services_access_rights.c.product_name == product_name) - stmt = ( - sa.select( - services_meta_data, - ) - .distinct(services_meta_data.c.key, services_meta_data.c.version) - .select_from(services_meta_data.join(services_access_rights)) - ) + stmt = stmt.distinct( + services_meta_data.c.key, services_meta_data.c.version + ).select_from(services_meta_data.join(services_access_rights)) if conditions: stmt = stmt.where(and_(*conditions)) stmt = stmt.order_by(services_meta_data.c.key, services_meta_data.c.version) @@ -181,6 +183,7 @@ def list_latest_services_with_history_stmt( services_meta_data.c.description, services_meta_data.c.description_ui, services_meta_data.c.thumbnail, + services_meta_data.c.icon, services_meta_data.c.version_display, services_meta_data.c.classifiers, services_meta_data.c.created, @@ -273,6 +276,7 @@ def list_latest_services_with_history_stmt( latest_query.c.description, latest_query.c.description_ui, latest_query.c.thumbnail, + latest_query.c.icon, latest_query.c.version_display, # ownership latest_query.c.owner_email, @@ -312,6 +316,7 @@ def list_latest_services_with_history_stmt( latest_query.c.description, latest_query.c.description_ui, latest_query.c.thumbnail, + latest_query.c.icon, latest_query.c.version_display, latest_query.c.classifiers, latest_query.c.created, @@ -374,6 +379,7 @@ def get_service_stmt( services_meta_data.c.description, services_meta_data.c.description_ui, services_meta_data.c.thumbnail, + services_meta_data.c.icon, services_meta_data.c.version_display, # ownership owner_subquery.label("owner_email"), diff --git a/services/catalog/src/simcore_service_catalog/db/repositories/services.py b/services/catalog/src/simcore_service_catalog/db/repositories/services.py index 7cb1b72e3336..1cae7e1c43be 100644 --- a/services/catalog/src/simcore_service_catalog/db/repositories/services.py +++ b/services/catalog/src/simcore_service_catalog/db/repositories/services.py @@ -17,21 +17,23 @@ from psycopg2.errors import ForeignKeyViolation from pydantic import PositiveInt, TypeAdapter, ValidationError from simcore_postgres_database.utils_services import create_select_latest_services_query -from sqlalchemy import literal_column from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.sql import and_, or_ from sqlalchemy.sql.expression import tuple_ from ...models.services_db import ( - ReleaseFromDB, + ReleaseDBGet, ServiceAccessRightsAtDB, - ServiceMetaDataAtDB, - ServiceWithHistoryFromDB, + ServiceMetaDataDBCreate, + ServiceMetaDataDBGet, + ServiceMetaDataDBPatch, + ServiceWithHistoryDBGet, ) from ...models.services_specifications import ServiceSpecificationsAtDB from ..tables import services_access_rights, services_meta_data, services_specifications from ._base import BaseRepository from ._services_sql import ( + SERVICES_META_DATA_COLS, AccessRightsClauses, can_get_service_stmt, get_service_history_stmt, @@ -79,11 +81,11 @@ async def list_services( write_access: bool | None = None, combine_access_with_and: bool | None = True, product_name: str | None = None, - ) -> list[ServiceMetaDataAtDB]: + ) -> list[ServiceMetaDataDBGet]: async with self.db_engine.connect() as conn: return [ - ServiceMetaDataAtDB.model_validate(row) + ServiceMetaDataDBGet.model_validate(row) async for row in await conn.stream( list_services_stmt( gids=gids, @@ -102,7 +104,7 @@ async def list_service_releases( major: int | None = None, minor: int | None = None, limit_count: int | None = None, - ) -> list[ServiceMetaDataAtDB]: + ) -> list[ServiceMetaDataDBGet]: """Lists LAST n releases of a given service, sorted from latest first major, minor is used to filter as major.minor.* or major.* @@ -124,7 +126,7 @@ async def list_service_releases( search_condition &= services_meta_data.c.version.like(f"{major}.%") query = ( - sa.select(services_meta_data) + sa.select(SERVICES_META_DATA_COLS) .where(search_condition) .order_by(sa.desc(services_meta_data.c.version)) ) @@ -134,22 +136,22 @@ async def list_service_releases( async with self.db_engine.connect() as conn: releases = [ - ServiceMetaDataAtDB.model_validate(row) + ServiceMetaDataDBGet.model_validate(row) async for row in await conn.stream(query) ] # Now sort naturally from latest first: (This is lame, the sorting should be done in the db) - def _by_version(x: ServiceMetaDataAtDB) -> packaging.version.Version: + def _by_version(x: ServiceMetaDataDBGet) -> packaging.version.Version: return packaging.version.parse(x.version) return sorted(releases, key=_by_version, reverse=True) - async def get_latest_release(self, key: str) -> ServiceMetaDataAtDB | None: + async def get_latest_release(self, key: str) -> ServiceMetaDataDBGet | None: """Returns last release or None if service was never released""" services_latest = create_select_latest_services_query().alias("services_latest") query = ( - sa.select(services_meta_data) + sa.select(SERVICES_META_DATA_COLS) .select_from( services_latest.join( services_meta_data, @@ -163,7 +165,7 @@ async def get_latest_release(self, key: str) -> ServiceMetaDataAtDB | None: result = await conn.execute(query) row = result.first() if row: - return ServiceMetaDataAtDB.model_validate(row) + return ServiceMetaDataDBGet.model_validate(row) return None # mypy async def get_service( @@ -175,18 +177,11 @@ async def get_service( execute_access: bool | None = None, write_access: bool | None = None, product_name: str | None = None, - ) -> ServiceMetaDataAtDB | None: + ) -> ServiceMetaDataDBGet | None: - query = sa.select(services_meta_data).where( - (services_meta_data.c.key == key) - & (services_meta_data.c.version == version) - ) - if gids or execute_access or write_access: - - query = sa.select(services_meta_data).select_from( - services_meta_data.join(services_access_rights) - ) + query = sa.select(SERVICES_META_DATA_COLS) + if gids or execute_access or write_access: conditions = [ services_meta_data.c.key == key, services_meta_data.c.version == version, @@ -202,20 +197,27 @@ async def get_service( if product_name: conditions.append(services_access_rights.c.product_name == product_name) - query = query.where(and_(*conditions)) + query = query.select_from( + services_meta_data.join(services_access_rights) + ).where(and_(*conditions)) + else: + query = query.where( + (services_meta_data.c.key == key) + & (services_meta_data.c.version == version) + ) async with self.db_engine.connect() as conn: result = await conn.execute(query) row = result.first() if row: - return ServiceMetaDataAtDB.model_validate(row) + return ServiceMetaDataDBGet.model_validate(row) return None # mypy async def create_or_update_service( self, - new_service: ServiceMetaDataAtDB, + new_service: ServiceMetaDataDBCreate, new_service_access_rights: list[ServiceAccessRightsAtDB], - ) -> ServiceMetaDataAtDB: + ) -> ServiceMetaDataDBGet: for access_rights in new_service_access_rights: if ( access_rights.key != new_service.key @@ -229,12 +231,12 @@ async def create_or_update_service( result = await conn.execute( # pylint: disable=no-value-for-parameter services_meta_data.insert() - .values(**new_service.model_dump(by_alias=True)) - .returning(literal_column("*")) + .values(**new_service.model_dump(exclude_unset=True)) + .returning(*SERVICES_META_DATA_COLS) ) row = result.first() assert row # nosec - created_service = ServiceMetaDataAtDB.model_validate(row) + created_service = ServiceMetaDataDBGet.model_validate(row) for access_rights in new_service_access_rights: insert_stmt = pg_insert(services_access_rights).values( @@ -243,13 +245,18 @@ async def create_or_update_service( await conn.execute(insert_stmt) return created_service - async def update_service(self, patched_service: ServiceMetaDataAtDB) -> None: + async def update_service( + self, + service_key: ServiceKey, + service_version: ServiceVersion, + patched_service: ServiceMetaDataDBPatch, + ) -> None: stmt_update = ( services_meta_data.update() .where( - (services_meta_data.c.key == patched_service.key) - & (services_meta_data.c.version == patched_service.version) + (services_meta_data.c.key == service_key) + & (services_meta_data.c.version == service_version) ) .values( **patched_service.model_dump( @@ -313,7 +320,7 @@ async def get_service_with_history( # get args key: ServiceKey, version: ServiceVersion, - ) -> ServiceWithHistoryFromDB | None: + ) -> ServiceWithHistoryDBGet | None: stmt_get = get_service_stmt( product_name=product_name, @@ -338,13 +345,14 @@ async def get_service_with_history( result = await conn.execute(stmt_history) row_h = result.one_or_none() - return ServiceWithHistoryFromDB( + return ServiceWithHistoryDBGet( key=row.key, version=row.version, # display name=row.name, description=row.description, description_ui=row.description_ui, + icon=row.icon, thumbnail=row.thumbnail, version_display=row.version_display, # ownership @@ -370,7 +378,7 @@ async def list_latest_services( # list args: pagination limit: int | None = None, offset: int | None = None, - ) -> tuple[PositiveInt, list[ServiceWithHistoryFromDB]]: + ) -> tuple[PositiveInt, list[ServiceWithHistoryDBGet]]: # get page stmt_total = total_count_stmt( @@ -396,7 +404,7 @@ async def list_latest_services( # compose history with latest items_page = [ - ServiceWithHistoryFromDB( + ServiceWithHistoryDBGet( key=r.key, version=r.version, # display @@ -404,6 +412,7 @@ async def list_latest_services( description=r.description, description_ui=r.description_ui, thumbnail=r.thumbnail, + icon=r.icon, version_display=r.version_display, # ownership owner_email=r.owner_email, @@ -429,7 +438,7 @@ async def get_service_history( user_id: UserID, # get args key: ServiceKey, - ) -> list[ReleaseFromDB] | None: + ) -> list[ReleaseDBGet] | None: stmt_history = get_service_history_stmt( product_name=product_name, @@ -442,7 +451,7 @@ async def get_service_history( row = result.one_or_none() return ( - TypeAdapter(list[ReleaseFromDB]).validate_python(row.history) + TypeAdapter(list[ReleaseDBGet]).validate_python(row.history) if row else None ) diff --git a/services/catalog/src/simcore_service_catalog/models/services_db.py b/services/catalog/src/simcore_service_catalog/models/services_db.py index 64d8f4a5fa35..63bf3d012853 100644 --- a/services/catalog/src/simcore_service_catalog/models/services_db.py +++ b/services/catalog/src/simcore_service_catalog/models/services_db.py @@ -2,10 +2,10 @@ from typing import Annotated, Any from common_library.basic_types import DEFAULT_FACTORY +from models_library.basic_types import IdInt from models_library.products import ProductName from models_library.services_access import ServiceGroupAccessRights from models_library.services_base import ServiceKeyVersion -from models_library.services_metadata_editable import ServiceMetaDataEditable from models_library.services_types import ServiceKey, ServiceVersion from pydantic import BaseModel, ConfigDict, Field from pydantic.config import JsonDict @@ -13,35 +13,46 @@ from simcore_postgres_database.models.services_compatibility import CompatiblePolicyDict -class ServiceMetaDataAtDB(ServiceKeyVersion, ServiceMetaDataEditable): - # for a partial update all Editable members must be Optional - name: str | None = None - thumbnail: str | None = None - description: str | None = None +class ServiceMetaDataDBGet(BaseModel): + # primary-keys + key: ServiceKey + version: ServiceVersion - classifiers: Annotated[ - list[str] | None, - Field(default_factory=list), - ] = DEFAULT_FACTORY + # ownership + owner: IdInt | None + + # display + name: str + description: str + description_ui: bool + thumbnail: str | None + icon: str | None + version_display: str | None - owner: PositiveInt | None = None + # tagging + classifiers: list[str] + quality: dict[str, Any] + + # lifecycle + created: datetime + modified: datetime + deprecated: datetime | None @staticmethod def _update_json_schema_extra(schema: JsonDict) -> None: schema.update( { "example": { - "key": "simcore/services/dynamic/sim4life", + "key": "simcore/services/dynamic/reading", "version": "1.0.9", "owner": 8, - "name": "sim4life", - "description": "s4l web", - "description_ui": 0, - "thumbnail": "https://picsum.photos/200", + "name": "reading", + "description": "example for service metadata db GET", + "description_ui": False, + "thumbnail": None, + "icon": "https://picsum.photos/50", "version_display": "S4L X", - "created": "2021-01-18 12:46:57.7315", - "modified": "2021-01-19 12:45:00", - "deprecated": "2099-01-19 12:45:00", + "classifiers": ["foo", "bar"], "quality": { "enabled": True, "tsr_target": { @@ -59,6 +70,9 @@ def _update_json_schema_extra(schema: JsonDict) -> None: for n in range(1, 11) }, }, + "created": "2021-01-18 12:46:57.7315", + "modified": "2021-01-19 12:45:00", + "deprecated": "2099-01-19 12:45:00", } } ) @@ -68,7 +82,85 @@ def _update_json_schema_extra(schema: JsonDict) -> None: ) -class ReleaseFromDB(BaseModel): +class ServiceMetaDataDBCreate(BaseModel): + # primary-keys + key: ServiceKey + version: ServiceVersion + + # ownership + owner: IdInt | None = None + + # display + name: str + description: str + description_ui: bool = False + thumbnail: str | None = None + icon: str | None = None + version_display: str | None = None + + # tagging + classifiers: Annotated[list[str], Field(default_factory=list)] = DEFAULT_FACTORY + quality: Annotated[dict[str, Any], Field(default_factory=dict)] = DEFAULT_FACTORY + + # lifecycle + deprecated: datetime | None = None + + @staticmethod + def _update_json_schema_extra(schema: JsonDict) -> None: + schema.update( + { + "examples": [ + # minimal w/ required values + { + "key": "simcore/services/dynamic/creating", + "version": "1.0.9", + "name": "creating", + "description": "example for service metadata db CREATE", + } + ] + } + ) + + model_config = ConfigDict(json_schema_extra=_update_json_schema_extra) + + +class ServiceMetaDataDBPatch(BaseModel): + # ownership + owner: IdInt | None = None + + # display + name: str | None = None + description: str | None = None + description_ui: bool = False + version_display: str | None = None + thumbnail: str | None = None + icon: str | None = None + + # tagging + classifiers: Annotated[list[str], Field(default_factory=list)] = DEFAULT_FACTORY + quality: Annotated[dict[str, Any], Field(default_factory=dict)] = DEFAULT_FACTORY + + # lifecycle + deprecated: datetime | None = None + + @staticmethod + def _update_json_schema_extra(schema: JsonDict) -> None: + schema.update( + { + "example": { + "name": "patching", + "description": "example for service metadata db PATCH", + "thumbnail": "https://picsum.photos/200", + "icon": "https://picsum.photos/50", + "version_display": "S4L X", + } + } + ) + + model_config = ConfigDict(json_schema_extra=_update_json_schema_extra) + + +class ReleaseDBGet(BaseModel): version: ServiceVersion version_display: str | None deprecated: datetime | None @@ -76,7 +168,7 @@ class ReleaseFromDB(BaseModel): compatibility_policy: CompatiblePolicyDict | None -class ServiceWithHistoryFromDB(BaseModel): +class ServiceWithHistoryDBGet(BaseModel): key: ServiceKey version: ServiceVersion # display @@ -84,6 +176,7 @@ class ServiceWithHistoryFromDB(BaseModel): description: str description_ui: bool thumbnail: str | None + icon: str | None version_display: str | None # ownership owner_email: str | None @@ -95,13 +188,13 @@ class ServiceWithHistoryFromDB(BaseModel): modified: datetime deprecated: datetime | None # releases - history: list[ReleaseFromDB] + history: list[ReleaseDBGet] assert ( # nosec - set(ReleaseFromDB.model_fields) + set(ReleaseDBGet.model_fields) .difference({"compatibility_policy"}) - .issubset(set(ServiceWithHistoryFromDB.model_fields)) + .issubset(set(ServiceWithHistoryDBGet.model_fields)) ) diff --git a/services/catalog/src/simcore_service_catalog/services/compatibility.py b/services/catalog/src/simcore_service_catalog/services/compatibility.py index 9c21e8b7ea70..f3d9b6c680a2 100644 --- a/services/catalog/src/simcore_service_catalog/services/compatibility.py +++ b/services/catalog/src/simcore_service_catalog/services/compatibility.py @@ -11,7 +11,7 @@ from simcore_service_catalog.utils.versioning import as_version from ..db.repositories.services import ServicesRepository -from ..models.services_db import ReleaseFromDB +from ..models.services_db import ReleaseDBGet def _get_default_compatibility_specs(target: ServiceVersion | Version) -> SpecifierSet: @@ -41,7 +41,7 @@ def _get_latest_compatible_version( return max(compatible_versions, default=None) -def _convert_to_versions(service_history: list[ReleaseFromDB]) -> list[Version]: +def _convert_to_versions(service_history: list[ReleaseDBGet]) -> list[Version]: return sorted( (as_version(h.version) for h in service_history if not h.deprecated), reverse=True, # latest first @@ -94,7 +94,7 @@ async def evaluate_service_compatibility_map( repo: ServicesRepository, product_name: ProductName, user_id: UserID, - service_release_history: list[ReleaseFromDB], + service_release_history: list[ReleaseDBGet], ) -> dict[ServiceVersion, Compatibility | None]: released_versions = _convert_to_versions(service_release_history) result: dict[ServiceVersion, Compatibility | None] = {} diff --git a/services/catalog/src/simcore_service_catalog/services/services_api.py b/services/catalog/src/simcore_service_catalog/services/services_api.py index 4122a035b0f8..27368a7565f6 100644 --- a/services/catalog/src/simcore_service_catalog/services/services_api.py +++ b/services/catalog/src/simcore_service_catalog/services/services_api.py @@ -17,8 +17,8 @@ from ..db.repositories.services import ServicesRepository from ..models.services_db import ( ServiceAccessRightsAtDB, - ServiceMetaDataAtDB, - ServiceWithHistoryFromDB, + ServiceMetaDataDBPatch, + ServiceWithHistoryDBGet, ) from ..services import manifest from ..services.director import DirectorApi @@ -29,7 +29,7 @@ def _db_to_api_model( - service_db: ServiceWithHistoryFromDB, + service_db: ServiceWithHistoryDBGet, access_rights_db: list[ServiceAccessRightsAtDB], service_manifest: ServiceMetaDataPublished, compatibility_map: dict[ServiceVersion, Compatibility | None] | None = None, @@ -41,6 +41,7 @@ def _db_to_api_model( version=service_db.version, name=service_db.name, thumbnail=HttpUrl(service_db.thumbnail) if service_db.thumbnail else None, + icon=HttpUrl(service_db.icon) if service_db.icon else None, description=service_db.description, description_ui=service_db.description_ui, version_display=service_db.version_display, @@ -239,11 +240,13 @@ async def update_service( # Updates service_meta_data await repo.update_service( - ServiceMetaDataAtDB( - key=service_key, - version=service_version, - **update.model_dump(exclude_unset=True), - ) + service_key, + service_version, + ServiceMetaDataDBPatch.model_validate( + update.model_dump( + exclude_unset=True, exclude={"access_rights"}, mode="json" + ), + ), ) # Updates service_access_rights (they can be added/removed/modified) diff --git a/services/catalog/tests/unit/test_services_compatibility.py b/services/catalog/tests/unit/test_services_compatibility.py index 04ef4bafd4d8..1211c25a97be 100644 --- a/services/catalog/tests/unit/test_services_compatibility.py +++ b/services/catalog/tests/unit/test_services_compatibility.py @@ -12,7 +12,7 @@ from packaging.version import Version from pytest_mock import MockerFixture, MockType from simcore_service_catalog.db.repositories.services import ServicesRepository -from simcore_service_catalog.models.services_db import ReleaseFromDB +from simcore_service_catalog.models.services_db import ReleaseDBGet from simcore_service_catalog.services.compatibility import ( _get_latest_compatible_version, evaluate_service_compatibility_map, @@ -178,10 +178,10 @@ async def test_evaluate_service_compatibility_map_with_default_policy( mock_repo: MockType, user_id: UserID ): service_release_history = [ - _create_as(ReleaseFromDB, version="1.0.0"), - _create_as(ReleaseFromDB, version="1.0.1"), - _create_as(ReleaseFromDB, version="1.1.0"), - _create_as(ReleaseFromDB, version="2.0.0"), + _create_as(ReleaseDBGet, version="1.0.0"), + _create_as(ReleaseDBGet, version="1.0.1"), + _create_as(ReleaseDBGet, version="1.1.0"), + _create_as(ReleaseDBGet, version="2.0.0"), ] compatibility_map = await evaluate_service_compatibility_map( @@ -199,14 +199,14 @@ async def test_evaluate_service_compatibility_map_with_custom_policy( mock_repo: MockType, user_id: UserID ): service_release_history = [ - _create_as(ReleaseFromDB, version="1.0.0"), + _create_as(ReleaseDBGet, version="1.0.0"), _create_as( - ReleaseFromDB, + ReleaseDBGet, version="1.0.1", compatibility_policy={"versions_specifier": ">1.1.0,<=2.0.0"}, ), - _create_as(ReleaseFromDB, version="1.2.0"), - _create_as(ReleaseFromDB, version="2.0.0"), + _create_as(ReleaseDBGet, version="1.2.0"), + _create_as(ReleaseDBGet, version="2.0.0"), ] compatibility_map = await evaluate_service_compatibility_map( @@ -228,9 +228,9 @@ async def test_evaluate_service_compatibility_map_with_other_service( mock_repo: MockType, user_id: UserID ): service_release_history = [ - _create_as(ReleaseFromDB, version="1.0.0"), + _create_as(ReleaseDBGet, version="1.0.0"), _create_as( - ReleaseFromDB, + ReleaseDBGet, version="1.0.1", compatibility_policy={ "other_service_key": "simcore/services/comp/other_service", @@ -240,9 +240,9 @@ async def test_evaluate_service_compatibility_map_with_other_service( ] mock_repo.get_service_history.return_value = [ - _create_as(ReleaseFromDB, version="5.0.0"), - _create_as(ReleaseFromDB, version="5.1.0"), - _create_as(ReleaseFromDB, version="5.2.0"), + _create_as(ReleaseDBGet, version="5.0.0"), + _create_as(ReleaseDBGet, version="5.1.0"), + _create_as(ReleaseDBGet, version="5.2.0"), ] compatibility_map = await evaluate_service_compatibility_map( @@ -265,10 +265,10 @@ async def test_evaluate_service_compatibility_map_with_deprecated_versions( mock_repo: MockType, user_id: UserID ): service_release_history = [ - _create_as(ReleaseFromDB, version="1.0.0"), - _create_as(ReleaseFromDB, version="1.0.1", deprecated=arrow.now().datetime), - _create_as(ReleaseFromDB, version="1.2.0"), - _create_as(ReleaseFromDB, version="1.2.5"), + _create_as(ReleaseDBGet, version="1.0.0"), + _create_as(ReleaseDBGet, version="1.0.1", deprecated=arrow.now().datetime), + _create_as(ReleaseDBGet, version="1.2.0"), + _create_as(ReleaseDBGet, version="1.2.5"), ] compatibility_map = await evaluate_service_compatibility_map( diff --git a/services/catalog/tests/unit/with_dbs/test_api_rpc.py b/services/catalog/tests/unit/with_dbs/test_api_rpc.py index 3192eabbfe62..3605eeae7f01 100644 --- a/services/catalog/tests/unit/with_dbs/test_api_rpc.py +++ b/services/catalog/tests/unit/with_dbs/test_api_rpc.py @@ -16,7 +16,7 @@ from models_library.services_types import ServiceKey, ServiceVersion from models_library.users import UserID from pydantic import ValidationError -from pytest_simcore.helpers.faker_factories import random_user +from pytest_simcore.helpers.faker_factories import random_icon_url, random_user from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict from pytest_simcore.helpers.postgres_tools import insert_and_get_row_lifespan from pytest_simcore.helpers.typing_env import EnvVarsDict @@ -134,6 +134,7 @@ async def test_rpc_catalog_client( product_name: ProductName, user_id: UserID, app: FastAPI, + faker: Faker, ): assert app @@ -178,6 +179,7 @@ async def test_rpc_catalog_client( update={ "name": "foo", "description": "bar", + "icon": random_icon_url(faker), "version_display": "this is a nice version", "description_ui": True, # owner activates wiki view }, # type: ignore @@ -189,6 +191,7 @@ async def test_rpc_catalog_client( assert updated.description == "bar" assert updated.description_ui assert updated.version_display == "this is a nice version" + assert updated.icon is not None assert not updated.classifiers got = await get_service( diff --git a/services/catalog/tests/unit/with_dbs/test_db_repositories.py b/services/catalog/tests/unit/with_dbs/test_db_repositories.py index 8c4053c4ca67..67e53525e38b 100644 --- a/services/catalog/tests/unit/with_dbs/test_db_repositories.py +++ b/services/catalog/tests/unit/with_dbs/test_db_repositories.py @@ -14,7 +14,9 @@ from simcore_service_catalog.db.repositories.services import ServicesRepository from simcore_service_catalog.models.services_db import ( ServiceAccessRightsAtDB, - ServiceMetaDataAtDB, + ServiceMetaDataDBCreate, + ServiceMetaDataDBGet, + ServiceMetaDataDBPatch, ) from simcore_service_catalog.utils.versioning import is_patch_release from sqlalchemy.ext.asyncio import AsyncEngine @@ -109,18 +111,18 @@ async def test_create_services( ) # validation - service = ServiceMetaDataAtDB.model_validate(fake_service) + service_db_create = ServiceMetaDataDBCreate.model_validate(fake_service) service_access_rights = [ ServiceAccessRightsAtDB.model_validate(a) for a in fake_access_rights ] new_service = await services_repo.create_or_update_service( - service, service_access_rights + service_db_create, service_access_rights ) - assert ( - new_service.model_dump(include=set(fake_service.keys())) == service.model_dump() - ) + assert new_service.model_dump( + include=service_db_create.model_fields_set + ) == service_db_create.model_dump(exclude_unset=True) async def test_read_services( @@ -201,7 +203,7 @@ async def test_list_service_releases( fake_catalog_with_jupyterlab: FakeCatalogInfo, services_repo: ServicesRepository, ): - services: list[ServiceMetaDataAtDB] = await services_repo.list_service_releases( + services: list[ServiceMetaDataDBGet] = await services_repo.list_service_releases( "simcore/services/dynamic/jupyterlab" ) assert len(services) == fake_catalog_with_jupyterlab.expected_services_count @@ -239,7 +241,7 @@ async def test_list_service_releases_version_filtered( assert latest.version == fake_catalog_with_jupyterlab.expected_latest releases_1_1_x: list[ - ServiceMetaDataAtDB + ServiceMetaDataDBGet ] = await services_repo.list_service_releases( "simcore/services/dynamic/jupyterlab", major=1, minor=1 ) @@ -248,7 +250,7 @@ async def test_list_service_releases_version_filtered( ] == fake_catalog_with_jupyterlab.expected_1_1_x expected_0_x_x: list[ - ServiceMetaDataAtDB + ServiceMetaDataDBGet ] = await services_repo.list_service_releases( "simcore/services/dynamic/jupyterlab", major=0 ) @@ -386,15 +388,15 @@ async def test_get_and_update_service_meta_data( assert got.version == service_version await services_repo.update_service( - ServiceMetaDataAtDB.model_construct( - key=service_key, version=service_version, name="foo" - ), + service_key, + service_version, + ServiceMetaDataDBPatch(name="foo"), ) updated = await services_repo.get_service(service_key, service_version) + assert updated - assert got.model_copy(update={"name": "foo"}) == updated - - assert await services_repo.get_service(service_key, service_version) == updated + expected = got.model_copy(update={"name": "foo", "modified": updated.modified}) + assert updated == expected async def test_can_get_service( diff --git a/services/web/server/VERSION b/services/web/server/VERSION index 7f422a161aee..524456c77673 100644 --- a/services/web/server/VERSION +++ b/services/web/server/VERSION @@ -1 +1 @@ -0.53.0 +0.54.0 diff --git a/services/web/server/setup.cfg b/services/web/server/setup.cfg index a6fd24519ae4..51de6cdd5a46 100644 --- a/services/web/server/setup.cfg +++ b/services/web/server/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.53.0 +current_version = 0.54.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 5476c616d9ee..c95e3f6ed15c 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.53.0 + version: 0.54.0 servers: - url: '' description: webserver @@ -7450,6 +7450,11 @@ components: - type: string - type: 'null' title: Thumbnail + icon: + anyOf: + - type: string + - type: 'null' + title: Icon description: type: string title: Description @@ -7580,6 +7585,7 @@ components: version: 0.9.0 - version: 0.8.0 - version: 0.1.0 + icon: https://cdn-icons-png.flaticon.com/512/25/25231.png inputs: input0: contentSchema: @@ -7624,6 +7630,14 @@ components: format: uri - type: 'null' title: Thumbnail + icon: + anyOf: + - type: string + maxLength: 2083 + minLength: 1 + format: uri + - type: 'null' + title: Icon description: anyOf: - type: string diff --git a/services/web/server/tests/unit/with_dbs/01/test_catalog_handlers__services.py b/services/web/server/tests/unit/with_dbs/01/test_catalog_handlers__services.py index 1bf0ce9e9bd8..f1024c85af32 100644 --- a/services/web/server/tests/unit/with_dbs/01/test_catalog_handlers__services.py +++ b/services/web/server/tests/unit/with_dbs/01/test_catalog_handlers__services.py @@ -8,6 +8,7 @@ import pytest from aiohttp import web from aiohttp.test_utils import TestClient +from faker import Faker from models_library.api_schemas_catalog.services import ServiceGetV2 from models_library.api_schemas_webserver.catalog import ( CatalogServiceGet, @@ -22,6 +23,7 @@ from pydantic import NonNegativeInt, TypeAdapter from pytest_mock import MockerFixture from pytest_simcore.helpers.assert_checks import assert_status +from pytest_simcore.helpers.faker_factories import random_icon_url from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict from pytest_simcore.helpers.typing_env import EnvVarsDict from pytest_simcore.helpers.webserver_login import UserInfoDict @@ -162,6 +164,7 @@ async def test_get_and_patch_service( client: TestClient, logged_user: UserInfoDict, mocked_rpc_catalog_service_api: dict[str, MagicMock], + faker: Faker, ): assert client.app assert client.app.router @@ -190,8 +193,8 @@ async def test_get_and_patch_service( # PATCH update = CatalogServiceUpdate( name="foo", - thumbnail=None, description="bar", + icon=random_icon_url(faker), classifiers=None, versionDisplay="Some nice name", descriptionUi=True, @@ -209,6 +212,7 @@ async def test_get_and_patch_service( assert model.key == service_key assert model.version == service_version assert model.name == update.name + assert model.icon == update.icon assert model.description == update.description assert model.description_ui == update.description_ui assert model.version_display == update.version_display