diff --git a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/functions/functions_rpc_interface.py b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/functions/functions_rpc_interface.py index e451cb2b9678..4fd80e9fcece 100644 --- a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/functions/functions_rpc_interface.py +++ b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/functions/functions_rpc_interface.py @@ -17,6 +17,7 @@ RegisteredFunctionJobCollection, ) from models_library.functions import ( + FunctionClass, FunctionJobStatus, FunctionOutputs, FunctionUserAccessRights, @@ -24,6 +25,7 @@ ) from models_library.products import ProductName from models_library.rabbitmq_basic_types import RPCMethodName +from models_library.rest_ordering import OrderBy from models_library.rest_pagination import PageMetaInfoLimitOffset from models_library.users import UserID from pydantic import TypeAdapter @@ -135,6 +137,10 @@ async def list_functions( product_name: ProductName, pagination_offset: int, pagination_limit: int, + order_by: OrderBy | None = None, + filter_by_function_class: FunctionClass | None = None, + search_by_function_title: str | None = None, + search_by_multi_columns: str | None = None, ) -> tuple[list[RegisteredFunction], PageMetaInfoLimitOffset]: result: tuple[list[RegisteredFunction], PageMetaInfoLimitOffset] = ( await rabbitmq_rpc_client.request( @@ -144,6 +150,10 @@ async def list_functions( pagination_limit=pagination_limit, user_id=user_id, product_name=product_name, + order_by=order_by, + filter_by_function_class=filter_by_function_class, + search_by_function_title=search_by_function_title, + search_by_multi_columns=search_by_multi_columns, ) ) return TypeAdapter( 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 08c1f922e244..1864a903276b 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 @@ -3,6 +3,7 @@ from models_library.functions import ( Function, FunctionAccessRights, + FunctionClass, FunctionID, FunctionInputs, FunctionInputSchema, @@ -40,6 +41,7 @@ UnsupportedFunctionJobClassError, ) from models_library.products import ProductName +from models_library.rest_ordering import OrderBy from models_library.rest_pagination import PageMetaInfoLimitOffset from models_library.users import UserID from servicelib.rabbitmq import RPCRouter @@ -176,6 +178,10 @@ async def list_functions( product_name: ProductName, pagination_limit: int, pagination_offset: int, + order_by: OrderBy | None = None, + filter_by_function_class: FunctionClass | None = None, + search_by_function_title: str | None = None, + search_by_multi_columns: str | None = None, ) -> tuple[list[RegisteredFunction], PageMetaInfoLimitOffset]: return await _functions_service.list_functions( app=app, @@ -183,6 +189,10 @@ async def list_functions( product_name=product_name, pagination_limit=pagination_limit, pagination_offset=pagination_offset, + order_by=order_by, + filter_by_function_class=filter_by_function_class, + search_by_function_title=search_by_function_title, + search_by_multi_columns=search_by_multi_columns, ) 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 581b00305b2c..6c09b5c9a7ee 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 @@ -6,6 +6,7 @@ import sqlalchemy from aiohttp import web +from models_library.basic_types import IDStr from models_library.functions import ( FunctionAccessRightsDB, FunctionClass, @@ -54,6 +55,7 @@ ) from models_library.groups import GroupID from models_library.products import ProductName +from models_library.rest_ordering import OrderBy, OrderDirection from models_library.rest_pagination import PageMetaInfoLimitOffset from models_library.users import UserID from pydantic import TypeAdapter @@ -84,10 +86,10 @@ pass_or_acquire_connection, transaction_context, ) -from sqlalchemy import Text, cast +from sqlalchemy import String, Text, cast from sqlalchemy.engine.row import Row from sqlalchemy.ext.asyncio import AsyncConnection -from sqlalchemy.sql import func +from sqlalchemy.sql import ColumnElement, func from ..db.plugin import get_asyncpg_engine from ..groups.api import list_all_user_groups_ids @@ -110,6 +112,8 @@ function_job_collections_access_rights_table, FunctionJobCollectionAccessRightsDB ) +DEFAULT_ORDER_BY = OrderBy(field=IDStr("modified"), direction=OrderDirection.DESC) + async def create_function( # noqa: PLR0913 app: web.Application, @@ -347,6 +351,38 @@ async def get_function( return RegisteredFunctionDB.model_validate(row) +def _create_list_functions_attributes_filters( + *, + filter_by_function_class: FunctionClass | None, + search_by_multi_columns: str | None, + search_by_function_title: str | None, +) -> list[ColumnElement]: + attributes_filters: list[ColumnElement] = [] + + if filter_by_function_class is not None: + attributes_filters.append( + functions_table.c.function_class == filter_by_function_class.value + ) + + if search_by_multi_columns is not None: + attributes_filters.append( + (functions_table.c.title.ilike(f"%{search_by_multi_columns}%")) + | (functions_table.c.description.ilike(f"%{search_by_multi_columns}%")) + | ( + cast(functions_table.c.uuid, String).ilike( + f"%{search_by_multi_columns}%" + ) + ) + ) + + if search_by_function_title is not None: + attributes_filters.append( + functions_table.c.title.ilike(f"%{search_by_function_title}%") + ) + + return attributes_filters + + async def list_functions( app: web.Application, connection: AsyncConnection | None = None, @@ -355,7 +391,12 @@ async def list_functions( product_name: ProductName, pagination_limit: int, pagination_offset: int, + order_by: OrderBy | None = None, + filter_by_function_class: FunctionClass | None = None, + search_by_multi_columns: str | None = None, + search_by_function_title: str | None = None, ) -> tuple[list[RegisteredFunctionDB], PageMetaInfoLimitOffset]: + async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: await check_user_api_access_rights( app, @@ -365,39 +406,59 @@ async def list_functions( api_access_rights=[FunctionsApiAccessRights.READ_FUNCTIONS], ) user_groups = await list_all_user_groups_ids(app, user_id=user_id) + attributes_filters = _create_list_functions_attributes_filters( + filter_by_function_class=filter_by_function_class, + search_by_multi_columns=search_by_multi_columns, + search_by_function_title=search_by_function_title, + ) - subquery = ( - functions_access_rights_table.select() - .with_only_columns(functions_access_rights_table.c.function_uuid) + # Build the base query with join to access rights table + base_query = ( + functions_table.select() + .join( + functions_access_rights_table, + functions_table.c.uuid == functions_access_rights_table.c.function_uuid, + ) .where( functions_access_rights_table.c.group_id.in_(user_groups), functions_access_rights_table.c.product_name == product_name, functions_access_rights_table.c.read, + *attributes_filters, ) ) - total_count_result = await conn.scalar( - func.count() - .select() - .select_from(functions_table) - .where(functions_table.c.uuid.in_(subquery)) + # Get total count + total_count = await conn.scalar( + func.count().select().select_from(base_query.subquery()) ) - if total_count_result == 0: + if total_count == 0: return [], PageMetaInfoLimitOffset( total=0, offset=pagination_offset, limit=pagination_limit, count=0 ) + + if order_by is None: + order_by = DEFAULT_ORDER_BY + # Apply ordering and pagination + if order_by.direction == OrderDirection.ASC: + base_query = base_query.order_by( + sqlalchemy.asc(getattr(functions_table.c, order_by.field)), + functions_table.c.uuid, + ) + else: + base_query = base_query.order_by( + sqlalchemy.desc(getattr(functions_table.c, order_by.field)), + functions_table.c.uuid, + ) + function_rows = [ RegisteredFunctionDB.model_validate(row) async for row in await conn.stream( - functions_table.select() - .where(functions_table.c.uuid.in_(subquery)) - .offset(pagination_offset) - .limit(pagination_limit) + base_query.offset(pagination_offset).limit(pagination_limit) ) ] return function_rows, PageMetaInfoLimitOffset( - total=total_count_result, + total=total_count, offset=pagination_offset, limit=pagination_limit, count=len(function_rows), 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 f1ec5024a381..99212d1c26c5 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 @@ -1,4 +1,5 @@ from aiohttp import web +from models_library.basic_types import IDStr from models_library.functions import ( Function, FunctionClass, @@ -37,6 +38,7 @@ ) from models_library.groups import GroupID from models_library.products import ProductName +from models_library.rest_ordering import OrderBy from models_library.rest_pagination import PageMetaInfoLimitOffset from models_library.users import UserID from servicelib.rabbitmq import RPCRouter @@ -185,6 +187,10 @@ async def list_functions( product_name: ProductName, pagination_limit: int, pagination_offset: int, + order_by: OrderBy | None = None, + filter_by_function_class: FunctionClass | None = None, + search_by_function_title: str | None = None, + search_by_multi_columns: str | None = None, ) -> tuple[list[RegisteredFunction], PageMetaInfoLimitOffset]: returned_functions, page = await _functions_repository.list_functions( app=app, @@ -192,6 +198,17 @@ async def list_functions( product_name=product_name, pagination_limit=pagination_limit, pagination_offset=pagination_offset, + order_by=( + OrderBy( + field=IDStr("uuid") if order_by.field == "uid" else order_by.field, + direction=order_by.direction, + ) + if order_by + else None + ), + filter_by_function_class=filter_by_function_class, + search_by_function_title=search_by_function_title, + search_by_multi_columns=search_by_multi_columns, ) return [ _decode_function(returned_function) for returned_function in returned_functions diff --git a/services/web/server/tests/unit/with_dbs/04/functions/test_functions_controller_rpc.py b/services/web/server/tests/unit/with_dbs/04/functions/test_functions_controller_rpc.py index 6b54ed191e8d..86411753b0d1 100644 --- a/services/web/server/tests/unit/with_dbs/04/functions/test_functions_controller_rpc.py +++ b/services/web/server/tests/unit/with_dbs/04/functions/test_functions_controller_rpc.py @@ -12,7 +12,12 @@ JSONFunctionOutputSchema, ProjectFunction, ) -from models_library.functions import FunctionUserAccessRights +from models_library.basic_types import IDStr +from models_library.functions import ( + FunctionClass, + FunctionUserAccessRights, + SolverFunction, +) from models_library.functions_errors import ( FunctionIDNotFoundError, FunctionReadAccessDeniedError, @@ -20,6 +25,7 @@ FunctionWriteAccessDeniedError, ) from models_library.products import ProductName +from models_library.rest_ordering import OrderBy, OrderDirection from pytest_simcore.helpers.webserver_users import UserInfoDict from servicelib.rabbitmq import RabbitMQRPCClient from servicelib.rabbitmq.rpc_interfaces.webserver.functions import ( @@ -272,11 +278,24 @@ async def test_list_functions_mixed_user( ) +@pytest.mark.parametrize("user_role", [UserRole.USER]) @pytest.mark.parametrize( - "user_role", - [UserRole.USER], + "order_by", + [ + None, + OrderBy(field=IDStr("uid"), direction=OrderDirection.ASC), + OrderBy(field=IDStr("uid"), direction=OrderDirection.DESC), + ], ) -async def test_list_functions_with_pagination( +@pytest.mark.parametrize( + "test_pagination_limit, test_pagination_offset", + [ + (5, 0), + (2, 2), + (12, 4), + ], +) +async def test_list_functions_with_pagination_ordering( client: TestClient, add_user_function_api_access_rights: None, rpc_client: RabbitMQRPCClient, @@ -284,52 +303,197 @@ async def test_list_functions_with_pagination( clean_functions: None, osparc_product_name: ProductName, logged_user: UserInfoDict, + order_by: OrderBy | None, + test_pagination_limit: int, + test_pagination_offset: int, ): # Register multiple functions - TOTAL_FUNCTIONS = 3 - for _ in range(TOTAL_FUNCTIONS): + TOTAL_FUNCTIONS = 10 + registered_functions = [ await functions_rpc.register_function( rabbitmq_rpc_client=rpc_client, function=mock_function, user_id=logged_user["id"], product_name=osparc_product_name, ) - - functions, page_info = await functions_rpc.list_functions( - rabbitmq_rpc_client=rpc_client, - pagination_limit=2, - pagination_offset=0, - user_id=logged_user["id"], - product_name=osparc_product_name, - ) + for _ in range(TOTAL_FUNCTIONS) + ] # List functions with pagination functions, page_info = await functions_rpc.list_functions( rabbitmq_rpc_client=rpc_client, - pagination_limit=2, - pagination_offset=0, + pagination_limit=test_pagination_limit, + pagination_offset=test_pagination_offset, user_id=logged_user["id"], product_name=osparc_product_name, + order_by=order_by, ) # Assert the list contains the correct number of functions - assert len(functions) == 2 - assert page_info.count == 2 + assert len(functions) == min( + test_pagination_limit, max(0, TOTAL_FUNCTIONS - test_pagination_offset) + ) + assert all(f.uid in [rf.uid for rf in registered_functions] for f in functions) + assert page_info.count == len(functions) assert page_info.total == TOTAL_FUNCTIONS - # List the next page of functions - functions, page_info = await functions_rpc.list_functions( - rabbitmq_rpc_client=rpc_client, - pagination_limit=2, - pagination_offset=2, - user_id=logged_user["id"], - product_name=osparc_product_name, + # Verify the functions are sorted correctly based on the order_by parameter + if order_by: + field = order_by.field + direction = order_by.direction + sorted_functions = sorted( + functions, + key=lambda f: getattr(f, field), + reverse=(direction == OrderDirection.DESC), + ) + assert functions == sorted_functions + + +@pytest.mark.parametrize( + "user_role", + [UserRole.USER], +) +async def test_list_functions_search( + client: TestClient, + rpc_client: RabbitMQRPCClient, + mock_function: ProjectFunction, + logged_user: UserInfoDict, + osparc_product_name: ProductName, + add_user_function_api_access_rights: None, +): + mock_function_dummy1 = mock_function.copy() + mock_function_dummy1.title = "Function TitleDummy1" + mock_function_dummy1.description = "Function DescriptionDummy1" + + mock_function_dummy2 = mock_function.copy() + mock_function_dummy2.title = "Function TitleDummy2" + mock_function_dummy2.description = "Function DescriptionDummy2" + + registered_functions = {} + for function in [mock_function_dummy1, mock_function_dummy2]: + registered_functions[function.title] = [] + for _ in range(5): + registered_functions[function.title].append( + await functions_rpc.register_function( + rabbitmq_rpc_client=rpc_client, + function=function, + user_id=logged_user["id"], + product_name=osparc_product_name, + ) + ) + + for search_term, expected_number in [("Dummy", 10), ("Dummy2", 5)]: + # Search for the function by title + functions, _ = await functions_rpc.list_functions( + rabbitmq_rpc_client=rpc_client, + user_id=logged_user["id"], + product_name=osparc_product_name, + search_by_function_title=search_term, + pagination_limit=10, + pagination_offset=0, + ) + + # Assert the function is found + assert len(functions) == expected_number + if search_term == "Dummy2": + assert functions[0].uid in [ + function.uid + for function in registered_functions[mock_function_dummy2.title] + ] + + for search_term, expected_number in [ + ("Dummy", 10), + ("Dummy2", 5), + (str(registered_functions[mock_function_dummy2.title][0].uid)[:8], 1), + ("DescriptionDummy2", 5), + ]: + # Search for the function by name, description, or UUID (multi-column search) + functions, _ = await functions_rpc.list_functions( + rabbitmq_rpc_client=rpc_client, + user_id=logged_user["id"], + product_name=osparc_product_name, + search_by_multi_columns=search_term, + pagination_limit=10, + pagination_offset=0, + ) + + # Assert the function is found + assert len(functions) == expected_number + if search_term == "Dummy2": + assert functions[0].uid in [ + function.uid + for function in registered_functions[mock_function_dummy2.title] + ] + + +@pytest.mark.parametrize( + "user_role", + [UserRole.USER], +) +async def test_list_functions_with_filters( + client: TestClient, + rpc_client: RabbitMQRPCClient, + mock_function: ProjectFunction, + logged_user: UserInfoDict, + osparc_product_name: ProductName, + add_user_function_api_access_rights: None, +): + N_OF_PROJECT_FUNCTIONS = 3 + N_OF_SOLVER_FUNCTIONS = 4 + # Register the function first + registered_functions = [ + await functions_rpc.register_function( + rabbitmq_rpc_client=rpc_client, + function=mock_function, + user_id=logged_user["id"], + product_name=osparc_product_name, + ) + for _ in range(N_OF_PROJECT_FUNCTIONS) + ] + + solver_function = SolverFunction( + title="Solver Function", + description="A function that solves problems", + function_class=FunctionClass.SOLVER, + input_schema=JSONFunctionInputSchema(), + output_schema=JSONFunctionOutputSchema(), + default_inputs=None, + solver_key="simcore/services/comp/foo.bar-baz_/sub-dir_1/my-service1", + solver_version="0.0.0", + ) + registered_functions.extend( + [ + await functions_rpc.register_function( + rabbitmq_rpc_client=rpc_client, + function=solver_function, + user_id=logged_user["id"], + product_name=osparc_product_name, + ) + for _ in range(N_OF_SOLVER_FUNCTIONS) + ] ) - # Assert the list contains the correct number of functions - assert len(functions) == 1 - assert page_info.count == 1 - assert page_info.total == TOTAL_FUNCTIONS + for function_class in [FunctionClass.PROJECT, FunctionClass.SOLVER]: + # List functions with filters + functions, _ = await functions_rpc.list_functions( + rabbitmq_rpc_client=rpc_client, + user_id=logged_user["id"], + product_name=osparc_product_name, + filter_by_function_class=function_class, + pagination_limit=10, + pagination_offset=0, + ) + + # Assert the function is found + assert len(functions) == ( + N_OF_PROJECT_FUNCTIONS + if function_class == FunctionClass.PROJECT + else N_OF_SOLVER_FUNCTIONS + ) + assert all( + function.uid in [f.uid for f in registered_functions] + for function in functions + ) @pytest.mark.parametrize(