diff --git a/api/specs/webserver/openapi-projects-metadata.yaml b/api/specs/webserver/openapi-projects-metadata.yaml new file mode 100644 index 00000000000..44d154b9798 --- /dev/null +++ b/api/specs/webserver/openapi-projects-metadata.yaml @@ -0,0 +1,93 @@ +paths: + /projects/{project_id}/metadata: + get: + tags: + - project + summary: Get Project Metadata + operationId: get_project_metadata + parameters: + - required: true + schema: + type: string + format: uuid + title: Project Id + name: project_id + in: path + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope_ProjectMetadataGet_' + patch: + tags: + - project + summary: Update Project Metadata + operationId: update_project_metadata + parameters: + - required: true + schema: + type: string + format: uuid + title: Project Id + name: project_id + in: path + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectMetadataUpdate' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope_ProjectMetadataGet_' +components: + schemas: + Envelope_ProjectMetadataGet_: + properties: + data: + $ref: '#/components/schemas/ProjectMetadataGet' + error: + title: Error + type: object + title: Envelope[ProjectMetadataGet] + ProjectMetadataGet: + properties: + projectUuid: + type: string + format: uuid + title: Projectuuid + custom: + additionalProperties: + anyOf: + - type: boolean + - type: integer + - type: number + - type: string + type: object + title: Custom + description: Custom key-value map + type: object + required: + - projectUuid + title: ProjectMetadataGet + ProjectMetadataUpdate: + properties: + custom: + additionalProperties: + anyOf: + - type: boolean + - type: integer + - type: number + - type: string + type: object + title: Custom + type: object + required: + - custom + title: ProjectMetadataUpdate diff --git a/api/specs/webserver/openapi.yaml b/api/specs/webserver/openapi.yaml index 0500acd5432..68669071337 100644 --- a/api/specs/webserver/openapi.yaml +++ b/api/specs/webserver/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.0.0 info: title: "osparc-simcore web API" - version: 0.23.0 + version: 0.24.0 description: "API designed for the front-end app" contact: name: IT'IS Foundation @@ -256,6 +256,9 @@ paths: /projects/{project_id}/outputs: $ref: "./openapi-projects-ports.yaml#/paths/~1projects~1{project_id}~1outputs" + /projects/{project_id}/metadata: + $ref: "./openapi-projects-metadata.yaml#/paths/~1projects~1{project_id}~1metadata" + /projects/{project_id}/metadata/ports: $ref: "./openapi-projects-ports.yaml#/paths/~1projects~1{project_id}~1metadata~1ports" diff --git a/api/specs/webserver/scripts/openapi_projects_metadata.py b/api/specs/webserver/scripts/openapi_projects_metadata.py new file mode 100644 index 00000000000..18469a627d4 --- /dev/null +++ b/api/specs/webserver/scripts/openapi_projects_metadata.py @@ -0,0 +1,63 @@ +""" Helper script to automatically generate OAS + +This OAS are the source of truth +""" + +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments + + +from enum import Enum +from typing import Annotated + +from _common import CURRENT_DIR, create_openapi_specs +from fastapi import Depends, FastAPI, status +from models_library.api_schemas_webserver.projects_metadata import ( + ProjectMetadataGet, + ProjectMetadataUpdate, +) +from models_library.generics import Envelope +from simcore_service_webserver.projects._metadata_handlers import ( + ProjectMetadataGet, + ProjectPathParams, +) + +app = FastAPI(redoc_url=None) + +TAGS: list[str | Enum] = ["project"] + + +# +# API entrypoints +# + + +@app.get( + "/projects/{project_id}/metadata", + response_model=Envelope[ProjectMetadataGet], + tags=TAGS, + operation_id="get_project_metadata", + status_code=status.HTTP_200_OK, +) +async def get_project_metadata(_params: Annotated[ProjectPathParams, Depends()]): + ... + + +@app.patch( + "/projects/{project_id}/metadata", + response_model=Envelope[ProjectMetadataGet], + tags=TAGS, + operation_id="update_project_metadata", + status_code=status.HTTP_200_OK, +) +async def update_project_metadata( + _params: Annotated[ProjectPathParams, Depends()], _body: ProjectMetadataUpdate +): + ... + + +if __name__ == "__main__": + + create_openapi_specs(app, CURRENT_DIR.parent / "openapi-projects-metadata.yaml") diff --git a/packages/models-library/src/models_library/api_schemas_webserver/projects_metadata.py b/packages/models-library/src/models_library/api_schemas_webserver/projects_metadata.py new file mode 100644 index 00000000000..c108dcd2fc2 --- /dev/null +++ b/packages/models-library/src/models_library/api_schemas_webserver/projects_metadata.py @@ -0,0 +1,21 @@ +from typing import TypeAlias + +from pydantic import Field, StrictBool, StrictFloat, StrictInt + +from ..projects import ProjectID +from ._base import InputSchema, OutputSchema + +# Limits metadata values +MetaValueType: TypeAlias = StrictBool | StrictInt | StrictFloat | str +MetadataDict: TypeAlias = dict[str, MetaValueType] + + +class ProjectMetadataGet(OutputSchema): + project_uuid: ProjectID + custom: MetadataDict = Field( + default_factory=dict, description="Custom key-value map" + ) + + +class ProjectMetadataUpdate(InputSchema): + custom: MetadataDict diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/f3285aff5e84_new_projects_metadata_table.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/f3285aff5e84_new_projects_metadata_table.py new file mode 100644 index 00000000000..0e4dc98204e --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/f3285aff5e84_new_projects_metadata_table.py @@ -0,0 +1,97 @@ +"""new projects_metadata table + +Revision ID: f3285aff5e84 +Revises: 58b24613c3f7 +Create Date: 2023-07-05 15:06:56.003418+00:00 + +""" +from typing import Final + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "f3285aff5e84" +down_revision = "58b24613c3f7" +branch_labels = None +depends_on = None + + +# auto-update modified +# TRIGGERS ------------------------ +_TABLE_NAME: Final[str] = "projects_metadata" +_TRIGGER_NAME: Final[str] = "trigger_auto_update" # NOTE: scoped on table +_PROCEDURE_NAME: Final[ + str +] = f"{_TABLE_NAME}_auto_update_modified()" # NOTE: scoped on database +modified_timestamp_trigger = sa.DDL( + f""" +DROP TRIGGER IF EXISTS {_TRIGGER_NAME} on {_TABLE_NAME}; +CREATE TRIGGER {_TRIGGER_NAME} +BEFORE INSERT OR UPDATE ON {_TABLE_NAME} +FOR EACH ROW EXECUTE PROCEDURE {_PROCEDURE_NAME}; + """ +) + +# PROCEDURES ------------------------ +update_modified_timestamp_procedure = sa.DDL( + f""" +CREATE OR REPLACE FUNCTION {_PROCEDURE_NAME} +RETURNS TRIGGER AS $$ +BEGIN + NEW.modified := current_timestamp; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + """ +) + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "projects_metadata", + sa.Column("project_uuid", sa.String(), nullable=False), + sa.Column( + "custom", + postgresql.JSONB(astext_type=sa.Text()), + server_default=sa.text("'{}'::jsonb"), + nullable=False, + ), + sa.Column( + "created", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "modified", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["project_uuid"], + ["projects.uuid"], + name="fk_projects_metadata_project_uuid", + onupdate="CASCADE", + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("project_uuid"), + ) + # ### end Alembic commands ### + + # custom + op.execute(update_modified_timestamp_procedure) + op.execute(modified_timestamp_trigger) + + +def downgrade(): + # custom + op.execute(f"DROP TRIGGER IF EXISTS {_TRIGGER_NAME} on {_TABLE_NAME};") + op.execute(f"DROP FUNCTION {_PROCEDURE_NAME};") + + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("projects_metadata") + # ### end Alembic commands ### diff --git a/packages/postgres-database/src/simcore_postgres_database/models/projects_metadata.py b/packages/postgres-database/src/simcore_postgres_database/models/projects_metadata.py new file mode 100644 index 00000000000..2a450e5a80c --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/models/projects_metadata.py @@ -0,0 +1,57 @@ +""" + These tables were designed to be controled by projects-plugin in + the webserver's service +""" + +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB + +from ._common import ( + column_created_datetime, + column_modified_datetime, + register_modified_datetime_auto_update_trigger, +) +from .base import metadata +from .projects import projects + +projects_metadata = sa.Table( + "projects_metadata", + # + # Keeps "third-party" metadata attached to a project + # + # These SHOULD NOT be actual properties of the project (e.g. uuid, name etc) + # but rather information attached by third-parties that "decorate" or qualify + # a project resource + # + # Things like 'stars', 'quality', 'classifiers', 'dev', etc (or any kind of stats) + # should be moved here + # + metadata, + sa.Column( + "project_uuid", + sa.String, + sa.ForeignKey( + projects.c.uuid, + onupdate="CASCADE", + ondelete="CASCADE", + name="fk_projects_metadata_project_uuid", + ), + nullable=False, + primary_key=True, + doc="The project unique identifier is also used to identify the associated job", + ), + sa.Column( + "custom", + JSONB, + nullable=False, + server_default=sa.text("'{}'::jsonb"), + doc="Reserved for the user to store custom metadata", + ), + # TIME STAMPS ----ß + column_created_datetime(timezone=True), + column_modified_datetime(timezone=True), + sa.PrimaryKeyConstraint("project_uuid"), +) + + +register_modified_datetime_auto_update_trigger(projects_metadata) diff --git a/packages/postgres-database/src/simcore_postgres_database/utils_projects_metadata.py b/packages/postgres-database/src/simcore_postgres_database/utils_projects_metadata.py new file mode 100644 index 00000000000..dafca9ffdad --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/utils_projects_metadata.py @@ -0,0 +1,92 @@ +import datetime +import uuid +from dataclasses import dataclass +from typing import Any + +import sqlalchemy as sa +from aiopg.sa.connection import SAConnection +from aiopg.sa.result import ResultProxy, RowProxy +from simcore_postgres_database.models.projects_metadata import projects_metadata +from sqlalchemy.dialects.postgresql import insert as pg_insert + +from .errors import ForeignKeyViolation +from .models.projects import projects +from .utils_models import FromRowMixin + +# +# Errors +# + + +class DBProjectNotFoundError(Exception): + ... + + +# +# Data +# + + +@dataclass(frozen=True, slots=True, kw_only=True) +class ProjectMetadata(FromRowMixin): + custom: dict[str, Any] | None + created: datetime.datetime | None + modified: datetime.datetime | None + + +# +# Helpers +# + + +async def get(connection: SAConnection, project_uuid: uuid.UUID) -> ProjectMetadata: + # JOIN LEFT OUTER + get_stmt = ( + sa.select( + projects.c.uuid, + projects_metadata.c.custom, + projects_metadata.c.created, + projects_metadata.c.modified, + ) + .select_from( + sa.join( + projects, + projects_metadata, + projects.c.uuid == projects_metadata.c.project_uuid, + isouter=True, + ) + ) + .where(projects.c.uuid == f"{project_uuid}") + ) + result: ResultProxy = await connection.execute(get_stmt) + row: RowProxy | None = await result.first() + if row is None: + msg = f"Project project_uuid={project_uuid!r} not found" + raise DBProjectNotFoundError(msg) + return ProjectMetadata.from_row(row) + + +async def upsert( + connection: SAConnection, + *, + project_uuid: uuid.UUID, + custom_metadata: dict[str, Any], +) -> ProjectMetadata: + data = { + "project_uuid": f"{project_uuid}", + "custom": custom_metadata, + } + insert_stmt = pg_insert(projects_metadata).values(**data) + upsert_stmt = insert_stmt.on_conflict_do_update( + index_elements=[projects_metadata.c.project_uuid], + set_=data, + ).returning(sa.literal_column("*")) + + try: + result: ResultProxy = await connection.execute(upsert_stmt) + row: RowProxy | None = await result.first() + assert row # nosec + return ProjectMetadata.from_row(row) + + except ForeignKeyViolation as err: + raise DBProjectNotFoundError(project_uuid) from err diff --git a/packages/postgres-database/tests/test_utils_projects_metadata.py b/packages/postgres-database/tests/test_utils_projects_metadata.py new file mode 100644 index 00000000000..1cfce5e2e2a --- /dev/null +++ b/packages/postgres-database/tests/test_utils_projects_metadata.py @@ -0,0 +1,82 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments + +from collections.abc import Awaitable, Callable + +import pytest +from aiopg.sa.connection import SAConnection +from aiopg.sa.result import RowProxy +from faker import Faker +from simcore_postgres_database import utils_projects_metadata +from simcore_postgres_database.utils_projects_metadata import DBProjectNotFoundError + + +@pytest.fixture +async def fake_user( + connection: SAConnection, + create_fake_user: Callable[..., Awaitable[RowProxy]], +) -> RowProxy: + user: RowProxy = await create_fake_user(connection, name=f"user.{__name__}") + return user + + +@pytest.fixture +async def fake_project( + connection: SAConnection, + fake_user: RowProxy, + create_fake_project: Callable[..., Awaitable[RowProxy]], +) -> RowProxy: + project: RowProxy = await create_fake_project(connection, fake_user, hidden=True) + return project + + +@pytest.mark.acceptance_test( + "For https://github.com/ITISFoundation/osparc-simcore/issues/4313" +) +async def test_projects_metadata_repository( + connection: SAConnection, + create_fake_user: Callable[..., Awaitable[RowProxy]], + create_fake_project: Callable[..., Awaitable[RowProxy]], + faker: Faker, +): + user: RowProxy = await create_fake_user(connection) + project: RowProxy = await create_fake_project(connection, user, hidden=True) + + # subresource is attached to parent + user_metadata = {"float": 3.14, "int": 42, "string": "foo", "bool": True} + + with pytest.raises(DBProjectNotFoundError): + await utils_projects_metadata.get(connection, project_uuid=faker.uuid4()) + + with pytest.raises(DBProjectNotFoundError): + await utils_projects_metadata.upsert( + connection, project_uuid=faker.uuid4(), custom_metadata=user_metadata + ) + + project_metadata = await utils_projects_metadata.get( + connection, project_uuid=project["uuid"] + ) + assert project_metadata is not None + assert project_metadata.custom is None + + got = await utils_projects_metadata.upsert( + connection, project_uuid=project["uuid"], custom_metadata=user_metadata + ) + assert got.custom + assert user_metadata == got.custom + + project_metadata = await utils_projects_metadata.get( + connection, project_uuid=project["uuid"] + ) + assert project_metadata is not None + assert project_metadata == got + + got_after_update = await utils_projects_metadata.upsert( + connection, project_uuid=project["uuid"], custom_metadata={} + ) + assert got_after_update.custom == {} + assert got.modified + assert got_after_update.modified + assert got.modified < got_after_update.modified diff --git a/services/api-server/src/simcore_service_api_server/api/routes/solvers.py b/services/api-server/src/simcore_service_api_server/api/routes/solvers.py index 57380050d7a..d671058d6c6 100644 --- a/services/api-server/src/simcore_service_api_server/api/routes/solvers.py +++ b/services/api-server/src/simcore_service_api_server/api/routes/solvers.py @@ -93,7 +93,7 @@ async def list_solvers_releases( @router.get( - "releases/page", + "/releases/page", response_model=LimitOffsetPage[Solver], include_in_schema=API_SERVER_DEV_FEATURES_ENABLED, ) diff --git a/services/web/server/VERSION b/services/web/server/VERSION index ca222b7cf39..2094a100ca8 100644 --- a/services/web/server/VERSION +++ b/services/web/server/VERSION @@ -1 +1 @@ -0.23.0 +0.24.0 diff --git a/services/web/server/setup.cfg b/services/web/server/setup.cfg index e54fab08a88..80bfd8bb744 100644 --- a/services/web/server/setup.cfg +++ b/services/web/server/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.23.0 +current_version = 0.24.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 5b714a0f061..9f91c15b7ff 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 @@ -1,7 +1,7 @@ openapi: 3.0.0 info: title: osparc-simcore web API - version: 0.23.0 + version: 0.24.0 description: API designed for the front-end app contact: name: IT'IS Foundation @@ -6310,6 +6310,90 @@ paths: type: string error: title: Error + '/projects/{project_id}/metadata': + get: + tags: + - project + summary: Get Project Metadata + operationId: get_project_metadata + parameters: + - required: true + schema: + type: string + format: uuid + title: Project Id + name: project_id + in: path + responses: + '200': + description: Successful Response + content: + application/json: + schema: + properties: + data: + properties: + projectUuid: + type: string + format: uuid + title: Projectuuid + custom: + additionalProperties: + anyOf: + - type: boolean + - type: integer + - type: number + - type: string + type: object + title: Custom + description: Custom key-value map + type: object + required: + - projectUuid + title: ProjectMetadataGet + error: + title: Error + type: object + title: 'Envelope[ProjectMetadataGet]' + patch: + tags: + - project + summary: Update Project Metadata + operationId: update_project_metadata + parameters: + - required: true + schema: + type: string + format: uuid + title: Project Id + name: project_id + in: path + requestBody: + content: + application/json: + schema: + properties: + custom: + additionalProperties: + anyOf: + - type: boolean + - type: integer + - type: number + - type: string + type: object + title: Custom + type: object + required: + - custom + title: ProjectMetadataUpdate + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/paths/~1projects~1%7Bproject_id%7D~1metadata/get/responses/200/content/application~1json/schema' '/projects/{project_id}/metadata/ports': get: tags: diff --git a/services/web/server/src/simcore_service_webserver/exporter/_formatter/_sds.py b/services/web/server/src/simcore_service_webserver/exporter/_formatter/_sds.py index d89e65e8221..b7f6e2f5fbd 100644 --- a/services/web/server/src/simcore_service_webserver/exporter/_formatter/_sds.py +++ b/services/web/server/src/simcore_service_webserver/exporter/_formatter/_sds.py @@ -8,7 +8,7 @@ from servicelib.pools import non_blocking_process_pool_executor from ...catalog.client import get_service -from ...projects.exceptions import ProjectsException +from ...projects.exceptions import BaseProjectError from ...projects.projects_api import get_project_for_user from ...scicrunch.db import ResearchResourceRepository from ..exceptions import SDSException @@ -56,7 +56,7 @@ async def create_sds_directory( user_id=user_id, include_state=True, ) - except ProjectsException as e: + except BaseProjectError as e: raise SDSException(f"Could not find project {project_id}") from e _logger.debug("Project data: %s", project_data) diff --git a/services/web/server/src/simcore_service_webserver/projects/_access_rights_api.py b/services/web/server/src/simcore_service_webserver/projects/_access_rights_api.py new file mode 100644 index 00000000000..40e1b850233 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/projects/_access_rights_api.py @@ -0,0 +1,21 @@ +from aiohttp import web +from models_library.projects import ProjectID +from models_library.users import UserID + +from ..db.plugin import get_database_engine +from ._access_rights_db import get_project_owner +from .exceptions import ProjectInvalidRightsError + + +async def validate_project_ownership( + app: web.Application, user_id: UserID, project_uuid: ProjectID +): + """ + Raises: + ProjectInvalidRightsError: if 'user_id' does not own 'project_uuid' + """ + if ( + await get_project_owner(get_database_engine(app), project_uuid=project_uuid) + != user_id + ): + raise ProjectInvalidRightsError(user_id=user_id, project_uuid=project_uuid) diff --git a/services/web/server/src/simcore_service_webserver/projects/_access_rights_db.py b/services/web/server/src/simcore_service_webserver/projects/_access_rights_db.py new file mode 100644 index 00000000000..c6b5b45099d --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/projects/_access_rights_db.py @@ -0,0 +1,19 @@ +import sqlalchemy +from aiopg.sa.engine import Engine +from models_library.projects import ProjectID +from models_library.users import UserID +from simcore_postgres_database.models.projects import projects + +from .exceptions import ProjectNotFoundError + + +async def get_project_owner(engine: Engine, project_uuid: ProjectID) -> UserID: + async with engine.acquire() as connection: + stmt = sqlalchemy.select(projects.c.prj_owner).where( + projects.c.uuid == f"{project_uuid}" + ) + + owner_id = await connection.scalar(stmt) + if owner_id is None: + raise ProjectNotFoundError(project_uuid=project_uuid) + return owner_id diff --git a/services/web/server/src/simcore_service_webserver/projects/_common_models.py b/services/web/server/src/simcore_service_webserver/projects/_common_models.py new file mode 100644 index 00000000000..3a79cead257 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/projects/_common_models.py @@ -0,0 +1,25 @@ +""" Handlers for STANDARD methods on /projects colletions + +Standard methods or CRUD that states for Create+Read(Get&List)+Update+Delete + +""" + +from models_library.projects import ProjectID +from models_library.users import UserID +from pydantic import BaseModel, Extra, Field +from servicelib.request_keys import RQT_USERID_KEY + +from .._constants import RQ_PRODUCT_KEY + + +class RequestContext(BaseModel): + user_id: UserID = Field(..., alias=RQT_USERID_KEY) # type: ignore[pydantic-alias] + product_name: str = Field(..., alias=RQ_PRODUCT_KEY) # type: ignore[pydantic-alias] + + +class ProjectPathParams(BaseModel): + project_id: ProjectID + + class Config: + allow_population_by_field_name = True + extra = Extra.forbid diff --git a/services/web/server/src/simcore_service_webserver/projects/_crud_create_utils.py b/services/web/server/src/simcore_service_webserver/projects/_crud_create_utils.py index ce6caeeb3f4..d006e430ffb 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_crud_create_utils.py +++ b/services/web/server/src/simcore_service_webserver/projects/_crud_create_utils.py @@ -1,8 +1,9 @@ import asyncio import logging +from collections.abc import Coroutine from contextlib import AsyncExitStack from dataclasses import asdict -from typing import Any, Coroutine, TypeAlias +from typing import Any, TypeAlias from aiohttp import web from jsonschema import ValidationError as JsonSchemaValidationError @@ -120,7 +121,7 @@ async def _copy_project_nodes_from_source_project( def _mapped_node_id(node: ProjectNode) -> NodeID: return NodeID(nodes_map[NodeIDStr(f"{node.node_id}")]) - dst_project_node_creates = { + return { _mapped_node_id(node): ProjectNodeCreate( node_id=_mapped_node_id(node), **{ @@ -131,7 +132,6 @@ def _mapped_node_id(node: ProjectNode) -> NodeID: ) for node in await db.list_project_nodes(ProjectID(source_project["uuid"])) } - return dst_project_node_creates async def _copy_files_from_source_project( diff --git a/services/web/server/src/simcore_service_webserver/projects/_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_handlers.py index e840574a8ba..ce2324f04ed 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_handlers.py @@ -37,8 +37,8 @@ from .exceptions import ( ProjectInvalidRightsError, ProjectNotFoundError, - ProjectStartsTooManyDynamicNodes, - ProjectTooManyProjectOpened, + ProjectStartsTooManyDynamicNodesError, + ProjectTooManyProjectOpenedError, ) log = logging.getLogger(__name__) @@ -105,7 +105,7 @@ async def open_project(request: web.Request) -> web.Response: # user id opened project uuid if not query_params.disable_service_auto_start: - with contextlib.suppress(ProjectStartsTooManyDynamicNodes): + with contextlib.suppress(ProjectStartsTooManyDynamicNodesError): # NOTE: this method raises that exception when the number of dynamic # services in the project is highter than the maximum allowed per project # the project shall still open though. @@ -148,7 +148,7 @@ async def open_project(request: web.Request) -> web.Response: raise web.HTTPServiceUnavailable( reason="Unexpected error while starting services." ) from exc - except ProjectTooManyProjectOpened as exc: + except ProjectTooManyProjectOpenedError as exc: raise web.HTTPConflict(reason=f"{exc}") from exc except ProjectInvalidRightsError as exc: raise web.HTTPForbidden( diff --git a/services/web/server/src/simcore_service_webserver/projects/_handlers_crud.py b/services/web/server/src/simcore_service_webserver/projects/_handlers_crud.py index 588e77762e3..11e0659983d 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_handlers_crud.py +++ b/services/web/server/src/simcore_service_webserver/projects/_handlers_crud.py @@ -23,7 +23,6 @@ Page, ) from models_library.rest_pagination_utils import paginate_data -from models_library.users import UserID from models_library.utils.fastapi_encoders import jsonable_encoder from pydantic import BaseModel, Extra, Field, NonNegativeInt, validator from servicelib.aiohttp.long_running_tasks.server import start_long_running_task @@ -38,10 +37,8 @@ ) from servicelib.json_serialization import json_dumps from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON -from servicelib.request_keys import RQT_USERID_KEY from servicelib.rest_constants import RESPONSE_MODEL_POLICY -from .._constants import RQ_PRODUCT_KEY from .._meta import api_version_prefix as VTAG from ..catalog.client import get_services_for_user_in_product from ..director_v2 import api @@ -51,6 +48,7 @@ from ..security.decorators import permission_required from ..users.api import get_user_name from . import _crud_create_utils, _crud_read_utils, projects_api +from ._common_models import ProjectPathParams, RequestContext from ._crud_read_utils import OrderDirection, ProjectListFilters, ProjectOrderBy from ._permalink_api import update_or_pop_permalink_in_project from .db import ProjectDBAPI @@ -76,24 +74,11 @@ RQ_REQUESTED_REPO_PROJECT_UUID_KEY = f"{__name__}.RQT_REQUESTED_REPO_PROJECT_UUID_KEY" -log = logging.getLogger(__name__) +_logger = logging.getLogger(__name__) routes = web.RouteTableDef() -class RequestContext(BaseModel): - user_id: UserID = Field(..., alias=RQT_USERID_KEY) # type: ignore[pydantic-alias] - product_name: str = Field(..., alias=RQ_PRODUCT_KEY) # type: ignore[pydantic-alias] - - -class ProjectPathParams(BaseModel): - project_id: ProjectID - - class Config: - allow_population_by_field_name = True - extra = Extra.forbid - - # # - Create https://google.aip.dev/133 # @@ -105,15 +90,15 @@ class _ProjectCreateParams(BaseModel): description="Option to create a project from existing template or study: from_study={study_uuid}", ) as_template: bool = Field( - False, + default=False, description="Option to create a template from existing project: as_template=true", ) copy_data: bool = Field( - True, + default=True, description="Option to copy data when creating from an existing template or as a template, defaults to True", ) hidden: bool = Field( - False, + default=False, description="Enables/disables hidden flag. Hidden projects are by default unlisted", ) @@ -227,9 +212,8 @@ def sort_by_should_have_special_format(cls, v): if field_info[1] == OrderDirection.DESC.value: direction = OrderDirection.DESC else: - raise ValueError( - "Field direction in the order_by parameter must contain either 'desc' direction or empty value for 'asc' direction." - ) + msg = "Field direction in the order_by parameter must contain either 'desc' direction or empty value for 'asc' direction." + raise ValueError(msg) parse_fields_with_direction.append( ProjectOrderBy(field=field_name, direction=direction) diff --git a/services/web/server/src/simcore_service_webserver/projects/_metadata_api.py b/services/web/server/src/simcore_service_webserver/projects/_metadata_api.py new file mode 100644 index 00000000000..433a863540c --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/projects/_metadata_api.py @@ -0,0 +1,30 @@ +from aiohttp import web +from models_library.api_schemas_webserver.projects_metadata import MetadataDict +from models_library.projects import ProjectID +from models_library.users import UserID + +from ..db.plugin import get_database_engine +from . import _metadata_db +from ._access_rights_api import validate_project_ownership + + +async def get_project_metadata( + app: web.Application, user_id: UserID, project_uuid: ProjectID +) -> MetadataDict: + await validate_project_ownership(app, user_id=user_id, project_uuid=project_uuid) + + return await _metadata_db.get_project_metadata( + engine=get_database_engine(app), project_uuid=project_uuid + ) + + +async def set_project_custom_metadata( + app: web.Application, user_id: UserID, project_uuid: ProjectID, value: MetadataDict +) -> MetadataDict: + await validate_project_ownership(app, user_id=user_id, project_uuid=project_uuid) + + return await _metadata_db.set_project_metadata( + engine=get_database_engine(app), + project_uuid=project_uuid, + custom_metadata=value, + ) diff --git a/services/web/server/src/simcore_service_webserver/projects/_metadata_db.py b/services/web/server/src/simcore_service_webserver/projects/_metadata_db.py new file mode 100644 index 00000000000..1ce0b1cd24c --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/projects/_metadata_db.py @@ -0,0 +1,56 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from aiopg.sa.connection import SAConnection +from aiopg.sa.engine import Engine +from models_library.api_schemas_webserver.projects_metadata import MetadataDict +from models_library.projects import ProjectID +from pydantic import parse_obj_as +from simcore_postgres_database import utils_projects_metadata +from simcore_postgres_database.utils_projects_metadata import DBProjectNotFoundError + +from .exceptions import ProjectNotFoundError + + +@asynccontextmanager +async def _acquire_and_handle( + engine: Engine, project_uuid: ProjectID +) -> AsyncIterator[SAConnection]: + try: + async with engine.acquire() as connection: + + yield connection + + except DBProjectNotFoundError as err: + raise ProjectNotFoundError(project_uuid=project_uuid) from err + + +async def get_project_metadata(engine: Engine, project_uuid: ProjectID) -> MetadataDict: + """ + Raises: + ProjectNotFoundError + ValidationError: illegal metadata format in the database + """ + async with _acquire_and_handle(engine, project_uuid) as connection: + metadata = await utils_projects_metadata.get( + connection, project_uuid=project_uuid + ) + # NOTE: if no metadata in table, it returns None -- which converts here to --> {} + return parse_obj_as(MetadataDict, metadata.custom or {}) + + +async def set_project_metadata( + engine: Engine, + project_uuid: ProjectID, + custom_metadata: MetadataDict, +) -> MetadataDict: + """ + Raises: + ProjectNotFoundError + ValidationError: illegal metadata format in the database + """ + async with _acquire_and_handle(engine, project_uuid) as connection: + metadata = await utils_projects_metadata.upsert( + connection, project_uuid=project_uuid, custom_metadata=custom_metadata + ) + return parse_obj_as(MetadataDict, metadata.custom) diff --git a/services/web/server/src/simcore_service_webserver/projects/_metadata_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_metadata_handlers.py new file mode 100644 index 00000000000..ff5eaf24e14 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/projects/_metadata_handlers.py @@ -0,0 +1,100 @@ +""" + +Design rationale: + +- Resource metadata/labels: https://cloud.google.com/apis/design/design_patterns#resource_labels + - named `metadata` instead of labels + - limit number of entries and depth? dict[str, st] ?? +- Singleton https://cloud.google.com/apis/design/design_patterns#singleton_resources + - the singleton is implicitly created or deleted when its parent is created or deleted + - Get and Update methods only +""" + + +import functools + +from aiohttp import web +from models_library.api_schemas_webserver.projects_metadata import ( + ProjectMetadataGet, + ProjectMetadataUpdate, +) +from servicelib.aiohttp.requests_validation import ( + parse_request_body_as, + parse_request_path_parameters_as, +) +from servicelib.aiohttp.typing_extension import Handler +from simcore_service_webserver.utils_aiohttp import envelope_json_response + +from .._meta import api_version_prefix +from ..login.decorators import login_required +from ..security.decorators import permission_required +from . import _metadata_api +from ._common_models import ProjectPathParams, RequestContext +from .exceptions import ProjectInvalidRightsError, ProjectNotFoundError + +routes = web.RouteTableDef() + + +def _handle_project_exceptions(handler: Handler): + """Transforms project errors -> http errors""" + + @functools.wraps(handler) + async def wrapper(request: web.Request) -> web.StreamResponse: + try: + return await handler(request) + + except ProjectNotFoundError as exc: + raise web.HTTPNotFound(reason=f"{exc}") from exc + except ProjectInvalidRightsError as exc: + raise web.HTTPUnauthorized(reason=f"{exc}") from exc + + return wrapper + + +# +# projects/*/custom-metadata +# + + +@routes.get( + f"/{api_version_prefix}/projects/{{project_id}}/metadata", + name="get_project_metadata", +) +@login_required +@permission_required("project.read") +@_handle_project_exceptions +async def get_project_metadata(request: web.Request) -> web.Response: + req_ctx = RequestContext.parse_obj(request) + path_params = parse_request_path_parameters_as(ProjectPathParams, request) + + custom_metadata = await _metadata_api.get_project_metadata( + request.app, user_id=req_ctx.user_id, project_uuid=path_params.project_id + ) + + return envelope_json_response( + ProjectMetadataGet(project_uuid=path_params.project_id, custom=custom_metadata) + ) + + +@routes.patch( + f"/{api_version_prefix}/projects/{{project_id}}/metadata", + name="update_project_metadata", +) +@login_required +@permission_required("project.update") +@_handle_project_exceptions +async def update_project_metadata(request: web.Request) -> web.Response: + req_ctx = RequestContext.parse_obj(request) + path_params = parse_request_path_parameters_as(ProjectPathParams, request) + update = await parse_request_body_as(ProjectMetadataUpdate, request) + + custom_metadata = await _metadata_api.set_project_custom_metadata( + request.app, + user_id=req_ctx.user_id, + project_uuid=path_params.project_id, + value=update.custom, + ) + + return envelope_json_response( + ProjectMetadataGet(project_uuid=path_params.project_id, custom=custom_metadata) + ) diff --git a/services/web/server/src/simcore_service_webserver/projects/_nodes_api.py b/services/web/server/src/simcore_service_webserver/projects/_nodes_api.py index 169e9c3f790..15b04017c06 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_nodes_api.py +++ b/services/web/server/src/simcore_service_webserver/projects/_nodes_api.py @@ -24,7 +24,7 @@ from .._constants import APP_SETTINGS_KEY, RQT_USERID_KEY from ..application_settings import get_settings from ..storage.api import get_download_link -from .exceptions import ProjectStartsTooManyDynamicNodes +from .exceptions import ProjectStartsTooManyDynamicNodesError _logger = logging.getLogger(__name__) _NODE_START_INTERVAL_S: Final[datetime.timedelta] = datetime.timedelta(seconds=15) @@ -48,7 +48,7 @@ def check_num_service_per_projects_limit( if project_settings.PROJECTS_MAX_NUM_RUNNING_DYNAMIC_NODES > 0 and ( number_of_services >= project_settings.PROJECTS_MAX_NUM_RUNNING_DYNAMIC_NODES ): - raise ProjectStartsTooManyDynamicNodes( + raise ProjectStartsTooManyDynamicNodesError( user_id=user_id, project_uuid=project_uuid ) diff --git a/services/web/server/src/simcore_service_webserver/projects/_nodes_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_nodes_handlers.py index f2591d96546..4c6681ea153 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_nodes_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_nodes_handlers.py @@ -55,7 +55,7 @@ ProjectNodeResourcesInsufficientRightsError, ProjectNodeResourcesInvalidError, ProjectNotFoundError, - ProjectStartsTooManyDynamicNodes, + ProjectStartsTooManyDynamicNodesError, ) log = logging.getLogger(__name__) @@ -244,7 +244,7 @@ async def start_node(request: web.Request) -> web.Response: raise web.HTTPNoContent(content_type=MIMETYPE_APPLICATION_JSON) - except ProjectStartsTooManyDynamicNodes as exc: + except ProjectStartsTooManyDynamicNodesError as exc: raise web.HTTPConflict(reason=f"{exc}") from exc diff --git a/services/web/server/src/simcore_service_webserver/projects/exceptions.py b/services/web/server/src/simcore_service_webserver/projects/exceptions.py index a45fe87893c..83d7b47f24d 100644 --- a/services/web/server/src/simcore_service_webserver/projects/exceptions.py +++ b/services/web/server/src/simcore_service_webserver/projects/exceptions.py @@ -6,7 +6,7 @@ from models_library.users import UserID -class ProjectsException(Exception): +class BaseProjectError(Exception): """Basic exception for errors raised in projects""" def __init__(self, msg=None): @@ -17,7 +17,7 @@ def detailed_message(self): return f"{type(self)}: {self}" -class ProjectInvalidRightsError(ProjectsException): +class ProjectInvalidRightsError(BaseProjectError): """Invalid rights to access project""" def __init__(self, user_id, project_uuid): @@ -28,7 +28,7 @@ def __init__(self, user_id, project_uuid): self.project_uuid = project_uuid -class ProjectOwnerNotFoundError(ProjectsException): +class ProjectOwnerNotFoundError(BaseProjectError): """Project owner was not found""" def __init__(self, project_uuid): @@ -36,7 +36,7 @@ def __init__(self, project_uuid): self.project_uuid = project_uuid -class ProjectNotFoundError(ProjectsException): +class ProjectNotFoundError(BaseProjectError): """Project was not found in DB""" def __init__(self, project_uuid, *, search_context: Any | None = None): @@ -52,13 +52,13 @@ def detailed_message(self): return msg -class ProjectDeleteError(ProjectsException): +class ProjectDeleteError(BaseProjectError): def __init__(self, project_uuid, reason): super().__init__(f"Failed to complete deletion of {project_uuid=}: {reason}") self.project_uuid = project_uuid -class NodeNotFoundError(ProjectsException): +class NodeNotFoundError(BaseProjectError): """Node was not found in project""" def __init__(self, project_uuid: str, node_uuid: str): @@ -70,7 +70,7 @@ def __init__(self, project_uuid: str, node_uuid: str): ProjectLockError = redis.exceptions.LockError -class ProjectStartsTooManyDynamicNodes(ProjectsException): +class ProjectStartsTooManyDynamicNodesError(BaseProjectError): """user tried to start too many nodes concurrently""" def __init__(self, user_id: UserID, project_uuid: ProjectID): @@ -81,24 +81,24 @@ def __init__(self, user_id: UserID, project_uuid: ProjectID): self.project_uuid = project_uuid -class ProjectTooManyProjectOpened(ProjectsException): +class ProjectTooManyProjectOpenedError(BaseProjectError): def __init__(self, max_num_projects: int): super().__init__( f"You cannot open more than {max_num_projects} stud{'y' if max_num_projects == 1 else 'ies'} at once. Please close another study and retry." ) -class PermalinkNotAllowedError(ProjectsException): +class PermalinkNotAllowedError(BaseProjectError): ... -class PermalinkFactoryError(ProjectsException): +class PermalinkFactoryError(BaseProjectError): ... -class ProjectNodeResourcesInvalidError(ProjectsException): +class ProjectNodeResourcesInvalidError(BaseProjectError): ... -class ProjectNodeResourcesInsufficientRightsError(ProjectsException): +class ProjectNodeResourcesInsufficientRightsError(BaseProjectError): ... diff --git a/services/web/server/src/simcore_service_webserver/projects/plugin.py b/services/web/server/src/simcore_service_webserver/projects/plugin.py index 93f6dcdb389..182de9fc0b1 100644 --- a/services/web/server/src/simcore_service_webserver/projects/plugin.py +++ b/services/web/server/src/simcore_service_webserver/projects/plugin.py @@ -18,6 +18,7 @@ _comments_handlers, _handlers, _handlers_crud, + _metadata_handlers, _nodes_handlers, _ports_handlers, _tags_handlers, @@ -34,7 +35,7 @@ def _create_routes(tag, specs, *handlers_module): for mod in handlers_module: handlers.update(get_handlers_from_namespace(mod)) - routes = map_handlers_with_operations( + return map_handlers_with_operations( handlers, filter( lambda o: tag in o.tags and "snapshot" not in o.path, @@ -43,8 +44,6 @@ def _create_routes(tag, specs, *handlers_module): strict=False, ) - return routes - @app_module_setup( "simcore_service_webserver.projects", @@ -71,6 +70,7 @@ def setup_projects(app: web.Application) -> bool: app.router.add_routes(_handlers.routes) app.router.add_routes(_handlers_crud.routes) app.router.add_routes(_comments_handlers.routes) + app.router.add_routes(_metadata_handlers.routes) app.router.add_routes(_ports_handlers.routes) app.router.add_routes( diff --git a/services/web/server/src/simcore_service_webserver/projects/projects_api.py b/services/web/server/src/simcore_service_webserver/projects/projects_api.py index 802874ea279..b0505732d8c 100644 --- a/services/web/server/src/simcore_service_webserver/projects/projects_api.py +++ b/services/web/server/src/simcore_service_webserver/projects/projects_api.py @@ -76,8 +76,8 @@ from .exceptions import ( NodeNotFoundError, ProjectLockError, - ProjectStartsTooManyDynamicNodes, - ProjectTooManyProjectOpened, + ProjectStartsTooManyDynamicNodesError, + ProjectTooManyProjectOpenedError, ) from .lock import get_project_locked_state, is_project_locked, lock_project from .models import ProjectDict @@ -324,7 +324,7 @@ async def add_project_node( ) if _is_node_dynamic(service_key): - with suppress(ProjectStartsTooManyDynamicNodes): + with suppress(ProjectStartsTooManyDynamicNodesError): # NOTE: we do not start the service if there are already too many await _start_dynamic_service( request, @@ -624,7 +624,7 @@ async def try_open_project_for_user( ) >= max_number_of_studies_per_user ): - raise ProjectTooManyProjectOpened( + raise ProjectTooManyProjectOpenedError( max_num_projects=max_number_of_studies_per_user ) @@ -991,7 +991,7 @@ async def run_project_dynamic_services( > project_settings.PROJECTS_MAX_NUM_RUNNING_DYNAMIC_NODES ): # we cannot start so many services so we are done - raise ProjectStartsTooManyDynamicNodes( + raise ProjectStartsTooManyDynamicNodesError( user_id=user_id, project_uuid=ProjectID(project["uuid"]) ) diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_handlers__delete.py b/services/web/server/tests/unit/with_dbs/02/test_projects_handlers__delete.py index 046471c4f51..8a0d7489226 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_handlers__delete.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_handlers__delete.py @@ -4,7 +4,8 @@ # pylint: disable=too-many-arguments -from typing import Any, Callable, Iterator +from collections.abc import Callable, Iterator +from typing import Any from unittest import mock from unittest.mock import MagicMock, call @@ -129,11 +130,11 @@ async def test_delete_multiple_opened_project_forbidden( redis_client, ): # service in project - service = await create_dynamic_service_mock(logged_user["id"], user_project["uuid"]) + await create_dynamic_service_mock(logged_user["id"], user_project["uuid"]) # open project in tab1 client_session_id1 = client_session_id_factory() try: - sio1 = await socketio_client_factory(client_session_id1) + await socketio_client_factory(client_session_id1) except SocketConnectionError: if user_role != UserRole.ANONYMOUS: pytest.fail("socket io connection should not fail") @@ -151,7 +152,7 @@ async def test_delete_multiple_opened_project_forbidden( # delete project in tab2 client_session_id2 = client_session_id_factory() try: - sio2 = await socketio_client_factory(client_session_id2) + await socketio_client_factory(client_session_id2) except SocketConnectionError: if user_role != UserRole.ANONYMOUS: pytest.fail("socket io connection should not fail") diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_metadata_handlers.py b/services/web/server/tests/unit/with_dbs/02/test_projects_metadata_handlers.py new file mode 100644 index 00000000000..65e9c4b93e6 --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_metadata_handlers.py @@ -0,0 +1,103 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments + +import json + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient +from faker import Faker +from models_library.api_schemas_webserver.projects_metadata import ( + ProjectMetadataGet, + ProjectMetadataUpdate, +) +from pydantic import parse_obj_as +from pytest_simcore.helpers.utils_assert import assert_status +from pytest_simcore.helpers.utils_login import UserInfoDict +from pytest_simcore.helpers.utils_webserver_unit_with_db import MockedStorageSubsystem +from simcore_postgres_database.models.users import UserRole +from simcore_service_webserver.projects import _crud_delete_utils +from simcore_service_webserver.projects.models import ProjectDict + + +@pytest.mark.acceptance_test( + "For https://github.com/ITISFoundation/osparc-simcore/issues/4313" +) +@pytest.mark.parametrize( + "user_role", + [ + UserRole.USER, + ], +) +async def test_custom_metadata_handlers( + # for deletion + mocked_director_v2_api: None, + storage_subsystem_mock: MockedStorageSubsystem, + # + client: TestClient, + faker: Faker, + logged_user: UserInfoDict, + user_project: ProjectDict, +): + # + # metadata is a singleton subresource of a project + # a singleton is implicitly created or deleted when its parent is created or deleted + # + assert client.app + + # get metadata of a non-existing project -> Not found + invalid_project_id = faker.uuid4() + url = client.app.router["get_project_metadata"].url_for( + project_id=invalid_project_id + ) + response = await client.get(f"{url}") + + _, error = await assert_status(response, expected_cls=web.HTTPNotFound) + error_message = error["errors"][0]["message"] + assert invalid_project_id in error_message + assert "project" in error_message.lower() + + # get metadata of an existing project the first time -> empty {} + url = client.app.router["get_project_metadata"].url_for( + project_id=user_project["uuid"] + ) + response = await client.get(f"{url}") + data, _ = await assert_status(response, expected_cls=web.HTTPOk) + assert data["custom"] == {} + + # replace metadata + custom_metadata = {"number": 3.14, "string": "str", "boolean": False} + custom_metadata["other"] = json.dumps(custom_metadata) + + url = client.app.router["update_project_metadata"].url_for( + project_id=user_project["uuid"] + ) + response = await client.patch( + f"{url}", json=ProjectMetadataUpdate(custom=custom_metadata).dict() + ) + + data, _ = await assert_status(response, expected_cls=web.HTTPOk) + + assert parse_obj_as(ProjectMetadataGet, data).custom == custom_metadata + + # delete project + url = client.app.router["delete_project"].url_for(project_id=user_project["uuid"]) + response = await client.delete(f"{url}") + await assert_status(response, expected_cls=web.HTTPNoContent) + + async def _wait_until_deleted(): + tasks = _crud_delete_utils.get_scheduled_tasks( + project_uuid=user_project["uuid"], user_id=logged_user["id"] + ) + await tasks[0] + + await _wait_until_deleted() + + # no metadata -> project not found + url = client.app.router["get_project_metadata"].url_for( + project_id=user_project["uuid"] + ) + response = await client.get(f"{url}") + await assert_status(response, expected_cls=web.HTTPNotFound)