Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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; otherwise, returns HTTP_409_CONFLICT if jobs exist.",
),
] = 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