Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions api/specs/web-server/_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
FunctionGroupPathParams,
)
from simcore_service_webserver.functions._controller._functions_rest_schemas import (
FunctionDeleteQueryParams,
FunctionGetQueryParams,
FunctionPathParams,
FunctionsListQueryParams,
Expand Down Expand Up @@ -80,6 +81,7 @@ async def update_function(
)
async def delete_function(
_path: Annotated[FunctionPathParams, Depends()],
_query: Annotated[as_query(FunctionDeleteQueryParams), Depends()],
): ...


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ class FunctionIDNotFoundError(FunctionBaseError):
status_code: int = 404 # Not Found


class FunctionHasJobsCannotDeleteError(FunctionBaseError):
msg_template: str = (
"Cannot delete function {function_id} because it has {jobs_count} associated job(s)."
)
status_code: int = 409 # Conflict


class FunctionJobIDNotFoundError(FunctionBaseError):
msg_template: str = "Function job {function_job_id} not found"
status_code: int = 404 # Not Found
Expand Down
2 changes: 1 addition & 1 deletion services/web/server/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.77.2
0.78.0
2 changes: 1 addition & 1 deletion services/web/server/setup.cfg
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[bumpversion]
current_version = 0.77.2
current_version = 0.78.0
commit = True
message = services/webserver api version: {current_version} → {new_version}
tag = False
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.77.2
version: 0.78.0
servers:
- url: ''
description: webserver
Expand Down Expand Up @@ -3788,6 +3788,13 @@ paths:
type: string
format: uuid
title: Function Id
- name: force
in: query
required: false
schema:
type: boolean
default: false
title: Force
responses:
'204':
description: Successful Response
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
from .._services_metadata.proxy import ServiceMetadata
from ._functions_rest_exceptions import handle_rest_requests_exceptions
from ._functions_rest_schemas import (
FunctionDeleteQueryParams,
FunctionFilters,
FunctionGetQueryParams,
FunctionGroupPathParams,
Expand Down Expand Up @@ -370,12 +371,18 @@ async def update_function(request: web.Request) -> web.Response:
async def delete_function(request: web.Request) -> web.Response:
path_params = parse_request_path_parameters_as(FunctionPathParams, request)
function_id = path_params.function_id

query_params: FunctionDeleteQueryParams = parse_request_query_parameters_as(
FunctionDeleteQueryParams, request
)

req_ctx = AuthenticatedRequestContext.model_validate(request)
await _functions_service.delete_function(
app=request.app,
function_id=function_id,
user_id=req_ctx.user_id,
product_name=req_ctx.product_name,
function_id=function_id,
force=query_params.force,
)

return web.json_response(status=status.HTTP_204_NO_CONTENT)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,13 @@ class FunctionsListQueryParams(
): ...


class FunctionDeleteQueryParams(BaseModel):
force: Annotated[
bool,
Field(
description="If true, deletes the function even if it has associated jobs.",
),
] = False


__all__: tuple[str, ...] = ("AuthenticatedRequestContext",)
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from models_library.functions_errors import (
FunctionBaseError,
FunctionExecuteAccessDeniedError,
FunctionHasJobsCannotDeleteError,
FunctionIDNotFoundError,
FunctionJobCollectionExecuteAccessDeniedError,
FunctionJobCollectionIDNotFoundError,
Expand Down Expand Up @@ -819,6 +820,7 @@ async def delete_function(
user_id: UserID,
product_name: ProductName,
function_id: FunctionID,
force: bool = False,
) -> None:
async with transaction_context(get_asyncpg_engine(app), connection) as transaction:
await check_user_permissions(
Expand All @@ -840,6 +842,20 @@ async def delete_function(
if row is None:
raise FunctionIDNotFoundError(function_id=function_id)

# Check for existing function jobs if force is not True
if not force:
jobs_result = await transaction.execute(
function_jobs_table.select()
.with_only_columns(func.count())
.where(function_jobs_table.c.function_uuid == function_id)
)
jobs_count = jobs_result.scalar() or 0

if jobs_count > 0:
raise FunctionHasJobsCannotDeleteError(
function_id=function_id, jobs_count=jobs_count
)

# Proceed with deletion
await transaction.execute(
functions_table.delete().where(functions_table.c.uuid == function_id)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -353,12 +353,14 @@ async def delete_function(
user_id: UserID,
product_name: ProductName,
function_id: FunctionID,
force: bool = False,
) -> None:
await _functions_repository.delete_function(
app=app,
user_id=user_id,
product_name=product_name,
function_id=function_id,
force=force,
)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import AsyncExitStack
from typing import Any
from uuid import uuid4
from uuid import UUID, uuid4

import pytest
import sqlalchemy
Expand All @@ -31,6 +31,13 @@
from simcore_postgres_database.models.funcapi_api_access_rights_table import (
funcapi_api_access_rights_table,
)
from simcore_postgres_database.models.funcapi_function_jobs_table import (
function_jobs_table,
)
from simcore_postgres_database.models.funcapi_functions_access_rights_table import (
functions_access_rights_table,
)
from simcore_postgres_database.models.funcapi_functions_table import functions_table
from simcore_service_webserver.application_settings import ApplicationSettings
from simcore_service_webserver.statics._constants import FRONTEND_APP_DEFAULT
from sqlalchemy.ext.asyncio import AsyncEngine
Expand Down Expand Up @@ -253,3 +260,71 @@ async def logged_user_function_api_access_rights(
async with AsyncExitStack() as stack:
row = await stack.enter_async_context(cm)
yield row


@pytest.fixture
async def fake_function_with_associated_job(
asyncpg_engine: AsyncEngine,
logged_user: UserInfoDict,
) -> AsyncIterator[UUID]:
async with AsyncExitStack() as stack:
function_row = await stack.enter_async_context(
insert_and_get_row_lifespan(
asyncpg_engine,
table=functions_table,
values={
"title": "Test Function",
"function_class": FunctionClass.PROJECT.value,
"description": "A test function",
"input_schema": {
"schema_class": "application/schema+json",
"schema_content": {
"type": "object",
"properties": {"input1": {"type": "string"}},
},
},
"output_schema": {
"schema_class": "application/schema+json",
"schema_content": {
"type": "object",
"properties": {"output1": {"type": "string"}},
},
},
"class_specific_data": {"project_id": f"{uuid4()}"},
},
pk_col=functions_table.c.uuid,
)
)

await stack.enter_async_context(
insert_and_get_row_lifespan(
asyncpg_engine,
table=functions_access_rights_table,
values={
"function_uuid": function_row["uuid"],
"group_id": logged_user["primary_gid"],
"product_name": "osparc", # Default product name
"read": True,
"write": True,
"execute": True,
},
pk_cols=[
functions_access_rights_table.c.function_uuid,
functions_access_rights_table.c.group_id,
functions_access_rights_table.c.product_name,
],
)
)

await stack.enter_async_context(
insert_and_get_row_lifespan(
asyncpg_engine,
table=function_jobs_table,
values={
"function_uuid": function_row["uuid"],
"status": "pending",
},
pk_col=function_jobs_table.c.uuid,
)
)
yield function_row["uuid"]
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from collections.abc import AsyncIterator
from http import HTTPStatus
from typing import Any
from uuid import uuid4
from uuid import UUID, uuid4

import pytest
from aiohttp.test_utils import TestClient
Expand Down Expand Up @@ -306,3 +306,43 @@ async def test_list_user_functions_permissions(
function_permissions = MyFunctionPermissionsGet.model_validate(data)
assert function_permissions.read_functions == expected_read_functions
assert function_permissions.write_functions == expected_write_functions


@pytest.mark.parametrize(
"user_role,expected_read_functions,expected_write_functions",
[(UserRole.USER, True, True)],
)
async def test_delete_function_with_associated_jobs(
client: TestClient,
logged_user: UserInfoDict,
fake_function_with_associated_job: UUID,
logged_user_function_api_access_rights: dict[str, Any],
) -> None:
function_id = fake_function_with_associated_job

url = client.app.router["get_function"].url_for(function_id=f"{function_id}")
response = await client.get(url)
data, error = await assert_status(response, status.HTTP_200_OK)
assert not error
function = TypeAdapter(RegisteredFunctionGet).validate_python(data)
assert function.uid == function_id

url = client.app.router["delete_function"].url_for(function_id=f"{function_id}")
response = await client.delete(url)
data, error = await assert_status(response, status.HTTP_409_CONFLICT)
assert error is not None

url = client.app.router["get_function"].url_for(function_id=f"{function_id}")
response = await client.get(url)
data, error = await assert_status(response, status.HTTP_200_OK)
assert not error

url = client.app.router["delete_function"].url_for(function_id=f"{function_id}")
response = await client.delete(url, params={"force": "true"})
data, error = await assert_status(response, status.HTTP_204_NO_CONTENT)
assert not error

url = client.app.router["get_function"].url_for(function_id=f"{function_id}")
response = await client.get(url)
data, error = await assert_status(response, status.HTTP_404_NOT_FOUND)
assert error is not None
Loading