diff --git a/api/specs/web-server/_functions.py b/api/specs/web-server/_functions.py index 72f4c8af48e..1946f408d86 100644 --- a/api/specs/web-server/_functions.py +++ b/api/specs/web-server/_functions.py @@ -7,15 +7,19 @@ from typing import Annotated +from _common import as_query from fastapi import APIRouter, Depends, status from models_library.api_schemas_webserver.functions import ( FunctionToRegister, RegisteredFunctionGet, + RegisteredFunctionUpdate, ) from models_library.generics import Envelope from simcore_service_webserver._meta import API_VTAG from simcore_service_webserver.functions._controller._functions_rest_schemas import ( + FunctionGetQueryParams, FunctionPathParams, + FunctionsListQueryParams, ) router = APIRouter( @@ -35,12 +39,32 @@ async def register_function( ) -> Envelope[RegisteredFunctionGet]: ... +@router.get( + "/functions", + response_model=Envelope[list[RegisteredFunctionGet]], +) +async def list_functions( + _query: Annotated[as_query(FunctionsListQueryParams), Depends()], +): ... + + @router.get( "/functions/{function_id}", response_model=Envelope[RegisteredFunctionGet], ) async def get_function( _path: Annotated[FunctionPathParams, Depends()], + _query: Annotated[as_query(FunctionGetQueryParams), Depends()], +): ... + + +@router.patch( + "/functions/{function_id}", + response_model=Envelope[RegisteredFunctionGet], +) +async def update_function( + _body: RegisteredFunctionUpdate, + _path: Annotated[FunctionPathParams, Depends()], ): ... diff --git a/packages/models-library/src/models_library/api_schemas_webserver/functions.py b/packages/models-library/src/models_library/api_schemas_webserver/functions.py index c1f4a7b55e5..43e41961076 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/functions.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/functions.py @@ -1,9 +1,11 @@ +import datetime from typing import Annotated, TypeAlias from pydantic import Field from ..functions import ( Function, + FunctionAccessRights, FunctionBase, FunctionClass, FunctionClassSpecificData, @@ -23,6 +25,7 @@ FunctionOutputs, FunctionOutputSchema, FunctionSchemaClass, + FunctionUpdate, JSONFunctionInputSchema, JSONFunctionOutputSchema, ProjectFunction, @@ -46,6 +49,7 @@ UnsupportedFunctionClassError, UnsupportedFunctionFunctionJobClassCombinationError, ) +from ..projects import ProjectID from ._base import InputSchema, OutputSchema __all__ = [ @@ -113,7 +117,13 @@ class RegisteredSolverFunctionGet(RegisteredSolverFunction, OutputSchema): ... -class RegisteredProjectFunctionGet(RegisteredProjectFunction, OutputSchema): ... +class RegisteredProjectFunctionGet(RegisteredProjectFunction, OutputSchema): + uid: Annotated[FunctionID, Field(alias="uuid")] + project_id: Annotated[ProjectID, Field(alias="templateId")] + created_at: Annotated[datetime.datetime, Field(alias="creationDate")] + modified_at: Annotated[datetime.datetime, Field(alias="lastChangeDate")] + access_rights: FunctionAccessRights | None = None + thumbnail: str | None = None class SolverFunctionToRegister(SolverFunction, InputSchema): ... @@ -131,3 +141,6 @@ class ProjectFunctionToRegister(ProjectFunction, InputSchema): ... RegisteredProjectFunctionGet | RegisteredSolverFunctionGet, Field(discriminator="function_class"), ] + + +class RegisteredFunctionUpdate(FunctionUpdate, InputSchema): ... diff --git a/packages/models-library/src/models_library/api_schemas_webserver/users.py b/packages/models-library/src/models_library/api_schemas_webserver/users.py index 89953ae1d81..7b07616b73f 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/users.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/users.py @@ -386,4 +386,5 @@ def from_domain_model(cls, permission: UserPermission) -> Self: class MyFunctionPermissionsGet(OutputSchema): + read_functions: bool write_functions: bool diff --git a/packages/models-library/src/models_library/functions.py b/packages/models-library/src/models_library/functions.py index 4ab6fe389b4..6f1cbaf136f 100644 --- a/packages/models-library/src/models_library/functions.py +++ b/packages/models-library/src/models_library/functions.py @@ -99,6 +99,12 @@ class FunctionBase(BaseModel): class RegisteredFunctionBase(FunctionBase): uid: FunctionID created_at: datetime.datetime + modified_at: datetime.datetime + + +class FunctionUpdate(BaseModel): + title: str | None = None + description: str | None = None class ProjectFunction(FunctionBase): @@ -250,6 +256,7 @@ class FunctionDB(BaseModel): class RegisteredFunctionDB(FunctionDB): uuid: FunctionID created: datetime.datetime + modified: datetime.datetime class FunctionJobCollectionDB(BaseModel): diff --git a/services/api-server/openapi.json b/services/api-server/openapi.json index 4598a9b45d9..a4832fe0b6b 100644 --- a/services/api-server/openapi.json +++ b/services/api-server/openapi.json @@ -10623,6 +10623,11 @@ "format": "date-time", "title": "Created At" }, + "modified_at": { + "type": "string", + "format": "date-time", + "title": "Modified At" + }, "project_id": { "type": "string", "format": "uuid", @@ -10636,6 +10641,7 @@ "default_inputs", "uid", "created_at", + "modified_at", "project_id" ], "title": "RegisteredProjectFunction" @@ -10782,6 +10788,11 @@ "format": "date-time", "title": "Created At" }, + "modified_at": { + "type": "string", + "format": "date-time", + "title": "Modified At" + }, "code_url": { "type": "string", "title": "Code Url" @@ -10794,6 +10805,7 @@ "default_inputs", "uid", "created_at", + "modified_at", "code_url" ], "title": "RegisteredPythonCodeFunction" @@ -10934,6 +10946,11 @@ "format": "date-time", "title": "Created At" }, + "modified_at": { + "type": "string", + "format": "date-time", + "title": "Modified At" + }, "solver_key": { "type": "string", "pattern": "^simcore/services/((comp|dynamic|frontend))/([a-z0-9][a-z0-9_.-]*/)*([a-z0-9-_]+[a-z0-9])$", @@ -10952,6 +10969,7 @@ "default_inputs", "uid", "created_at", + "modified_at", "solver_key", "solver_version" ], diff --git a/services/api-server/tests/unit/api_functions/conftest.py b/services/api-server/tests/unit/api_functions/conftest.py index f25334c5a28..f248fcfcf2d 100644 --- a/services/api-server/tests/unit/api_functions/conftest.py +++ b/services/api-server/tests/unit/api_functions/conftest.py @@ -127,9 +127,10 @@ def mock_function( def mock_registered_project_function(mock_function: Function) -> RegisteredFunction: return RegisteredProjectFunction( **{ - **mock_function.dict(), - "uid": str(uuid4()), + **mock_function.model_dump(), + "uid": f"{uuid4()}", "created_at": datetime.datetime.now(datetime.UTC), + "modified_at": datetime.datetime.now(datetime.UTC), } ) @@ -148,8 +149,9 @@ def mock_registered_solver_function( "input_schema": sample_input_schema, "output_schema": sample_output_schema, "default_inputs": None, - "uid": str(uuid4()), + "uid": f"{uuid4()}", "created_at": datetime.datetime.now(datetime.UTC), + "modified_at": datetime.datetime.now(datetime.UTC), "solver_key": "simcore/services/comp/ans-model", "solver_version": "1.0.1", } @@ -166,7 +168,7 @@ def mock_project_function_job( "description": "A test function job", "inputs": {"key": "value"}, "outputs": None, - "project_job_id": str(uuid4()), + "project_job_id": f"{uuid4()}", "function_class": FunctionClass.PROJECT, } return ProjectFunctionJob(**mock_function_job) @@ -178,8 +180,8 @@ def mock_registered_project_function_job( ) -> RegisteredFunctionJob: return RegisteredProjectFunctionJob( **{ - **mock_project_function_job.dict(), - "uid": str(uuid4()), + **mock_project_function_job.model_dump(), + "uid": f"{uuid4()}", "created_at": datetime.datetime.now(datetime.UTC), } ) @@ -206,8 +208,8 @@ def mock_registered_solver_function_job( ) -> RegisteredFunctionJob: return RegisteredSolverFunctionJob( **{ - **mock_solver_function_job.dict(), - "uid": str(uuid4()), + **mock_solver_function_job.model_dump(), + "uid": f"{uuid4()}", "created_at": datetime.datetime.now(datetime.UTC), } ) @@ -222,7 +224,7 @@ def mock_function_job_collection( "description": "A test function job collection", "function_uid": mock_registered_project_function_job.function_uid, "function_class": FunctionClass.PROJECT, - "project_id": str(uuid4()), + "project_id": f"{uuid4()}", "function_job_ids": [ mock_registered_project_function_job.uid for _ in range(5) ], @@ -237,7 +239,7 @@ def mock_registered_function_job_collection( return RegisteredFunctionJobCollection( **{ **mock_function_job_collection.model_dump(), - "uid": str(uuid4()), + "uid": f"{uuid4()}", "created_at": datetime.datetime.now(datetime.UTC), } ) diff --git a/services/static-webserver/client/source/class/osparc/data/Resources.js b/services/static-webserver/client/source/class/osparc/data/Resources.js index b965b58d24b..e9f36a4cea4 100644 --- a/services/static-webserver/client/source/class/osparc/data/Resources.js +++ b/services/static-webserver/client/source/class/osparc/data/Resources.js @@ -631,6 +631,14 @@ qx.Class.define("osparc.data.Resources", { create: { method: "POST", url: statics.API + "/functions" + }, + getOne: { + method: "GET", + url: statics.API + "/functions/{functionId}?include_extras=true" + }, + getPage: { + method: "GET", + url: statics.API + "/functions?include_extras=true" } } }, diff --git a/services/static-webserver/client/source/class/osparc/data/model/Function.js b/services/static-webserver/client/source/class/osparc/data/model/Function.js index 58f89f7fc99..1dae7c4047b 100644 --- a/services/static-webserver/client/source/class/osparc/data/model/Function.js +++ b/services/static-webserver/client/source/class/osparc/data/model/Function.js @@ -31,7 +31,7 @@ qx.Class.define("osparc.data.model.Function", { this.set({ uuid: functionData.uuid, functionType: functionData.functionClass, - name: functionData.name, + name: functionData.title, description: functionData.description, inputSchema: functionData.inputSchema || this.getInputSchema(), outputSchema: functionData.outputSchema || this.getOutputSchema(), diff --git a/services/static-webserver/client/source/class/osparc/store/Functions.js b/services/static-webserver/client/source/class/osparc/store/Functions.js index 487e844c911..7e951e6c327 100644 --- a/services/static-webserver/client/source/class/osparc/store/Functions.js +++ b/services/static-webserver/client/source/class/osparc/store/Functions.js @@ -92,7 +92,7 @@ qx.Class.define("osparc.store.Functions", { }, fetchFunctionsPaginated: function(params, options) { - const isBackendReady = false; + const isBackendReady = true; if (!isBackendReady) { return new Promise(resolve => { const response = this.__dummyResponse(); @@ -110,7 +110,7 @@ qx.Class.define("osparc.store.Functions", { }, fetchFunction: function(functionId) { - const isBackendReady = false; + const isBackendReady = true; if (!isBackendReady) { return new Promise(resolve => { const response = this.__dummyResponse(); 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 96b4071f7b3..a6ea4a93db0 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 @@ -3354,19 +3354,19 @@ paths: summary: Register Function operationId: register_function requestBody: + required: true content: application/json: schema: oneOf: - $ref: '#/components/schemas/ProjectFunctionToRegister' - $ref: '#/components/schemas/SolverFunctionToRegister' - title: ' Body' discriminator: propertyName: functionClass mapping: PROJECT: '#/components/schemas/ProjectFunctionToRegister' SOLVER: '#/components/schemas/SolverFunctionToRegister' - required: true + title: ' Body' responses: '200': description: Successful Response @@ -3374,6 +3374,40 @@ paths: application/json: schema: $ref: '#/components/schemas/Envelope_Annotated_Union_RegisteredProjectFunctionGet__RegisteredSolverFunctionGet___FieldInfo_annotation_NoneType__required_True__discriminator__function_class____' + get: + tags: + - functions + summary: List Functions + operationId: list_functions + parameters: + - name: include_extras + in: query + required: false + schema: + type: boolean + default: false + title: Include Extras + - name: limit + in: query + required: false + schema: + type: integer + default: 20 + title: Limit + - name: offset + in: query + required: false + schema: + type: integer + default: 0 + title: Offset + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope_list_Annotated_Union_RegisteredProjectFunctionGet__RegisteredSolverFunctionGet___FieldInfo_annotation_NoneType__required_True__discriminator__function_class_____' /v0/functions/{function_id}: get: tags: @@ -3388,6 +3422,39 @@ paths: type: string format: uuid title: Function Id + - name: include_extras + in: query + required: false + schema: + type: boolean + default: false + title: Include Extras + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope_Annotated_Union_RegisteredProjectFunctionGet__RegisteredSolverFunctionGet___FieldInfo_annotation_NoneType__required_True__discriminator__function_class____' + patch: + tags: + - functions + summary: Update Function + operationId: update_function + parameters: + - name: function_id + in: path + required: true + schema: + type: string + format: uuid + title: Function Id + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RegisteredFunctionUpdate' responses: '200': description: Successful Response @@ -11239,6 +11306,30 @@ components: title: Error type: object title: Envelope[dict[str, Any]] + ? Envelope_list_Annotated_Union_RegisteredProjectFunctionGet__RegisteredSolverFunctionGet___FieldInfo_annotation_NoneType__required_True__discriminator__function_class_____ + : properties: + data: + anyOf: + - items: + oneOf: + - $ref: '#/components/schemas/RegisteredProjectFunctionGet' + - $ref: '#/components/schemas/RegisteredSolverFunctionGet' + discriminator: + propertyName: functionClass + mapping: + PROJECT: '#/components/schemas/RegisteredProjectFunctionGet' + SOLVER: '#/components/schemas/RegisteredSolverFunctionGet' + type: array + - type: 'null' + title: Data + error: + anyOf: + - {} + - type: 'null' + title: Error + type: object + title: Envelope[list[Annotated[Union[RegisteredProjectFunctionGet, RegisteredSolverFunctionGet], + FieldInfo(annotation=NoneType, required=True, discriminator='function_class')]]] Envelope_list_Annotated_str__StringConstraints___: properties: data: @@ -12233,6 +12324,23 @@ components: required: - name title: FolderReplaceBodyParams + FunctionAccessRights: + properties: + read: + type: boolean + title: Read + default: false + write: + type: boolean + title: Write + default: false + execute: + type: boolean + title: Execute + default: false + additionalProperties: false + type: object + title: FunctionAccessRights GetProjectInactivityResponse: properties: is_inactive: @@ -13023,11 +13131,15 @@ components: title: MarkerUI MyFunctionPermissionsGet: properties: + readFunctions: + type: boolean + title: Readfunctions writeFunctions: type: boolean title: Writefunctions type: object required: + - readFunctions - writeFunctions title: MyFunctionPermissionsGet MyGroupsGet: @@ -15919,6 +16031,20 @@ components: - name - message title: RegisterPhoneNextPage + RegisteredFunctionUpdate: + properties: + title: + anyOf: + - type: string + - type: 'null' + title: Title + description: + anyOf: + - type: string + - type: 'null' + title: Description + type: object + title: RegisteredFunctionUpdate RegisteredProjectFunctionGet: properties: functionClass: @@ -15956,26 +16082,40 @@ components: type: object - type: 'null' title: Defaultinputs - uid: + uuid: type: string format: uuid - title: Uid - createdAt: + title: Uuid + creationDate: type: string format: date-time - title: Createdat - projectId: + title: Creationdate + lastChangeDate: + type: string + format: date-time + title: Lastchangedate + templateId: type: string format: uuid - title: Projectid + title: Templateid + accessRights: + anyOf: + - $ref: '#/components/schemas/FunctionAccessRights' + - type: 'null' + thumbnail: + anyOf: + - type: string + - type: 'null' + title: Thumbnail type: object required: - inputSchema - outputSchema - defaultInputs - - uid - - createdAt - - projectId + - uuid + - creationDate + - lastChangeDate + - templateId title: RegisteredProjectFunctionGet RegisteredSolverFunctionGet: properties: @@ -16022,6 +16162,10 @@ components: type: string format: date-time title: Createdat + modifiedAt: + type: string + format: date-time + title: Modifiedat solverKey: type: string pattern: ^simcore/services/((comp|dynamic|frontend))/([a-z0-9][a-z0-9_.-]*/)*([a-z0-9-_]+[a-z0-9])$ @@ -16037,6 +16181,7 @@ components: - defaultInputs - uid - createdAt + - modifiedAt - solverKey - solverVersion title: RegisteredSolverFunctionGet diff --git a/services/web/server/src/simcore_service_webserver/folders/_folders_rest.py b/services/web/server/src/simcore_service_webserver/folders/_folders_rest.py index 5219a500f1a..b43c75ca571 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_folders_rest.py +++ b/services/web/server/src/simcore_service_webserver/folders/_folders_rest.py @@ -8,7 +8,7 @@ ) from models_library.folders import FolderTuple from models_library.rest_ordering import OrderBy -from models_library.rest_pagination import ItemT, Page +from models_library.rest_pagination import Page from models_library.rest_pagination_utils import paginate_data from servicelib.aiohttp import status from servicelib.aiohttp.requests_validation import ( @@ -16,13 +16,11 @@ parse_request_path_parameters_as, parse_request_query_parameters_as, ) -from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON -from servicelib.rest_constants import RESPONSE_MODEL_POLICY from .._meta import API_VTAG as VTAG from ..login.decorators import login_required from ..security.decorators import permission_required -from ..utils_aiohttp import envelope_json_response +from ..utils_aiohttp import create_json_response_from_page, envelope_json_response from . import _folders_service from ._common.exceptions_handlers import handle_plugin_requests_exceptions from ._common.models import ( @@ -39,13 +37,6 @@ routes = web.RouteTableDef() -def _create_json_response_from_page(page: Page[ItemT]): - return web.Response( - text=page.model_dump_json(**RESPONSE_MODEL_POLICY), - content_type=MIMETYPE_APPLICATION_JSON, - ) - - @routes.post(f"/{VTAG}/folders", name="create_folder") @login_required @permission_required("folder.create") @@ -107,7 +98,7 @@ async def list_folders(request: web.Request): offset=query_params.offset, ) ) - return _create_json_response_from_page(page) + return create_json_response_from_page(page) @routes.get(f"/{VTAG}/folders:search", name="list_folders_full_search") @@ -143,7 +134,7 @@ async def list_folders_full_search(request: web.Request): offset=query_params.offset, ) ) - return _create_json_response_from_page(page) + return create_json_response_from_page(page) @routes.get(f"/{VTAG}/folders/{{folder_id}}", name="get_folder") diff --git a/services/web/server/src/simcore_service_webserver/functions/_controller/_functions_rest.py b/services/web/server/src/simcore_service_webserver/functions/_controller/_functions_rest.py index aa80b3cf301..0030562a0eb 100644 --- a/services/web/server/src/simcore_service_webserver/functions/_controller/_functions_rest.py +++ b/services/web/server/src/simcore_service_webserver/functions/_controller/_functions_rest.py @@ -4,23 +4,35 @@ FunctionToRegister, RegisteredFunction, RegisteredFunctionGet, + RegisteredFunctionUpdate, + RegisteredProjectFunctionGet, ) from models_library.api_schemas_webserver.users import MyFunctionPermissionsGet +from models_library.functions import FunctionClass, RegisteredProjectFunction +from models_library.rest_pagination import Page +from models_library.rest_pagination_utils import paginate_data from pydantic import TypeAdapter from servicelib.aiohttp import status from servicelib.aiohttp.requests_validation import ( handle_validation_as_http_error, parse_request_path_parameters_as, + parse_request_query_parameters_as, ) from ..._meta import API_VTAG as VTAG from ...login.decorators import login_required from ...models import AuthenticatedRequestContext +from ...projects import _projects_service +from ...projects.models import ProjectDBGet from ...security.decorators import permission_required -from ...utils_aiohttp import envelope_json_response +from ...utils_aiohttp import create_json_response_from_page, envelope_json_response from .. import _functions_service from ._functions_rest_exceptions import handle_rest_requests_exceptions -from ._functions_rest_schemas import FunctionPathParams +from ._functions_rest_schemas import ( + FunctionGetQueryParams, + FunctionPathParams, + FunctionsListQueryParams, +) routes = web.RouteTableDef() @@ -55,6 +67,89 @@ async def register_function(request: web.Request) -> web.Response: ) +@routes.get( + f"/{VTAG}/functions", + name="list_functions", +) +@login_required +@permission_required("function.read") +@handle_rest_requests_exceptions +async def list_functions(request: web.Request) -> web.Response: + query_params: FunctionsListQueryParams = parse_request_query_parameters_as( + FunctionsListQueryParams, request + ) + + req_ctx = AuthenticatedRequestContext.model_validate(request) + functions, page_meta_info = await _functions_service.list_functions( + request.app, + user_id=req_ctx.user_id, + product_name=req_ctx.product_name, + pagination_limit=query_params.limit, + pagination_offset=query_params.offset, + ) + + chunk: list[RegisteredFunctionGet] = [] + projects_map: dict[str, ProjectDBGet | None] = ( + {} + ) # ProjectDBGet has to be renamed at some point! + + if query_params.include_extras: + project_ids = [] + for function in functions: + if function.function_class == FunctionClass.PROJECT: + assert isinstance(function, RegisteredProjectFunction) + project_ids.append(function.project_id) + + projects_map = { + f"{p.uuid}": p + for p in await _projects_service.batch_get_projects( + request.app, + project_uuids=project_ids, + ) + } + + for function in functions: + if ( + query_params.include_extras + and function.function_class == FunctionClass.PROJECT + ): + assert isinstance(function, RegisteredProjectFunction) # nosec + if project := projects_map.get(f"{function.project_id}"): + chunk.append( + TypeAdapter(RegisteredProjectFunctionGet).validate_python( + function.model_dump(mode="json") + | { + "access_rights": await _functions_service.get_function_user_permissions( + request.app, + user_id=req_ctx.user_id, + product_name=req_ctx.product_name, + function_id=function.uid, + ), + "thumbnail": ( + f"{project.thumbnail}" if project.thumbnail else None + ), + } + ) + ) + else: + chunk.append( + TypeAdapter(RegisteredFunctionGet).validate_python( + function.model_dump(mode="json") + ) + ) + + page = Page[RegisteredFunctionGet].model_validate( + paginate_data( + chunk=chunk, + request_url=request.url, + total=page_meta_info.total, + limit=query_params.limit, + offset=query_params.offset, + ) + ) + return create_json_response_from_page(page) + + @routes.get( f"/{VTAG}/functions/{{function_id}}", name="get_function", @@ -66,6 +161,10 @@ async def get_function(request: web.Request) -> web.Response: path_params = parse_request_path_parameters_as(FunctionPathParams, request) function_id = path_params.function_id + query_params: FunctionGetQueryParams = parse_request_query_parameters_as( + FunctionGetQueryParams, request + ) + req_ctx = AuthenticatedRequestContext.model_validate(request) registered_function: RegisteredFunction = await _functions_service.get_function( app=request.app, @@ -74,6 +173,27 @@ async def get_function(request: web.Request) -> web.Response: product_name=req_ctx.product_name, ) + if ( + query_params.include_extras + and registered_function.function_class == FunctionClass.PROJECT + ): + assert isinstance(registered_function, RegisteredProjectFunctionGet) # nosec + + project_dict = await _projects_service.get_project_for_user( + app=request.app, + project_uuid=f"{registered_function.project_id}", + user_id=req_ctx.user_id, + ) + + return envelope_json_response( + TypeAdapter(RegisteredProjectFunctionGet).validate_python( + registered_function.model_dump(mode="json") + | { + "thumbnail": project_dict.get("thumbnail", None), + } + ) + ) + return envelope_json_response( TypeAdapter(RegisteredFunctionGet).validate_python( registered_function.model_dump(mode="json") @@ -81,6 +201,37 @@ async def get_function(request: web.Request) -> web.Response: ) +@routes.patch( + f"/{VTAG}/functions/{{function_id}}", + name="update_function", +) +@login_required +@permission_required("function.update") +@handle_rest_requests_exceptions +async def update_function(request: web.Request) -> web.Response: + path_params = parse_request_path_parameters_as(FunctionPathParams, request) + function_id = path_params.function_id + + function_update = TypeAdapter(RegisteredFunctionUpdate).validate_python( + await request.json() + ) + req_ctx = AuthenticatedRequestContext.model_validate(request) + + updated_function = await _functions_service.update_function( + request.app, + user_id=req_ctx.user_id, + product_name=req_ctx.product_name, + function_id=function_id, + function=function_update, + ) + + return envelope_json_response( + TypeAdapter(RegisteredFunctionGet).validate_python( + updated_function.model_dump(mode="json") + ) + ) + + @routes.delete( f"/{VTAG}/functions/{{function_id}}", name="delete_function", @@ -124,5 +275,8 @@ async def list_user_functions_permissions(request: web.Request) -> web.Response: assert function_permissions.user_id == req_ctx.user_id # nosec return envelope_json_response( - MyFunctionPermissionsGet(write_functions=function_permissions.write_functions) + MyFunctionPermissionsGet( + read_functions=function_permissions.read_functions, + write_functions=function_permissions.write_functions, + ) ) diff --git a/services/web/server/src/simcore_service_webserver/functions/_controller/_functions_rest_schemas.py b/services/web/server/src/simcore_service_webserver/functions/_controller/_functions_rest_schemas.py index f97f5728511..79030f202be 100644 --- a/services/web/server/src/simcore_service_webserver/functions/_controller/_functions_rest_schemas.py +++ b/services/web/server/src/simcore_service_webserver/functions/_controller/_functions_rest_schemas.py @@ -1,4 +1,5 @@ from models_library.functions import FunctionID +from models_library.rest_pagination import PageQueryParameters from pydantic import BaseModel, ConfigDict from ...models import AuthenticatedRequestContext @@ -11,4 +12,14 @@ class FunctionPathParams(BaseModel): model_config = ConfigDict(populate_by_name=True, extra="forbid") +class _FunctionQueryParams(BaseModel): + include_extras: bool = False + + +class FunctionGetQueryParams(_FunctionQueryParams): ... + + +class FunctionsListQueryParams(PageQueryParameters, _FunctionQueryParams): ... + + __all__: tuple[str, ...] = ("AuthenticatedRequestContext",) diff --git a/services/web/server/src/simcore_service_webserver/functions/_controller/_functions_rpc.py b/services/web/server/src/simcore_service_webserver/functions/_controller/_functions_rpc.py index f77b0701115..470c532d03e 100644 --- a/services/web/server/src/simcore_service_webserver/functions/_controller/_functions_rpc.py +++ b/services/web/server/src/simcore_service_webserver/functions/_controller/_functions_rpc.py @@ -11,6 +11,7 @@ FunctionJobCollectionsListFilters, FunctionJobID, FunctionOutputSchema, + FunctionUpdate, FunctionUserApiAccessRights, RegisteredFunction, RegisteredFunctionJob, @@ -318,12 +319,12 @@ async def update_function_title( function_id: FunctionID, title: str, ) -> RegisteredFunction: - return await _functions_service.update_function_title( + return await _functions_service.update_function( app=app, user_id=user_id, product_name=product_name, function_id=function_id, - title=title, + function=FunctionUpdate(title=title), ) @@ -342,12 +343,12 @@ async def update_function_description( function_id: FunctionID, description: str, ) -> RegisteredFunction: - return await _functions_service.update_function_description( + return await _functions_service.update_function( app=app, user_id=user_id, product_name=product_name, function_id=function_id, - description=description, + function=FunctionUpdate(description=description), ) diff --git a/services/web/server/src/simcore_service_webserver/functions/_functions_repository.py b/services/web/server/src/simcore_service_webserver/functions/_functions_repository.py index cec64473248..65fc448f1e3 100644 --- a/services/web/server/src/simcore_service_webserver/functions/_functions_repository.py +++ b/services/web/server/src/simcore_service_webserver/functions/_functions_repository.py @@ -20,6 +20,7 @@ FunctionOutputs, FunctionOutputSchema, FunctionsApiAccessRights, + FunctionUpdate, FunctionUserApiAccessRights, RegisteredFunctionDB, RegisteredFunctionJobCollectionDB, @@ -633,14 +634,14 @@ async def delete_function( ) -async def _update_function_attribute( +async def update_function( app: web.Application, - connection: AsyncConnection | None, + connection: AsyncConnection | None = None, *, user_id: UserID, product_name: ProductName, function_id: FunctionID, - **update_kwargs, + function: FunctionUpdate, ) -> RegisteredFunctionDB: async with transaction_context(get_asyncpg_engine(app), connection) as transaction: await check_user_api_access_rights( @@ -667,7 +668,7 @@ async def _update_function_attribute( result = await transaction.execute( functions_table.update() .where(functions_table.c.uuid == function_id) - .values(**update_kwargs) + .values(**function.model_dump(exclude_none=True, exclude_unset=True)) .returning(*_FUNCTIONS_TABLE_COLS) ) row = result.one_or_none() @@ -678,44 +679,6 @@ async def _update_function_attribute( return RegisteredFunctionDB.model_validate(row) -async def update_function_title( - app: web.Application, - connection: AsyncConnection | None = None, - *, - user_id: UserID, - product_name: ProductName, - function_id: FunctionID, - title: str, -) -> RegisteredFunctionDB: - return await _update_function_attribute( - app, - connection=connection, - user_id=user_id, - product_name=product_name, - function_id=function_id, - title=title, - ) - - -async def update_function_description( - app: web.Application, - connection: AsyncConnection | None = None, - *, - user_id: UserID, - product_name: ProductName, - function_id: FunctionID, - description: str, -) -> RegisteredFunctionDB: - return await _update_function_attribute( - app, - connection=connection, - user_id=user_id, - product_name=product_name, - function_id=function_id, - description=description, - ) - - async def get_function_job( app: web.Application, connection: AsyncConnection | None = None, diff --git a/services/web/server/src/simcore_service_webserver/functions/_functions_service.py b/services/web/server/src/simcore_service_webserver/functions/_functions_service.py index d74dbe52217..e23cf0317c3 100644 --- a/services/web/server/src/simcore_service_webserver/functions/_functions_service.py +++ b/services/web/server/src/simcore_service_webserver/functions/_functions_service.py @@ -14,6 +14,7 @@ FunctionJobDB, FunctionJobID, FunctionOutputSchema, + FunctionUpdate, FunctionUserAccessRights, FunctionUserApiAccessRights, RegisteredFunction, @@ -291,38 +292,20 @@ async def delete_function_job_collection( ) -async def update_function_title( +async def update_function( app: web.Application, *, user_id: UserID, product_name: ProductName, function_id: FunctionID, - title: str, + function: FunctionUpdate, ) -> RegisteredFunction: - updated_function = await _functions_repository.update_function_title( + updated_function = await _functions_repository.update_function( app=app, user_id=user_id, product_name=product_name, function_id=function_id, - title=title, - ) - return _decode_function(updated_function) - - -async def update_function_description( - app: web.Application, - *, - user_id: UserID, - product_name: ProductName, - function_id: FunctionID, - description: str, -) -> RegisteredFunction: - updated_function = await _functions_repository.update_function_description( - app=app, - user_id=user_id, - product_name=product_name, - function_id=function_id, - description=description, + function=function, ) return _decode_function(updated_function) @@ -474,6 +457,7 @@ def _decode_function( project_id=function.class_specific_data["project_id"], default_inputs=function.default_inputs, created_at=function.created, + modified_at=function.modified, ) if function.function_class == FunctionClass.SOLVER: @@ -487,6 +471,7 @@ def _decode_function( solver_version=function.class_specific_data["solver_version"], default_inputs=function.default_inputs, created_at=function.created, + modified_at=function.modified, ) raise UnsupportedFunctionClassError(function_class=function.function_class) diff --git a/services/web/server/src/simcore_service_webserver/projects/_projects_repository.py b/services/web/server/src/simcore_service_webserver/projects/_projects_repository.py index 705e4970ee7..d368a04b8dc 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_projects_repository.py +++ b/services/web/server/src/simcore_service_webserver/projects/_projects_repository.py @@ -151,6 +151,24 @@ async def batch_get_project_name( return [rows.get(project_uuid) for project_uuid in projects_uuids_str] +async def batch_get_projects( + app: web.Application, + connection: AsyncConnection | None = None, + *, + project_uuids: list[ProjectID], +) -> list[ProjectDBGet]: + if not project_uuids: + return [] + async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: + query = ( + sql.select(projects) + .select_from(projects) + .where(projects.c.uuid.in_([f"{uuid}" for uuid in project_uuids])) + ) + result = await conn.stream(query) + return [ProjectDBGet.model_validate(row) async for row in result] + + def _select_trashed_by_primary_gid_query() -> sql.Select: return sql.select( projects.c.uuid, diff --git a/services/web/server/src/simcore_service_webserver/projects/_projects_service.py b/services/web/server/src/simcore_service_webserver/projects/_projects_service.py index 6152340916d..b2b50257b1c 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_projects_service.py +++ b/services/web/server/src/simcore_service_webserver/projects/_projects_service.py @@ -153,7 +153,7 @@ ProjectTooManyProjectOpenedError, ProjectTypeAndTemplateIncompatibilityError, ) -from .models import ProjectDict, ProjectPatchInternalExtended +from .models import ProjectDBGet, ProjectDict, ProjectPatchInternalExtended from .settings import ProjectsSettings, get_plugin_settings from .utils import extract_dns_without_default_port @@ -275,6 +275,17 @@ async def batch_get_project_name( return [name if name else "Unknown" for name in get_project_names] +async def batch_get_projects( + app: web.Application, + *, + project_uuids: list[ProjectID], +) -> list[ProjectDBGet]: + return await _projects_repository.batch_get_projects( + app=app, + project_uuids=project_uuids, + ) + + # # UPDATE project ----------------------------------------------------- # diff --git a/services/web/server/src/simcore_service_webserver/security/_authz_access_roles.py b/services/web/server/src/simcore_service_webserver/security/_authz_access_roles.py index 0edd5b2a10c..0c0579a0b69 100644 --- a/services/web/server/src/simcore_service_webserver/security/_authz_access_roles.py +++ b/services/web/server/src/simcore_service_webserver/security/_authz_access_roles.py @@ -78,6 +78,7 @@ class PermissionDict(TypedDict, total=False): "project.workspaces.*", "function.create", "function.read", + "function.update", "function.execute", "function.delete", "resource-usage.read", diff --git a/services/web/server/tests/unit/with_dbs/04/functions_rpc/conftest.py b/services/web/server/tests/unit/with_dbs/04/functions_rpc/conftest.py index 3649ba419a2..31d4939417a 100644 --- a/services/web/server/tests/unit/with_dbs/04/functions_rpc/conftest.py +++ b/services/web/server/tests/unit/with_dbs/04/functions_rpc/conftest.py @@ -200,6 +200,7 @@ async def logged_user_function_api_access_rights( asyncpg_engine: AsyncEngine, logged_user: UserInfoDict, *, + expected_read_functions: bool, expected_write_functions: bool, ) -> AsyncIterator[dict[str, Any]]: cm = insert_and_get_row_lifespan( @@ -208,7 +209,7 @@ async def logged_user_function_api_access_rights( values={ "group_id": logged_user["primary_gid"], "product_name": FRONTEND_APP_DEFAULT, - "read_functions": True, + "read_functions": expected_read_functions, "write_functions": expected_write_functions, "execute_functions": True, "read_function_jobs": True, diff --git a/services/web/server/tests/unit/with_dbs/04/functions_rpc/test_functions_controller_rest.py b/services/web/server/tests/unit/with_dbs/04/functions_rpc/test_functions_controller_rest.py index 68bc2837355..24e412431eb 100644 --- a/services/web/server/tests/unit/with_dbs/04/functions_rpc/test_functions_controller_rest.py +++ b/services/web/server/tests/unit/with_dbs/04/functions_rpc/test_functions_controller_rest.py @@ -18,6 +18,7 @@ RegisteredProjectFunctionGet, ) from models_library.api_schemas_webserver.users import MyFunctionPermissionsGet +from pydantic import TypeAdapter from pytest_simcore.helpers.assert_checks import assert_status from pytest_simcore.helpers.webserver_users import UserInfoDict from servicelib.aiohttp import status @@ -50,13 +51,15 @@ def mock_function() -> dict[str, Any]: @pytest.mark.parametrize( - "user_role,add_user_function_api_access_rights,expected_register,expected_get,expected_delete,expected_get2", + "user_role,add_user_function_api_access_rights,expected_register,expected_get,expected_list,expected_update,expected_delete,expected_get2", [ ( UserRole.USER, True, status.HTTP_201_CREATED, status.HTTP_200_OK, + status.HTTP_200_OK, + status.HTTP_200_OK, status.HTTP_204_NO_CONTENT, status.HTTP_404_NOT_FOUND, ), @@ -67,21 +70,26 @@ def mock_function() -> dict[str, Any]: status.HTTP_403_FORBIDDEN, status.HTTP_403_FORBIDDEN, status.HTTP_403_FORBIDDEN, + status.HTTP_403_FORBIDDEN, + status.HTTP_403_FORBIDDEN, ), ], indirect=["add_user_function_api_access_rights"], ) -async def test_register_get_delete_function( +async def test_function_workflow( client: TestClient, logged_user: UserInfoDict, mock_function: dict[str, Any], expected_register: HTTPStatus, expected_get: HTTPStatus, + expected_list: HTTPStatus, + expected_update: HTTPStatus, expected_delete: HTTPStatus, expected_get2: HTTPStatus, add_user_function_api_access_rights: AsyncIterator[None], request: pytest.FixtureRequest, ) -> None: + # Register a new function url = client.app.router["register_function"].url_for() response = await client.post(url, json=mock_function) data, error = await assert_status(response, expected_status_code=expected_register) @@ -92,8 +100,9 @@ async def test_register_get_delete_function( assert returned_function.uid is not None returned_function_uid = returned_function.uid + # Get the registered function url = client.app.router["get_function"].url_for( - function_id=str(returned_function_uid) + function_id=f"{returned_function_uid}" ) response = await client.get(url) data, error = await assert_status(response, expected_get) @@ -101,27 +110,68 @@ async def test_register_get_delete_function( retrieved_function = RegisteredProjectFunctionGet.model_validate(data) assert retrieved_function.uid == returned_function.uid + # List existing functions + url = client.app.router["list_functions"].url_for() + response = await client.get(url) + data, error = await assert_status(response, expected_list) + if not error: + retrieved_functions = TypeAdapter( + list[RegisteredProjectFunctionGet] + ).validate_python(data) + assert len(retrieved_functions) == 1 + assert retrieved_functions[0].uid == returned_function_uid + + # Update existing function + new_title = "Test Function (edited)" + new_description = "A test function (edited)" + url = client.app.router["update_function"].url_for( + function_id=f"{returned_function_uid}" + ) + response = await client.patch( + url, json={"title": new_title, "description": new_description} + ) + data, error = await assert_status(response, expected_update) + if not error: + updated_function = RegisteredProjectFunctionGet.model_validate(data) + assert updated_function.title == new_title + assert updated_function.description == new_description + + # Delete existing function url = client.app.router["delete_function"].url_for( - function_id=str(returned_function_uid) + function_id=f"{returned_function_uid}" ) response = await client.delete(url) data, error = await assert_status(response, expected_delete) + # Check if the function was effectively deleted url = client.app.router["get_function"].url_for( - function_id=str(returned_function_uid) + function_id=f"{returned_function_uid}" ) response = await client.get(url) data, error = await assert_status(response, expected_get2) @pytest.mark.parametrize("user_role", [UserRole.USER]) -@pytest.mark.parametrize("expected_write_functions", [True, False]) +@pytest.mark.parametrize( + "expected_read_functions,expected_write_functions", + [ + (True, True), + (True, False), + (False, True), # Weird, but allowed for testing purposes + (False, False), + ], +) async def test_list_user_functions_permissions( client: TestClient, logged_user: UserInfoDict, + expected_read_functions: bool, expected_write_functions: bool, logged_user_function_api_access_rights: dict[str, Any], ): + assert ( + logged_user_function_api_access_rights["read_functions"] + == expected_read_functions + ) assert ( logged_user_function_api_access_rights["write_functions"] == expected_write_functions @@ -133,4 +183,5 @@ async def test_list_user_functions_permissions( assert not error function_permissions = MyFunctionPermissionsGet.model_validate(data) + assert function_permissions.read_functions == expected_read_functions assert function_permissions.write_functions == expected_write_functions