diff --git a/api/specs/web-server/_common.py b/api/specs/web-server/_common.py index f3dcd66bc5c..b6e1cf68769 100644 --- a/api/specs/web-server/_common.py +++ b/api/specs/web-server/_common.py @@ -8,15 +8,68 @@ from typing import Any, ClassVar, NamedTuple import yaml -from fastapi import FastAPI +from fastapi import FastAPI, Query from models_library.basic_types import LogLevel -from pydantic import BaseModel, Field +from models_library.utils.json_serialization import json_dumps +from pydantic import BaseModel, Field, create_model from pydantic.fields import FieldInfo from servicelib.fastapi.openapi import override_fastapi_openapi_method CURRENT_DIR = Path(sys.argv[0] if __name__ == "__main__" else __file__).resolve().parent +def _create_json_type(**schema_extras): + class _Json(str): + __slots__ = () + + @classmethod + def __modify_schema__(cls, field_schema: dict[str, Any]) -> None: + # openapi.json schema is corrected here + field_schema.update( + type="string", + # format="json-string" NOTE: we need to get rid of openapi-core in web-server before using this! + ) + if schema_extras: + field_schema.update(schema_extras) + + return _Json + + +def as_query(model_class: type[BaseModel]) -> type[BaseModel]: + fields = {} + for field_name, model_field in model_class.__fields__.items(): + + field_type = model_field.type_ + default_value = model_field.default + + kwargs = { + "alias": model_field.field_info.alias, + "title": model_field.field_info.title, + "description": model_field.field_info.description, + "gt": model_field.field_info.gt, + "ge": model_field.field_info.ge, + "lt": model_field.field_info.lt, + "le": model_field.field_info.le, + "min_length": model_field.field_info.min_length, + "max_length": model_field.field_info.max_length, + "regex": model_field.field_info.regex, + **model_field.field_info.extra, + } + + if issubclass(field_type, BaseModel): + # Complex fields + field_type = _create_json_type( + description=kwargs["description"], + example=kwargs.get("example_json"), + ) + default_value = json_dumps(default_value) if default_value else None + + fields[field_name] = (field_type, Query(default=default_value, **kwargs)) + + new_model_name = f"{model_class.__name__}Query" + return create_model(new_model_name, **fields) + + class Log(BaseModel): level: LogLevel | None = Field("INFO", description="log level") message: str = Field( @@ -120,6 +173,9 @@ def assert_handler_signature_against_model( for field in model_cls.__fields__.values() ] - assert {p.name for p in implemented_params}.issubset( # nosec - {p.name for p in specs_params} - ), f"Entrypoint {handler} does not implement OAS" + implemented_names = {p.name for p in implemented_params} + specified_names = {p.name for p in specs_params} + + if not implemented_names.issubset(specified_names): + msg = f"Entrypoint {handler} does not implement OAS: {implemented_names} not in {specified_names}" + raise AssertionError(msg) diff --git a/api/specs/web-server/_folders.py b/api/specs/web-server/_folders.py index ef5e29ac85d..c2e75579b26 100644 --- a/api/specs/web-server/_folders.py +++ b/api/specs/web-server/_folders.py @@ -9,19 +9,20 @@ from typing import Annotated -from fastapi import APIRouter, Depends, Query, status +from _common import as_query +from fastapi import APIRouter, Depends, status from models_library.api_schemas_webserver.folders_v2 import ( CreateFolderBodyParams, FolderGet, PutFolderBodyParams, ) -from models_library.folders import FolderID from models_library.generics import Envelope -from models_library.rest_pagination import PageQueryParameters -from models_library.workspaces import WorkspaceID -from pydantic import Json from simcore_service_webserver._meta import API_VTAG -from simcore_service_webserver.folders._models import FolderFilters, FoldersPathParams +from simcore_service_webserver.folders._models import ( + FolderSearchQueryParams, + FoldersListQueryParams, + FoldersPathParams, +) router = APIRouter( prefix=f"/{API_VTAG}", @@ -36,7 +37,9 @@ response_model=Envelope[FolderGet], status_code=status.HTTP_201_CREATED, ) -async def create_folder(_body: CreateFolderBodyParams): +async def create_folder( + _body: CreateFolderBodyParams, +): ... @@ -45,20 +48,7 @@ async def create_folder(_body: CreateFolderBodyParams): response_model=Envelope[list[FolderGet]], ) async def list_folders( - params: Annotated[PageQueryParameters, Depends()], - folder_id: FolderID | None = None, - workspace_id: WorkspaceID | None = None, - order_by: Annotated[ - Json, - Query( - description="Order by field (modified_at|name|description) and direction (asc|desc). The default sorting order is ascending.", - example='{"field": "name", "direction": "desc"}', - ), - ] = '{"field": "modified_at", "direction": "desc"}', - filters: Annotated[ - Json | None, - Query(description=FolderFilters.schema_json(indent=1)), - ] = None, + _query: Annotated[as_query(FoldersListQueryParams), Depends()], ): ... @@ -68,19 +58,7 @@ async def list_folders( response_model=Envelope[list[FolderGet]], ) async def list_folders_full_search( - params: Annotated[PageQueryParameters, Depends()], - text: str | None = None, - order_by: Annotated[ - Json, - Query( - description="Order by field (modified_at|name|description) and direction (asc|desc). The default sorting order is ascending.", - example='{"field": "name", "direction": "desc"}', - ), - ] = '{"field": "modified_at", "direction": "desc"}', - filters: Annotated[ - Json | None, - Query(description=FolderFilters.schema_json(indent=1)), - ] = None, + _query: Annotated[as_query(FolderSearchQueryParams), Depends()], ): ... @@ -89,7 +67,9 @@ async def list_folders_full_search( "/folders/{folder_id}", response_model=Envelope[FolderGet], ) -async def get_folder(_path: Annotated[FoldersPathParams, Depends()]): +async def get_folder( + _path: Annotated[FoldersPathParams, Depends()], +): ... @@ -98,7 +78,8 @@ async def get_folder(_path: Annotated[FoldersPathParams, Depends()]): response_model=Envelope[FolderGet], ) async def replace_folder( - _path: Annotated[FoldersPathParams, Depends()], _body: PutFolderBodyParams + _path: Annotated[FoldersPathParams, Depends()], + _body: PutFolderBodyParams, ): ... @@ -107,5 +88,7 @@ async def replace_folder( "/folders/{folder_id}", status_code=status.HTTP_204_NO_CONTENT, ) -async def delete_folder(_path: Annotated[FoldersPathParams, Depends()]): +async def delete_folder( + _path: Annotated[FoldersPathParams, Depends()], +): ... diff --git a/api/specs/web-server/_groups.py b/api/specs/web-server/_groups.py index 1f8d7e15f56..9fa015bd7b5 100644 --- a/api/specs/web-server/_groups.py +++ b/api/specs/web-server/_groups.py @@ -48,7 +48,7 @@ async def list_groups(): response_model=Envelope[GroupGet], status_code=status.HTTP_201_CREATED, ) -async def create_group(_b: GroupCreate): +async def create_group(_body: GroupCreate): """ Creates an organization group """ @@ -58,7 +58,7 @@ async def create_group(_b: GroupCreate): "/groups/{gid}", response_model=Envelope[GroupGet], ) -async def get_group(_p: Annotated[_GroupPathParams, Depends()]): +async def get_group(_path: Annotated[_GroupPathParams, Depends()]): """ Get an organization group """ @@ -69,8 +69,8 @@ async def get_group(_p: Annotated[_GroupPathParams, Depends()]): response_model=Envelope[GroupGet], ) async def update_group( - _p: Annotated[_GroupPathParams, Depends()], - _b: GroupUpdate, + _path: Annotated[_GroupPathParams, Depends()], + _body: GroupUpdate, ): """ Updates organization groups @@ -81,7 +81,7 @@ async def update_group( "/groups/{gid}", status_code=status.HTTP_204_NO_CONTENT, ) -async def delete_group(_p: Annotated[_GroupPathParams, Depends()]): +async def delete_group(_path: Annotated[_GroupPathParams, Depends()]): """ Deletes organization groups """ @@ -91,7 +91,7 @@ async def delete_group(_p: Annotated[_GroupPathParams, Depends()]): "/groups/{gid}/users", response_model=Envelope[list[GroupUserGet]], ) -async def get_all_group_users(_p: Annotated[_GroupPathParams, Depends()]): +async def get_all_group_users(_path: Annotated[_GroupPathParams, Depends()]): """ Gets users in organization groups """ @@ -102,8 +102,8 @@ async def get_all_group_users(_p: Annotated[_GroupPathParams, Depends()]): status_code=status.HTTP_204_NO_CONTENT, ) async def add_group_user( - _p: Annotated[_GroupPathParams, Depends()], - _b: GroupUserAdd, + _path: Annotated[_GroupPathParams, Depends()], + _body: GroupUserAdd, ): """ Adds a user to an organization group @@ -115,7 +115,7 @@ async def add_group_user( response_model=Envelope[GroupUserGet], ) async def get_group_user( - _p: Annotated[_GroupUserPathParams, Depends()], + _path: Annotated[_GroupUserPathParams, Depends()], ): """ Gets specific user in an organization group @@ -127,8 +127,8 @@ async def get_group_user( response_model=Envelope[GroupUserGet], ) async def update_group_user( - _p: Annotated[_GroupUserPathParams, Depends()], - _b: GroupUserUpdate, + _path: Annotated[_GroupUserPathParams, Depends()], + _body: GroupUserUpdate, ): """ Updates user (access-rights) to an organization group @@ -140,7 +140,7 @@ async def update_group_user( status_code=status.HTTP_204_NO_CONTENT, ) async def delete_group_user( - _p: Annotated[_GroupUserPathParams, Depends()], + _path: Annotated[_GroupUserPathParams, Depends()], ): """ Removes a user from an organization group @@ -157,8 +157,8 @@ async def delete_group_user( response_model=Envelope[dict[str, Any]], ) async def get_group_classifiers( - _p: Annotated[_GroupPathParams, Depends()], - _q: Annotated[_ClassifiersQuery, Depends()], + _path: Annotated[_GroupPathParams, Depends()], + _query: Annotated[_ClassifiersQuery, Depends()], ): ... diff --git a/api/specs/web-server/_projects_crud.py b/api/specs/web-server/_projects_crud.py index 4c560464eb8..31f26d6425e 100644 --- a/api/specs/web-server/_projects_crud.py +++ b/api/specs/web-server/_projects_crud.py @@ -11,7 +11,8 @@ from typing import Annotated -from fastapi import APIRouter, Depends, Header, Query, status +from _common import as_query +from fastapi import APIRouter, Depends, Header, status from models_library.api_schemas_directorv2.dynamic_services import ( GetProjectInactivityResponse, ) @@ -27,14 +28,14 @@ from models_library.projects import ProjectID from models_library.projects_nodes_io import NodeID from models_library.rest_pagination import Page -from pydantic import Json +from pydantic import BaseModel from simcore_service_webserver._meta import API_VTAG from simcore_service_webserver.projects._common_models import ProjectPathParams from simcore_service_webserver.projects._crud_handlers import ProjectCreateParams from simcore_service_webserver.projects._crud_handlers_models import ( - ProjectFilters, - ProjectListFullSearchParams, - ProjectListParams, + ProjectActiveQueryParams, + ProjectsListQueryParams, + ProjectsSearchQueryParams, ) router = APIRouter( @@ -45,28 +46,34 @@ ) -@router.post( - "/projects", - response_model=Envelope[TaskGet], - summary="Creates a new project or copies an existing one", - status_code=status.HTTP_201_CREATED, -) -async def create_project( - _params: Annotated[ProjectCreateParams, Depends()], - _create: ProjectCreateNew | ProjectCopyOverride, - x_simcore_user_agent: Annotated[str | None, Header()] = "undefined", +class _ProjectCreateHeaderParams(BaseModel): + x_simcore_user_agent: Annotated[ + str | None, Header(description="Optional simcore user agent") + ] = "undefined" x_simcore_parent_project_uuid: Annotated[ ProjectID | None, Header( description="Optionally sets a parent project UUID (both project and node must be set)", ), - ] = None, + ] = None x_simcore_parent_node_id: Annotated[ NodeID | None, Header( description="Optionally sets a parent node ID (both project and node must be set)", ), - ] = None, + ] = None + + +@router.post( + "/projects", + response_model=Envelope[TaskGet], + summary="Creates a new project or copies an existing one", + status_code=status.HTTP_201_CREATED, +) +async def create_project( + _h: Annotated[_ProjectCreateHeaderParams, Depends()], + _path: Annotated[ProjectCreateParams, Depends()], + _body: ProjectCreateNew | ProjectCopyOverride, ): ... @@ -76,18 +83,7 @@ async def create_project( response_model=Page[ProjectListItem], ) async def list_projects( - _params: Annotated[ProjectListParams, Depends()], - order_by: Annotated[ - Json, - Query( - description="Order by field (type|uuid|name|description|prj_owner|creation_date|last_change_date) and direction (asc|desc). The default sorting order is ascending.", - example='{"field": "last_change_date", "direction": "desc"}', - ), - ] = '{"field": "last_change_date", "direction": "desc"}', - filters: Annotated[ - Json | None, - Query(description=ProjectFilters.schema_json(indent=1)), - ] = None, + _query: Annotated[as_query(ProjectsListQueryParams), Depends()], ): ... @@ -96,7 +92,9 @@ async def list_projects( "/projects/active", response_model=Envelope[ProjectGet], ) -async def get_active_project(client_session_id: str): +async def get_active_project( + _query: Annotated[ProjectActiveQueryParams, Depends()], +): ... @@ -104,7 +102,9 @@ async def get_active_project(client_session_id: str): "/projects/{project_id}", response_model=Envelope[ProjectGet], ) -async def get_project(project_id: ProjectID): +async def get_project( + _path: Annotated[ProjectPathParams, Depends()], +): ... @@ -113,7 +113,10 @@ async def get_project(project_id: ProjectID): response_model=None, status_code=status.HTTP_204_NO_CONTENT, ) -async def patch_project(project_id: ProjectID, _new: ProjectPatch): +async def patch_project( + _path: Annotated[ProjectPathParams, Depends()], + _body: ProjectPatch, +): ... @@ -121,7 +124,9 @@ async def patch_project(project_id: ProjectID, _new: ProjectPatch): "/projects/{project_id}", status_code=status.HTTP_204_NO_CONTENT, ) -async def delete_project(project_id: ProjectID): +async def delete_project( + _path: Annotated[ProjectPathParams, Depends()], +): ... @@ -131,24 +136,17 @@ async def delete_project(project_id: ProjectID): status_code=status.HTTP_201_CREATED, ) async def clone_project( - _params: Annotated[ProjectPathParams, Depends()], + _path: Annotated[ProjectPathParams, Depends()], ): ... @router.get( "/projects:search", - response_model=Page[ProjectListFullSearchParams], + response_model=Page[ProjectListItem], ) async def list_projects_full_search( - _params: Annotated[ProjectListFullSearchParams, Depends()], - order_by: Annotated[ - Json, - Query( - description="Order by field (type|uuid|name|description|prj_owner|creation_date|last_change_date) and direction (asc|desc). The default sorting order is ascending.", - example='{"field": "last_change_date", "direction": "desc"}', - ), - ] = ('{"field": "last_change_date", "direction": "desc"}',), + _query: Annotated[as_query(ProjectsSearchQueryParams), Depends()], ): ... @@ -159,6 +157,6 @@ async def list_projects_full_search( status_code=status.HTTP_200_OK, ) async def get_project_inactivity( - _params: Annotated[ProjectPathParams, Depends()], + _path: Annotated[ProjectPathParams, Depends()], ): ... diff --git a/api/specs/web-server/_resource_usage.py b/api/specs/web-server/_resource_usage.py index 54924473746..2f9b1213b04 100644 --- a/api/specs/web-server/_resource_usage.py +++ b/api/specs/web-server/_resource_usage.py @@ -11,8 +11,8 @@ from typing import Annotated -from _common import assert_handler_signature_against_model -from fastapi import APIRouter, Query, status +from _common import as_query +from fastapi import APIRouter, Depends, status from models_library.api_schemas_resource_usage_tracker.service_runs import ( OsparcCreditsAggregatedByServiceGet, ) @@ -29,92 +29,49 @@ UpdatePricingUnitBodyParams, ) from models_library.generics import Envelope -from models_library.resource_tracker import ( - PricingPlanId, - PricingUnitId, - ServicesAggregatedUsagesTimePeriod, - ServicesAggregatedUsagesType, -) -from models_library.rest_pagination import DEFAULT_NUMBER_OF_ITEMS_PER_PAGE -from models_library.wallets import WalletID -from pydantic import Json, NonNegativeInt from simcore_service_webserver._meta import API_VTAG from simcore_service_webserver.resource_usage._pricing_plans_admin_handlers import ( - _GetPricingPlanPathParams, - _GetPricingUnitPathParams, + PricingPlanGetPathParams, + PricingUnitGetPathParams, ) from simcore_service_webserver.resource_usage._pricing_plans_handlers import ( - _GetPricingPlanUnitPathParams, + PricingPlanUnitGetPathParams, ) from simcore_service_webserver.resource_usage._service_runs_handlers import ( - ORDER_BY_DESCRIPTION, - _ListServicesAggregatedUsagesQueryParams, - _ListServicesResourceUsagesQueryParams, - _ListServicesResourceUsagesQueryParamsWithPagination, + ServicesAggregatedUsagesListQueryParams, + ServicesResourceUsagesListQueryParams, + ServicesResourceUsagesReportQueryParams, ) router = APIRouter(prefix=f"/{API_VTAG}") -# -# API entrypoints -# - - @router.get( "/services/-/resource-usages", response_model=Envelope[list[ServiceRunGet]], - summary="Retrieve finished and currently running user services (user and product are taken from context, optionally wallet_id parameter might be provided).", + summary="Retrieve finished and currently running user services" + " (user and product are taken from context, optionally wallet_id parameter might be provided).", tags=["usage"], ) async def list_resource_usage_services( - order_by: Annotated[ - Json, - Query( - description="Order by field (wallet_id|wallet_name|user_id|project_id|project_name|node_id|node_name|service_key|service_version|service_type|started_at|stopped_at|service_run_status|credit_cost|transaction_status) and direction (asc|desc). The default sorting order is ascending.", - example='{"field": "started_at", "direction": "desc"}', - ), - ] = '{"field": "started_at", "direction": "desc"}', - filters: Annotated[ - Json | None, - Query( - description="Filters to process on the resource usages list, encoded as JSON. Currently supports the filtering of 'started_at' field with 'from' and 'until' parameters in ISO 8601 format. The date range specified is inclusive.", - example='{"started_at": {"from": "yyyy-mm-dd", "until": "yyyy-mm-dd"}}', - ), - ] = None, - wallet_id: Annotated[WalletID | None, Query] = None, - limit: int = DEFAULT_NUMBER_OF_ITEMS_PER_PAGE, - offset: NonNegativeInt = 0, + _query: Annotated[as_query(ServicesResourceUsagesListQueryParams), Depends()], ): ... -assert_handler_signature_against_model( - list_resource_usage_services, _ListServicesResourceUsagesQueryParamsWithPagination -) - - @router.get( "/services/-/aggregated-usages", response_model=Envelope[list[OsparcCreditsAggregatedByServiceGet]], - summary="Used credits based on aggregate by type, currently supported `services`. (user and product are taken from context, optionally wallet_id parameter might be provided).", + summary="Used credits based on aggregate by type, currently supported `services`" + ". (user and product are taken from context, optionally wallet_id parameter might be provided).", tags=["usage"], ) async def list_osparc_credits_aggregated_usages( - aggregated_by: ServicesAggregatedUsagesType, - time_period: ServicesAggregatedUsagesTimePeriod, - wallet_id: Annotated[WalletID, Query], - limit: int = DEFAULT_NUMBER_OF_ITEMS_PER_PAGE, - offset: NonNegativeInt = 0, + _query: Annotated[as_query(ServicesAggregatedUsagesListQueryParams), Depends()] ): ... -assert_handler_signature_against_model( - list_osparc_credits_aggregated_usages, _ListServicesAggregatedUsagesQueryParams -) - - @router.get( "/services/-/usage-report", status_code=status.HTTP_302_FOUND, @@ -124,33 +81,15 @@ async def list_osparc_credits_aggregated_usages( } }, tags=["usage"], - summary="Redirects to download CSV link. CSV obtains finished and currently running user services (user and product are taken from context, optionally wallet_id parameter might be provided).", + summary="Redirects to download CSV link. CSV obtains finished and currently running " + "user services (user and product are taken from context, optionally wallet_id parameter might be provided).", ) async def export_resource_usage_services( - order_by: Annotated[ - Json, - Query( - description="", - example='{"field": "started_at", "direction": "desc"}', - ), - ] = '{"field": "started_at", "direction": "desc"}', - filters: Annotated[ - Json | None, - Query( - description=ORDER_BY_DESCRIPTION, - example='{"started_at": {"from": "yyyy-mm-dd", "until": "yyyy-mm-dd"}}', - ), - ] = None, - wallet_id: Annotated[WalletID | None, Query] = None, + _query: Annotated[as_query(ServicesResourceUsagesReportQueryParams), Depends()] ): ... -assert_handler_signature_against_model( - list_resource_usage_services, _ListServicesResourceUsagesQueryParams -) - - @router.get( "/pricing-plans/{pricing_plan_id}/pricing-units/{pricing_unit_id}", response_model=Envelope[PricingUnitGet], @@ -158,16 +97,11 @@ async def export_resource_usage_services( tags=["pricing-plans"], ) async def get_pricing_plan_unit( - pricing_plan_id: PricingPlanId, pricing_unit_id: PricingUnitId + _path: Annotated[PricingPlanUnitGetPathParams, Depends()], ): ... -assert_handler_signature_against_model( - get_pricing_plan_unit, _GetPricingPlanUnitPathParams -) - - ## Pricing plans for Admin panel @@ -189,21 +123,20 @@ async def list_pricing_plans(): tags=["admin"], ) async def get_pricing_plan( - pricing_plan_id: PricingPlanId, + _path: Annotated[PricingPlanGetPathParams, Depends()], ): ... -assert_handler_signature_against_model(get_pricing_plan, _GetPricingPlanPathParams) - - @router.post( "/admin/pricing-plans", response_model=Envelope[PricingPlanAdminGet], summary="Create pricing plan", tags=["admin"], ) -async def create_pricing_plan(body: CreatePricingPlanBodyParams): +async def create_pricing_plan( + _body: CreatePricingPlanBodyParams, +): ... @@ -214,14 +147,12 @@ async def create_pricing_plan(body: CreatePricingPlanBodyParams): tags=["admin"], ) async def update_pricing_plan( - pricing_plan_id: PricingPlanId, body: UpdatePricingPlanBodyParams + _path: Annotated[PricingPlanGetPathParams, Depends()], + _body: UpdatePricingPlanBodyParams, ): ... -assert_handler_signature_against_model(update_pricing_plan, _GetPricingPlanPathParams) - - ## Pricing units for Admin panel @@ -232,14 +163,11 @@ async def update_pricing_plan( tags=["admin"], ) async def get_pricing_unit( - pricing_plan_id: PricingPlanId, pricing_unit_id: PricingUnitId + _path: Annotated[PricingUnitGetPathParams, Depends()], ): ... -assert_handler_signature_against_model(get_pricing_unit, _GetPricingUnitPathParams) - - @router.post( "/admin/pricing-plans/{pricing_plan_id}/pricing-units", response_model=Envelope[PricingUnitAdminGet], @@ -247,14 +175,12 @@ async def get_pricing_unit( tags=["admin"], ) async def create_pricing_unit( - pricing_plan_id: PricingPlanId, body: CreatePricingUnitBodyParams + _path: Annotated[PricingPlanGetPathParams, Depends()], + _body: CreatePricingUnitBodyParams, ): ... -assert_handler_signature_against_model(create_pricing_unit, _GetPricingPlanPathParams) - - @router.put( "/admin/pricing-plans/{pricing_plan_id}/pricing-units/{pricing_unit_id}", response_model=Envelope[PricingUnitAdminGet], @@ -262,16 +188,12 @@ async def create_pricing_unit( tags=["admin"], ) async def update_pricing_unit( - pricing_plan_id: PricingPlanId, - pricing_unit_id: PricingUnitId, - body: UpdatePricingUnitBodyParams, + _path: Annotated[PricingUnitGetPathParams, Depends()], + _body: UpdatePricingUnitBodyParams, ): ... -assert_handler_signature_against_model(update_pricing_unit, _GetPricingUnitPathParams) - - ## Pricing Plans to Service Admin panel @@ -282,14 +204,11 @@ async def update_pricing_unit( tags=["admin"], ) async def list_connected_services_to_pricing_plan( - pricing_plan_id: PricingPlanId, + _path: Annotated[PricingPlanGetPathParams, Depends()], ): ... -assert_handler_signature_against_model(update_pricing_unit, _GetPricingPlanPathParams) - - @router.post( "/admin/pricing-plans/{pricing_plan_id}/billable-services", response_model=Envelope[PricingPlanToServiceAdminGet], @@ -297,10 +216,7 @@ async def list_connected_services_to_pricing_plan( tags=["admin"], ) async def connect_service_to_pricing_plan( - pricing_plan_id: PricingPlanId, - body: ConnectServiceToPricingPlanBodyParams, + _path: Annotated[PricingPlanGetPathParams, Depends()], + _body: ConnectServiceToPricingPlanBodyParams, ): ... - - -assert_handler_signature_against_model(update_pricing_unit, _GetPricingPlanPathParams) diff --git a/api/specs/web-server/_trash.py b/api/specs/web-server/_trash.py index cdd883f7cf3..9aa23b8b288 100644 --- a/api/specs/web-server/_trash.py +++ b/api/specs/web-server/_trash.py @@ -48,8 +48,8 @@ def empty_trash(): }, ) def trash_project( - _p: Annotated[ProjectPathParams, Depends()], - _q: Annotated[RemoveQueryParams, Depends()], + _path: Annotated[ProjectPathParams, Depends()], + _query: Annotated[RemoveQueryParams, Depends()], ): ... @@ -60,7 +60,7 @@ def trash_project( status_code=status.HTTP_204_NO_CONTENT, ) def untrash_project( - _p: Annotated[ProjectPathParams, Depends()], + _path: Annotated[ProjectPathParams, Depends()], ): ... @@ -81,8 +81,8 @@ def untrash_project( }, ) def trash_folder( - _p: Annotated[FoldersPathParams, Depends()], - _q: Annotated[RemoveQueryParams_duplicated, Depends()], + _path: Annotated[FoldersPathParams, Depends()], + _query: Annotated[RemoveQueryParams_duplicated, Depends()], ): ... @@ -93,6 +93,6 @@ def trash_folder( status_code=status.HTTP_204_NO_CONTENT, ) def untrash_folder( - _p: Annotated[FoldersPathParams, Depends()], + _path: Annotated[FoldersPathParams, Depends()], ): ... diff --git a/packages/models-library/src/models_library/rest_base.py b/packages/models-library/src/models_library/rest_base.py new file mode 100644 index 00000000000..a6b24ef6382 --- /dev/null +++ b/packages/models-library/src/models_library/rest_base.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel, Extra + + +class RequestParameters(BaseModel): + """ + Base model for any type of request parameters, + i.e. context, path, query, headers + """ + + def as_params(self, **export_options) -> dict[str, str]: + data = self.dict(**export_options) + return {k: f"{v}" for k, v in data.items()} + + +class StrictRequestParameters(RequestParameters): + """Use a base class for context, path and query parameters""" + + class Config: + extra = Extra.forbid # strict diff --git a/packages/models-library/src/models_library/rest_ordering.py b/packages/models-library/src/models_library/rest_ordering.py index c8a791343ee..31a59e984bd 100644 --- a/packages/models-library/src/models_library/rest_ordering.py +++ b/packages/models-library/src/models_library/rest_ordering.py @@ -1,8 +1,12 @@ from enum import Enum +from typing import Any, ClassVar -from pydantic import BaseModel, Field +from models_library.utils.json_serialization import json_dumps +from pydantic import BaseModel, Extra, Field, validator from .basic_types import IDStr +from .rest_base import RequestParameters +from .utils.common_validators import parse_json_pre_validator class OrderDirection(str, Enum): @@ -11,10 +15,100 @@ class OrderDirection(str, Enum): class OrderBy(BaseModel): - """inspired by Google AIP https://google.aip.dev/132#ordering""" + # Based on https://google.aip.dev/132#ordering + field: IDStr = Field(..., description="field name identifier") + direction: OrderDirection = Field( + default=OrderDirection.ASC, + description=( + f"As [A,B,C,...] if `{OrderDirection.ASC.value}`" + f" or [Z,Y,X, ...] if `{OrderDirection.DESC.value}`" + ), + ) - field: IDStr = Field() - direction: OrderDirection = Field(default=OrderDirection.ASC) - class Config: - extra = "forbid" +class _BaseOrderQueryParams(RequestParameters): + order_by: OrderBy | None = None + + +def create_ordering_query_model_classes( + *, + ordering_fields: set[str], + default: OrderBy, + ordering_fields_api_to_column_map: dict[str, str] | None = None, +) -> type[_BaseOrderQueryParams]: + """Factory to create an uniform model used as ordering parameters in a query + + Arguments: + ordering_fields -- A set of valid fields that can be used for ordering. + These should correspond to API field names. + default -- The default ordering configuration to be applied if no explicit + ordering is provided + + Keyword Arguments: + ordering_fields_api_to_column_map -- A mapping of API field names to + database column names. If provided, fields specified in the API + will be automatically translated to their corresponding database + column names for seamless integration with database queries. + """ + _ordering_fields_api_to_column_map = ordering_fields_api_to_column_map or {} + + assert set(_ordering_fields_api_to_column_map.keys()).issubset( # nosec + ordering_fields + ) + + assert default.field in ordering_fields # nosec + + msg_field_options = "|".join(sorted(ordering_fields)) + msg_direction_options = "|".join(sorted(OrderDirection)) + + class _OrderBy(OrderBy): + class Config: + schema_extra: ClassVar[dict[str, Any]] = { + "example": { + "field": next(iter(ordering_fields)), + "direction": OrderDirection.DESC.value, + } + } + extra = Extra.forbid + # Necessary to run _check_ordering_field_and_map in defaults and assignments + validate_all = True + validate_assignment = True + + @validator("field", allow_reuse=True, always=True) + @classmethod + def _check_ordering_field_and_map(cls, v): + if v not in ordering_fields: + msg = ( + f"We do not support ordering by provided field '{v}'. " + f"Fields supported are {msg_field_options}." + ) + raise ValueError(msg) + + # API field name -> DB column_name conversion + return _ordering_fields_api_to_column_map.get(v) or v + + order_by_example: dict[str, Any] = _OrderBy.Config.schema_extra["example"] + order_by_example_json = json_dumps(order_by_example) + assert _OrderBy.parse_obj(order_by_example), "Example is invalid" # nosec + + converted_default = _OrderBy.parse_obj( + # NOTE: enforces ordering_fields_api_to_column_map + default.dict() + ) + + class _OrderQueryParams(_BaseOrderQueryParams): + order_by: _OrderBy = Field( + default=converted_default, + description=( + f"Order by field (`{msg_field_options}`) and direction (`{msg_direction_options}`). " + f"The default sorting order is `{json_dumps(default)}`." + ), + example=order_by_example, + example_json=order_by_example_json, + ) + + _pre_parse_string = validator("order_by", allow_reuse=True, pre=True)( + parse_json_pre_validator + ) + + return _OrderQueryParams diff --git a/packages/models-library/src/models_library/rest_pagination.py b/packages/models-library/src/models_library/rest_pagination.py index 89c90cb1c2d..0213fb4f8a5 100644 --- a/packages/models-library/src/models_library/rest_pagination.py +++ b/packages/models-library/src/models_library/rest_pagination.py @@ -13,6 +13,7 @@ ) from pydantic.generics import GenericModel +from .rest_base import RequestParameters from .utils.common_validators import none_to_empty_list_pre_validator # Default limit values @@ -29,7 +30,7 @@ class PageLimitInt(ConstrainedInt): DEFAULT_NUMBER_OF_ITEMS_PER_PAGE: Final[PageLimitInt] = parse_obj_as(PageLimitInt, 20) -class PageQueryParameters(BaseModel): +class PageQueryParameters(RequestParameters): """Use as pagination options in query parameters""" limit: PageLimitInt = Field( diff --git a/packages/models-library/src/models_library/utils/common_validators.py b/packages/models-library/src/models_library/utils/common_validators.py index f1d754de5dc..0fcf1879951 100644 --- a/packages/models-library/src/models_library/utils/common_validators.py +++ b/packages/models-library/src/models_library/utils/common_validators.py @@ -20,6 +20,10 @@ class MyModel(BaseModel): import operator from typing import Any +from orjson import JSONDecodeError + +from .json_serialization import json_loads + def empty_str_to_none_pre_validator(value: Any): if isinstance(value, str) and value.strip() == "": @@ -39,6 +43,16 @@ def none_to_empty_list_pre_validator(value: Any): return value +def parse_json_pre_validator(value: Any): + if isinstance(value, str): + try: + return json_loads(value) + except JSONDecodeError as err: + msg = f"Invalid JSON {value=}: {err}" + raise TypeError(msg) from err + return value + + def create_enums_pre_validator(enum_cls: type[enum.Enum]): """Enables parsing enums from equivalent enums diff --git a/packages/models-library/tests/test_rest_ordering.py b/packages/models-library/tests/test_rest_ordering.py new file mode 100644 index 00000000000..fec004cd01e --- /dev/null +++ b/packages/models-library/tests/test_rest_ordering.py @@ -0,0 +1,139 @@ +import pytest +from models_library.basic_types import IDStr +from models_library.rest_ordering import ( + OrderBy, + OrderDirection, + create_ordering_query_model_classes, +) +from models_library.utils.json_serialization import json_dumps +from pydantic import BaseModel, Extra, Field, Json, ValidationError, validator + + +class ReferenceOrderQueryParamsClass(BaseModel): + # NOTE: this class is a copy of `FolderListSortParams` from + # services/web/server/src/simcore_service_webserver/folders/_models.py + # and used as a reference in these tests to ensure the same functionality + + # pylint: disable=unsubscriptable-object + order_by: Json[OrderBy] = Field( + default=OrderBy(field=IDStr("modified_at"), direction=OrderDirection.DESC), + description="Order by field (modified_at|name|description) and direction (asc|desc). The default sorting order is ascending.", + example='{"field": "name", "direction": "desc"}', + ) + + @validator("order_by", check_fields=False) + @classmethod + def _validate_order_by_field(cls, v): + if v.field not in { + "modified_at", + "name", + "description", + }: + msg = f"We do not support ordering by provided field {v.field}" + raise ValueError(msg) + if v.field == "modified_at": + v.field = "modified_column" + return v + + class Config: + extra = Extra.forbid + + +def test_ordering_query_model_class_factory(): + BaseOrderingQueryModel = create_ordering_query_model_classes( + ordering_fields={"modified_at", "name", "description"}, + default=OrderBy(field=IDStr("modified_at"), direction=OrderDirection.DESC), + ordering_fields_api_to_column_map={"modified_at": "modified_column"}, + ) + + # inherits to add extra post-validator + class OrderQueryParamsModel(BaseOrderingQueryModel): + ... + + # normal + data = {"order_by": {"field": "modified_at", "direction": "asc"}} + model = OrderQueryParamsModel.parse_obj(data) + + assert model.order_by + assert model.order_by.dict() == {"field": "modified_column", "direction": "asc"} + + # test against reference + expected = ReferenceOrderQueryParamsClass.parse_obj( + {"order_by": json_dumps({"field": "modified_at", "direction": "asc"})} + ) + assert expected.dict() == model.dict() + + +def test_ordering_query_model_class__fails_with_invalid_fields(): + + OrderQueryParamsModel = create_ordering_query_model_classes( + ordering_fields={"modified", "name", "description"}, + default=OrderBy(field=IDStr("modified"), direction=OrderDirection.DESC), + ) + + # fails with invalid field to sort + with pytest.raises(ValidationError) as err_info: + OrderQueryParamsModel.parse_obj({"order_by": {"field": "INVALID"}}) + + error = err_info.value.errors()[0] + + assert error["type"] == "value_error" + assert "INVALID" in error["msg"] + assert error["loc"] == ("order_by", "field") + + +def test_ordering_query_model_class__fails_with_invalid_direction(): + OrderQueryParamsModel = create_ordering_query_model_classes( + ordering_fields={"modified", "name", "description"}, + default=OrderBy(field=IDStr("modified"), direction=OrderDirection.DESC), + ) + + with pytest.raises(ValidationError) as err_info: + OrderQueryParamsModel.parse_obj( + {"order_by": {"field": "modified", "direction": "INVALID"}} + ) + + error = err_info.value.errors()[0] + + assert error["type"] == "type_error.enum" + assert error["loc"] == ("order_by", "direction") + + +def test_ordering_query_model_class__defaults(): + + OrderQueryParamsModel = create_ordering_query_model_classes( + ordering_fields={"modified", "name", "description"}, + default=OrderBy(field=IDStr("modified"), direction=OrderDirection.DESC), + ordering_fields_api_to_column_map={"modified": "modified_at"}, + ) + + # checks all defaults + model = OrderQueryParamsModel() + assert model.order_by + assert model.order_by.field == "modified_at" # NOTE that this was mapped! + assert model.order_by.direction == OrderDirection.DESC + + # partial defaults + model = OrderQueryParamsModel.parse_obj({"order_by": {"field": "name"}}) + assert model.order_by + assert model.order_by.field == "name" + assert model.order_by.direction == OrderBy.__fields__["direction"].default + + # direction alone is invalid + with pytest.raises(ValidationError) as err_info: + OrderQueryParamsModel.parse_obj({"order_by": {"direction": "asc"}}) + + error = err_info.value.errors()[0] + assert error["loc"] == ("order_by", "field") + assert error["type"] == "value_error.missing" + + +def test_ordering_query_model_with_map(): + OrderQueryParamsModel = create_ordering_query_model_classes( + ordering_fields={"modified", "name", "description"}, + default=OrderBy(field=IDStr("modified"), direction=OrderDirection.DESC), + ordering_fields_api_to_column_map={"modified": "some_db_column_name"}, + ) + + model = OrderQueryParamsModel.parse_obj({"order_by": {"field": "modified"}}) + assert model.order_by.field == "some_db_column_name" diff --git a/packages/pytest-simcore/src/pytest_simcore/helpers/assert_checks.py b/packages/pytest-simcore/src/pytest_simcore/helpers/assert_checks.py index 2f71de33e25..3ce30f4131a 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/assert_checks.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/assert_checks.py @@ -82,13 +82,13 @@ def _do_assert_error( assert is_error(expected_status_code) - assert len(error["errors"]) == 1 - - err = error["errors"][0] + assert len(error["errors"]) >= 1 if expected_msg: - assert expected_msg in err["message"] + messages = [detail["message"] for detail in error["errors"]] + assert expected_msg in messages if expected_error_code: - assert expected_error_code == err["code"] + codes = [detail["code"] for detail in error["errors"]] + assert expected_error_code in codes return data, error diff --git a/packages/service-library/src/servicelib/aiohttp/requests_validation.py b/packages/service-library/src/servicelib/aiohttp/requests_validation.py index 085243c5d26..e5cef8ecd96 100644 --- a/packages/service-library/src/servicelib/aiohttp/requests_validation.py +++ b/packages/service-library/src/servicelib/aiohttp/requests_validation.py @@ -14,7 +14,7 @@ from aiohttp import web from models_library.utils.json_serialization import json_dumps -from pydantic import BaseModel, Extra, ValidationError, parse_obj_as +from pydantic import BaseModel, ValidationError, parse_obj_as from ..mimetype_constants import MIMETYPE_APPLICATION_JSON from . import status @@ -24,17 +24,6 @@ UnionOfModelTypes: TypeAlias = Union[type[ModelClass], type[ModelClass]] # noqa: UP007 -class RequestParams(BaseModel): - ... - - -class StrictRequestParams(BaseModel): - """Use a base class for context, path and query parameters""" - - class Config: - extra = Extra.forbid # strict - - @contextmanager def handle_validation_as_http_error( *, error_msg_template: str, resource_name: str, use_error_v1: bool diff --git a/packages/service-library/src/servicelib/fastapi/openapi.py b/packages/service-library/src/servicelib/fastapi/openapi.py index 37e21c13278..dc01e2452b1 100644 --- a/packages/service-library/src/servicelib/fastapi/openapi.py +++ b/packages/service-library/src/servicelib/fastapi/openapi.py @@ -25,7 +25,7 @@ } -def get_common_oas_options(is_devel_mode: bool) -> dict[str, Any]: +def get_common_oas_options(*, is_devel_mode: bool) -> dict[str, Any]: """common OAS options for FastAPI constructor""" servers: list[dict[str, Any]] = [ _OAS_DEFAULT_SERVER, diff --git a/packages/service-library/tests/aiohttp/test_requests_validation.py b/packages/service-library/tests/aiohttp/test_requests_validation.py index 08e2f07bfbe..003f363f6e2 100644 --- a/packages/service-library/tests/aiohttp/test_requests_validation.py +++ b/packages/service-library/tests/aiohttp/test_requests_validation.py @@ -3,15 +3,21 @@ # pylint: disable=unused-variable import json -from typing import Callable +from collections.abc import Callable from uuid import UUID import pytest from aiohttp import web -from aiohttp.test_utils import TestClient +from aiohttp.test_utils import TestClient, make_mocked_request from faker import Faker +from models_library.rest_base import RequestParameters, StrictRequestParameters +from models_library.rest_ordering import ( + OrderBy, + OrderDirection, + create_ordering_query_model_classes, +) from models_library.utils.json_serialization import json_dumps -from pydantic import BaseModel, Extra, Field +from pydantic import BaseModel, Field from servicelib.aiohttp import status from servicelib.aiohttp.requests_validation import ( parse_request_body_as, @@ -19,6 +25,7 @@ parse_request_path_parameters_as, parse_request_query_parameters_as, ) +from yarl import URL RQT_USERID_KEY = f"{__name__}.user_id" APP_SECRET_KEY = f"{__name__}.secret" @@ -30,7 +37,7 @@ def jsonable_encoder(data): return json.loads(json_dumps(data)) -class MyRequestContext(BaseModel): +class MyRequestContext(RequestParameters): user_id: int = Field(alias=RQT_USERID_KEY) secret: str = Field(alias=APP_SECRET_KEY) @@ -39,31 +46,24 @@ def create_fake(cls, faker: Faker): return cls(user_id=faker.pyint(), secret=faker.password()) -class MyRequestPathParams(BaseModel): +class MyRequestPathParams(StrictRequestParameters): project_uuid: UUID - class Config: - extra = Extra.forbid - @classmethod def create_fake(cls, faker: Faker): return cls(project_uuid=faker.uuid4()) -class MyRequestQueryParams(BaseModel): +class MyRequestQueryParams(RequestParameters): is_ok: bool = True label: str - def as_params(self, **kwargs) -> dict[str, str]: - data = self.dict(**kwargs) - return {k: f"{v}" for k, v in data.items()} - @classmethod def create_fake(cls, faker: Faker): return cls(is_ok=faker.pybool(), label=faker.word()) -class MyRequestHeadersParams(BaseModel): +class MyRequestHeadersParams(RequestParameters): user_agent: str = Field(alias="X-Simcore-User-Agent") optional_header: str | None = Field(default=None, alias="X-Simcore-Optional-Header") @@ -359,3 +359,19 @@ async def test_parse_request_with_invalid_headers_params( ], } } + + +def test_parse_request_query_parameters_as_with_order_by_query_models(): + + OrderQueryModel = create_ordering_query_model_classes( + ordering_fields={"modified", "name"}, default=OrderBy(field="name") + ) + + expected = OrderBy(field="name", direction=OrderDirection.ASC) + + url = URL("/test").with_query(order_by=expected.json()) + + request = make_mocked_request("GET", path=f"{url}") + + query_params = parse_request_query_parameters_as(OrderQueryModel, request) + assert query_params.order_by == expected diff --git a/packages/service-library/tests/fastapi/test_request_decorators.py b/packages/service-library/tests/fastapi/test_request_decorators.py index 312684437e7..18f6267cf33 100644 --- a/packages/service-library/tests/fastapi/test_request_decorators.py +++ b/packages/service-library/tests/fastapi/test_request_decorators.py @@ -6,9 +6,10 @@ import subprocess import sys import time +from collections.abc import Callable, Iterator from contextlib import contextmanager from pathlib import Path -from typing import Callable, Iterator, NamedTuple +from typing import NamedTuple import pytest import requests diff --git a/services/agent/src/simcore_service_agent/core/application.py b/services/agent/src/simcore_service_agent/core/application.py index 84bc71e24c5..c11ec676a17 100644 --- a/services/agent/src/simcore_service_agent/core/application.py +++ b/services/agent/src/simcore_service_agent/core/application.py @@ -48,7 +48,7 @@ def create_app() -> FastAPI: description=SUMMARY, version=f"{VERSION}", openapi_url=f"/api/{API_VTAG}/openapi.json", - **get_common_oas_options(settings.SC_BOOT_MODE.is_devel_mode()), + **get_common_oas_options(is_devel_mode=settings.SC_BOOT_MODE.is_devel_mode()), ) override_fastapi_openapi_method(app) app.state.settings = settings diff --git a/services/director-v2/src/simcore_service_director_v2/core/application.py b/services/director-v2/src/simcore_service_director_v2/core/application.py index 6487d725143..621d9d93c42 100644 --- a/services/director-v2/src/simcore_service_director_v2/core/application.py +++ b/services/director-v2/src/simcore_service_director_v2/core/application.py @@ -132,7 +132,7 @@ def create_base_app(settings: AppSettings | None = None) -> FastAPI: description=SUMMARY, version=API_VERSION, openapi_url=f"/api/{API_VTAG}/openapi.json", - **get_common_oas_options(settings.SC_BOOT_MODE.is_devel_mode()), + **get_common_oas_options(is_devel_mode=settings.SC_BOOT_MODE.is_devel_mode()), ) override_fastapi_openapi_method(app) app.state.settings = settings diff --git a/services/dynamic-sidecar/src/simcore_service_dynamic_sidecar/core/application.py b/services/dynamic-sidecar/src/simcore_service_dynamic_sidecar/core/application.py index 59547f40119..7e89d37d801 100644 --- a/services/dynamic-sidecar/src/simcore_service_dynamic_sidecar/core/application.py +++ b/services/dynamic-sidecar/src/simcore_service_dynamic_sidecar/core/application.py @@ -142,7 +142,7 @@ def create_base_app() -> FastAPI: description=SUMMARY, version=API_VERSION, openapi_url=f"/api/{API_VTAG}/openapi.json", - **get_common_oas_options(settings.SC_BOOT_MODE.is_devel_mode()), + **get_common_oas_options(is_devel_mode=settings.SC_BOOT_MODE.is_devel_mode()), ) override_fastapi_openapi_method(app) app.state.settings = settings diff --git a/services/static-webserver/client/source/class/osparc/dashboard/ResourceBrowserBase.js b/services/static-webserver/client/source/class/osparc/dashboard/ResourceBrowserBase.js index 344507aad9a..ca565c756f3 100644 --- a/services/static-webserver/client/source/class/osparc/dashboard/ResourceBrowserBase.js +++ b/services/static-webserver/client/source/class/osparc/dashboard/ResourceBrowserBase.js @@ -195,7 +195,6 @@ qx.Class.define("osparc.dashboard.ResourceBrowserBase", { __centerLayout: null, _resourceType: null, _resourcesList: null, - _topBar: null, _toolbar: null, _searchBarFilter: null, __viewModeLayout: null, diff --git a/services/static-webserver/client/source/class/osparc/dashboard/StudyBrowser.js b/services/static-webserver/client/source/class/osparc/dashboard/StudyBrowser.js index a2de2032524..0c8e3701ec8 100644 --- a/services/static-webserver/client/source/class/osparc/dashboard/StudyBrowser.js +++ b/services/static-webserver/client/source/class/osparc/dashboard/StudyBrowser.js @@ -991,10 +991,12 @@ qx.Class.define("osparc.dashboard.StudyBrowser", { this.invalidateStudies(); this._resourcesContainer.setResourcesToList([]); + this._toolbar.show(); if (context === "search") { this.__reloadFolders(); this.__reloadStudies(); } else if (context === "workspaces") { + this._toolbar.hide(); this._searchBarFilter.resetFilters(); this.__reloadWorkspaces(); } else if (context === "studiesAndFolders") { diff --git a/services/static-webserver/client/source/class/osparc/desktop/WorkbenchView.js b/services/static-webserver/client/source/class/osparc/desktop/WorkbenchView.js index accb850ab5d..d6df7d06b28 100644 --- a/services/static-webserver/client/source/class/osparc/desktop/WorkbenchView.js +++ b/services/static-webserver/client/source/class/osparc/desktop/WorkbenchView.js @@ -752,13 +752,15 @@ qx.Class.define("osparc.desktop.WorkbenchView", { __iFrameChanged: function(node) { this.__iframePage.removeAll(); - const loadingPage = node.getLoadingPage(); - const iFrame = node.getIFrame(); - const src = iFrame.getSource(); - const iFrameView = (src === null || src === "about:blank") ? loadingPage : iFrame; - this.__iframePage.add(iFrameView, { - flex: 1 - }); + if (node) { + const loadingPage = node.getLoadingPage(); + const iFrame = node.getIFrame(); + const src = iFrame.getSource(); + const iFrameView = (src === null || src === "about:blank") ? loadingPage : iFrame; + this.__iframePage.add(iFrameView, { + flex: 1 + }); + } }, __populateSecondaryColumn: function(node) { diff --git a/services/static-webserver/client/source/class/osparc/node/slideshow/NodeView.js b/services/static-webserver/client/source/class/osparc/node/slideshow/NodeView.js index af05dbb0e90..ce84b75556d 100644 --- a/services/static-webserver/client/source/class/osparc/node/slideshow/NodeView.js +++ b/services/static-webserver/client/source/class/osparc/node/slideshow/NodeView.js @@ -143,14 +143,15 @@ qx.Class.define("osparc.node.slideshow.NodeView", { this._iFrameLayout.removeAll(); const node = this.getNode(); - - const loadingPage = node.getLoadingPage(); - const iFrame = node.getIFrame(); - const src = iFrame.getSource(); - const iFrameView = (src === null || src === "about:blank") ? loadingPage : iFrame; - this._iFrameLayout.add(iFrameView, { - flex: 1 - }); + if (node) { + const loadingPage = node.getLoadingPage(); + const iFrame = node.getIFrame(); + const src = iFrame.getSource(); + const iFrameView = (src === null || src === "about:blank") ? loadingPage : iFrame; + this._iFrameLayout.add(iFrameView, { + flex: 1 + }); + } } } }); diff --git a/services/static-webserver/client/source/class/osparc/viewer/NodeViewer.js b/services/static-webserver/client/source/class/osparc/viewer/NodeViewer.js index 96c96f57e6d..2596b4b1dbc 100644 --- a/services/static-webserver/client/source/class/osparc/viewer/NodeViewer.js +++ b/services/static-webserver/client/source/class/osparc/viewer/NodeViewer.js @@ -93,15 +93,16 @@ qx.Class.define("osparc.viewer.NodeViewer", { __iFrameChanged: function() { this._removeAll(); - const iframeHandler = this.getNode().getIframeHandler(); - - const loadingPage = iframeHandler.getLoadingPage(); - const iFrame = iframeHandler.getIFrame(); - const src = iFrame.getSource(); - const iFrameView = (src === null || src === "about:blank") ? loadingPage : iFrame; - this._add(iFrameView, { - flex: 1 - }); + if (this.getNode() && this.getNode().getIframeHandler()) { + const iframeHandler = this.getNode().getIframeHandler(); + const loadingPage = iframeHandler.getLoadingPage(); + const iFrame = iframeHandler.getIFrame(); + const src = iFrame.getSource(); + const iFrameView = (src === null || src === "about:blank") ? loadingPage : iFrame; + this._add(iFrameView, { + flex: 1 + }); + } }, __attachSocketEventHandlers: function() { 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 860d9869218..df35af2db92 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 @@ -2601,51 +2601,20 @@ paths: parameters: - required: false schema: - title: Folder Id - exclusiveMinimum: true - type: integer - minimum: 0 - name: folder_id + title: Filters + type: string + description: Custom filter query parameter encoded as JSON + name: filters in: query - required: false - schema: - title: Workspace Id - exclusiveMinimum: true - type: integer - minimum: 0 - name: workspace_id - in: query - - description: Order by field (modified_at|name|description) and direction (asc|desc). - The default sorting order is ascending. - required: false schema: title: Order By - description: Order by field (modified_at|name|description) and direction - (asc|desc). The default sorting order is ascending. - default: '{"field": "modified_at", "direction": "desc"}' - example: '{"field": "name", "direction": "desc"}' - name: order_by - in: query - - description: "{\n \"title\": \"FolderFilters\",\n \"description\": \"Encoded\ - \ as JSON. Each available filter can have its own logic (should be well\ - \ documented)\\nInspired by Docker API https://docs.docker.com/engine/api/v1.43/#tag/Container/operation/ContainerList.\"\ - ,\n \"type\": \"object\",\n \"properties\": {\n \"trashed\": {\n \"title\"\ - : \"Trashed\",\n \"description\": \"Set to true to list trashed, false\ - \ to list non-trashed (default), None to list all\",\n \"default\": false,\n\ - \ \"type\": \"boolean\"\n }\n }\n}" - required: false - schema: - title: Filters type: string - description: "{\n \"title\": \"FolderFilters\",\n \"description\": \"Encoded\ - \ as JSON. Each available filter can have its own logic (should be well\ - \ documented)\\nInspired by Docker API https://docs.docker.com/engine/api/v1.43/#tag/Container/operation/ContainerList.\"\ - ,\n \"type\": \"object\",\n \"properties\": {\n \"trashed\": {\n \"\ - title\": \"Trashed\",\n \"description\": \"Set to true to list trashed,\ - \ false to list non-trashed (default), None to list all\",\n \"default\"\ - : false,\n \"type\": \"boolean\"\n }\n }\n}" - format: json-string - name: filters + description: Order by field (`description|modified|name`) and direction + (`asc|desc`). The default sorting order is `{"field":"modified","direction":"desc"}`. + default: '{"field":"modified","direction":"desc"}' + example: '{"field":"some_field_name","direction":"desc"}' + name: order_by in: query - required: false schema: @@ -2665,6 +2634,22 @@ paths: default: 0 name: offset in: query + - required: false + schema: + title: Folder Id + exclusiveMinimum: true + type: integer + minimum: 0 + name: folder_id + in: query + - required: false + schema: + title: Workspace Id + exclusiveMinimum: true + type: integer + minimum: 0 + name: workspace_id + in: query responses: '200': description: Successful Response @@ -2699,41 +2684,20 @@ paths: parameters: - required: false schema: - title: Text + title: Filters type: string - name: text + description: Custom filter query parameter encoded as JSON + name: filters in: query - - description: Order by field (modified_at|name|description) and direction (asc|desc). - The default sorting order is ascending. - required: false + - required: false schema: title: Order By - description: Order by field (modified_at|name|description) and direction - (asc|desc). The default sorting order is ascending. - default: '{"field": "modified_at", "direction": "desc"}' - example: '{"field": "name", "direction": "desc"}' - name: order_by - in: query - - description: "{\n \"title\": \"FolderFilters\",\n \"description\": \"Encoded\ - \ as JSON. Each available filter can have its own logic (should be well\ - \ documented)\\nInspired by Docker API https://docs.docker.com/engine/api/v1.43/#tag/Container/operation/ContainerList.\"\ - ,\n \"type\": \"object\",\n \"properties\": {\n \"trashed\": {\n \"title\"\ - : \"Trashed\",\n \"description\": \"Set to true to list trashed, false\ - \ to list non-trashed (default), None to list all\",\n \"default\": false,\n\ - \ \"type\": \"boolean\"\n }\n }\n}" - required: false - schema: - title: Filters type: string - description: "{\n \"title\": \"FolderFilters\",\n \"description\": \"Encoded\ - \ as JSON. Each available filter can have its own logic (should be well\ - \ documented)\\nInspired by Docker API https://docs.docker.com/engine/api/v1.43/#tag/Container/operation/ContainerList.\"\ - ,\n \"type\": \"object\",\n \"properties\": {\n \"trashed\": {\n \"\ - title\": \"Trashed\",\n \"description\": \"Set to true to list trashed,\ - \ false to list non-trashed (default), None to list all\",\n \"default\"\ - : false,\n \"type\": \"boolean\"\n }\n }\n}" - format: json-string - name: filters + description: Order by field (`description|modified|name`) and direction + (`asc|desc`). The default sorting order is `{"field":"modified","direction":"desc"}`. + default: '{"field":"modified","direction":"desc"}' + example: '{"field":"some_field_name","direction":"desc"}' + name: order_by in: query - required: false schema: @@ -2753,6 +2717,13 @@ paths: default: 0 name: offset in: query + - required: false + schema: + title: Text + maxLength: 100 + type: string + name: text + in: query responses: '200': description: Successful Response @@ -3136,56 +3107,6 @@ paths: summary: List Projects operationId: list_projects parameters: - - description: Order by field (type|uuid|name|description|prj_owner|creation_date|last_change_date) - and direction (asc|desc). The default sorting order is ascending. - required: false - schema: - title: Order By - description: Order by field (type|uuid|name|description|prj_owner|creation_date|last_change_date) - and direction (asc|desc). The default sorting order is ascending. - default: '{"field": "last_change_date", "direction": "desc"}' - example: '{"field": "last_change_date", "direction": "desc"}' - name: order_by - in: query - - description: "{\n \"title\": \"ProjectFilters\",\n \"description\": \"Encoded\ - \ as JSON. Each available filter can have its own logic (should be well\ - \ documented)\\nInspired by Docker API https://docs.docker.com/engine/api/v1.43/#tag/Container/operation/ContainerList.\"\ - ,\n \"type\": \"object\",\n \"properties\": {\n \"trashed\": {\n \"title\"\ - : \"Trashed\",\n \"description\": \"Set to true to list trashed, false\ - \ to list non-trashed (default), None to list all\",\n \"default\": false,\n\ - \ \"type\": \"boolean\"\n }\n }\n}" - required: false - schema: - title: Filters - type: string - description: "{\n \"title\": \"ProjectFilters\",\n \"description\": \"Encoded\ - \ as JSON. Each available filter can have its own logic (should be well\ - \ documented)\\nInspired by Docker API https://docs.docker.com/engine/api/v1.43/#tag/Container/operation/ContainerList.\"\ - ,\n \"type\": \"object\",\n \"properties\": {\n \"trashed\": {\n \"\ - title\": \"Trashed\",\n \"description\": \"Set to true to list trashed,\ - \ false to list non-trashed (default), None to list all\",\n \"default\"\ - : false,\n \"type\": \"boolean\"\n }\n }\n}" - format: json-string - name: filters - in: query - - required: false - schema: - title: Limit - exclusiveMaximum: true - minimum: 1 - type: integer - default: 20 - maximum: 50 - name: limit - in: query - - required: false - schema: - title: Offset - minimum: 0 - type: integer - default: 0 - name: offset - in: query - required: false schema: allOf: @@ -3223,6 +3144,41 @@ paths: minimum: 0 name: workspace_id in: query + - required: false + schema: + title: Filters + type: string + description: Custom filter query parameter encoded as JSON + name: filters + in: query + - required: false + schema: + title: Order By + type: string + description: Order by field (`creation_date|description|last_change_date|name|prj_owner|type|uuid`) + and direction (`asc|desc`). The default sorting order is `{"field":"last_change_date","direction":"desc"}`. + default: '{"field":"last_change_date","direction":"desc"}' + example: '{"field":"some_field_name","direction":"desc"}' + name: order_by + in: query + - required: false + schema: + title: Limit + exclusiveMaximum: true + minimum: 1 + type: integer + default: 20 + maximum: 50 + name: limit + in: query + - required: false + schema: + title: Offset + minimum: 0 + type: integer + default: 0 + name: offset + in: query responses: '200': description: Successful Response @@ -3264,10 +3220,12 @@ paths: default: false name: hidden in: query - - required: false + - description: Optional simcore user agent + required: false schema: title: X-Simcore-User-Agent type: string + description: Optional simcore user agent default: undefined name: x-simcore-user-agent in: header @@ -3297,7 +3255,7 @@ paths: content: application/json: schema: - title: ' Create' + title: ' Body' anyOf: - $ref: '#/components/schemas/ProjectCreateNew' - $ref: '#/components/schemas/ProjectCopyOverride' @@ -3416,16 +3374,14 @@ paths: summary: List Projects Full Search operationId: list_projects_full_search parameters: - - description: Order by field (type|uuid|name|description|prj_owner|creation_date|last_change_date) - and direction (asc|desc). The default sorting order is ascending. - required: false + - required: false schema: title: Order By - description: Order by field (type|uuid|name|description|prj_owner|creation_date|last_change_date) - and direction (asc|desc). The default sorting order is ascending. - default: - - '{"field": "last_change_date", "direction": "desc"}' - example: '{"field": "last_change_date", "direction": "desc"}' + type: string + description: Order by field (`creation_date|description|last_change_date|name|prj_owner|type|uuid`) + and direction (`asc|desc`). The default sorting order is `{"field":"last_change_date","direction":"desc"}`. + default: '{"field":"last_change_date","direction":"desc"}' + example: '{"field":"some_field_name","direction":"desc"}' name: order_by in: query - required: false @@ -3465,7 +3421,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Page_ProjectListFullSearchParams_' + $ref: '#/components/schemas/Page_ProjectListItem_' /v0/projects/{project_id}/inactivity: get: tags: @@ -4649,22 +4605,25 @@ paths: are taken from context, optionally wallet_id parameter might be provided). operationId: list_resource_usage_services parameters: - - description: Order by field (wallet_id|wallet_name|user_id|project_id|project_name|node_id|node_name|service_key|service_version|service_type|started_at|stopped_at|service_run_status|credit_cost|transaction_status) - and direction (asc|desc). The default sorting order is ascending. - required: false + - required: false schema: title: Order By - description: Order by field (wallet_id|wallet_name|user_id|project_id|project_name|node_id|node_name|service_key|service_version|service_type|started_at|stopped_at|service_run_status|credit_cost|transaction_status) - and direction (asc|desc). The default sorting order is ascending. - default: '{"field": "started_at", "direction": "desc"}' - example: '{"field": "started_at", "direction": "desc"}' + type: string + description: Order by field (`credit_cost|node_id|node_name|project_id|project_name|root_parent_project_id|root_parent_project_name|service_key|service_run_status|service_type|service_version|started_at|stopped_at|transaction_status|user_email|user_id|wallet_id|wallet_name`) + and direction (`asc|desc`). The default sorting order is `{"field":"started_at","direction":"desc"}`. + default: '{"field":"started_at","direction":"desc"}' + example: '{"field":"some_field_name","direction":"desc"}' name: order_by in: query - - description: Filters to process on the resource usages list, encoded as JSON. - Currently supports the filtering of 'started_at' field with 'from' and 'until' - parameters in ISO 8601 format. The date range specified is - inclusive. - required: false + - required: false + schema: + title: Wallet Id + exclusiveMinimum: true + type: integer + minimum: 0 + name: wallet_id + in: query + - required: false schema: title: Filters type: string @@ -4672,23 +4631,16 @@ paths: JSON. Currently supports the filtering of 'started_at' field with 'from' and 'until' parameters in ISO 8601 format. The date range specified is inclusive. - format: json-string - example: '{"started_at": {"from": "yyyy-mm-dd", "until": "yyyy-mm-dd"}}' name: filters in: query - - required: false - schema: - title: Wallet Id - exclusiveMinimum: true - type: integer - minimum: 0 - name: wallet_id - in: query - required: false schema: title: Limit + exclusiveMaximum: true + minimum: 1 type: integer default: 20 + maximum: 50 name: limit in: query - required: false @@ -4715,29 +4667,14 @@ paths: be provided). operationId: list_osparc_credits_aggregated_usages parameters: - - required: true - schema: - $ref: '#/components/schemas/ServicesAggregatedUsagesType' - name: aggregated_by - in: query - - required: true - schema: - $ref: '#/components/schemas/ServicesAggregatedUsagesTimePeriod' - name: time_period - in: query - - required: true - schema: - title: Wallet Id - exclusiveMinimum: true - type: integer - minimum: 0 - name: wallet_id - in: query - required: false schema: title: Limit + exclusiveMaximum: true + minimum: 1 type: integer default: 20 + maximum: 50 name: limit in: query - required: false @@ -4748,6 +4685,24 @@ paths: default: 0 name: offset in: query + - required: false + schema: + $ref: '#/components/schemas/ServicesAggregatedUsagesType' + name: aggregated_by + in: query + - required: false + schema: + $ref: '#/components/schemas/ServicesAggregatedUsagesTimePeriod' + name: time_period + in: query + - required: false + schema: + title: Wallet Id + exclusiveMinimum: true + type: integer + minimum: 0 + name: wallet_id + in: query responses: '200': description: Successful Response @@ -4767,21 +4722,12 @@ paths: - required: false schema: title: Order By - default: '{"field": "started_at", "direction": "desc"}' - example: '{"field": "started_at", "direction": "desc"}' - name: order_by - in: query - - description: Order by field (wallet_id|wallet_name|user_id|project_id|project_name|node_id|node_name|service_key|service_version|service_type|started_at|stopped_at|service_run_status|credit_cost|transaction_status) - and direction (asc|desc). The default sorting order is ascending. - required: false - schema: - title: Filters type: string - description: Order by field (wallet_id|wallet_name|user_id|project_id|project_name|node_id|node_name|service_key|service_version|service_type|started_at|stopped_at|service_run_status|credit_cost|transaction_status) - and direction (asc|desc). The default sorting order is ascending. - format: json-string - example: '{"started_at": {"from": "yyyy-mm-dd", "until": "yyyy-mm-dd"}}' - name: filters + description: Order by field (`credit_cost|node_id|node_name|project_id|project_name|root_parent_project_id|root_parent_project_name|service_key|service_run_status|service_type|service_version|started_at|stopped_at|transaction_status|user_email|user_id|wallet_id|wallet_name`) + and direction (`asc|desc`). The default sorting order is `{"field":"started_at","direction":"desc"}`. + default: '{"field":"started_at","direction":"desc"}' + example: '{"field":"some_field_name","direction":"desc"}' + name: order_by in: query - required: false schema: @@ -4791,6 +4737,16 @@ paths: minimum: 0 name: wallet_id in: query + - required: false + schema: + title: Filters + type: string + description: Filters to process on the resource usages list, encoded as + JSON. Currently supports the filtering of 'started_at' field with 'from' + and 'until' parameters in ISO 8601 format. The date range + specified is inclusive. + name: filters + in: query responses: '302': description: redirection to download link @@ -9905,25 +9861,6 @@ components: $ref: '#/components/schemas/ProjectIterationResultItem' additionalProperties: false description: Paginated response model of ItemTs - Page_ProjectListFullSearchParams_: - title: Page[ProjectListFullSearchParams] - required: - - _meta - - _links - - data - type: object - properties: - _meta: - $ref: '#/components/schemas/PageMetaInfoLimitOffset' - _links: - $ref: '#/components/schemas/PageLinks' - data: - title: Data - type: array - items: - $ref: '#/components/schemas/ProjectListFullSearchParams' - additionalProperties: false - description: Paginated response model of ItemTs Page_ProjectListItem_: title: Page[ProjectListItem] required: @@ -10787,37 +10724,6 @@ components: format: uri results: $ref: '#/components/schemas/ExtractedResults' - ProjectListFullSearchParams: - title: ProjectListFullSearchParams - type: object - properties: - limit: - title: Limit - exclusiveMaximum: true - minimum: 1 - type: integer - description: maximum number of items to return (pagination) - default: 20 - maximum: 50 - offset: - title: Offset - minimum: 0 - type: integer - description: index to the first item to return (pagination) - default: 0 - text: - title: Text - maxLength: 100 - type: string - description: Multi column full text search, across all folders and workspaces - example: My Project - tag_ids: - title: Tag Ids - type: string - description: Search by tag ID (multiple tag IDs may be provided separated - by column) - example: 1,3 - description: Use as pagination options in query parameters ProjectListItem: title: ProjectListItem required: diff --git a/services/web/server/src/simcore_service_webserver/api_keys/_handlers.py b/services/web/server/src/simcore_service_webserver/api_keys/_handlers.py index 627d733d9c7..07be7223107 100644 --- a/services/web/server/src/simcore_service_webserver/api_keys/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/api_keys/_handlers.py @@ -3,17 +3,15 @@ from aiohttp import web from aiohttp.web import RouteTableDef from models_library.api_schemas_webserver.auth import ApiKeyCreate -from models_library.users import UserID -from pydantic import Field from servicelib.aiohttp import status -from servicelib.aiohttp.requests_validation import RequestParams, parse_request_body_as +from servicelib.aiohttp.requests_validation import parse_request_body_as from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON from simcore_postgres_database.errors import DatabaseError from simcore_service_webserver.security.decorators import permission_required -from .._constants import RQ_PRODUCT_KEY, RQT_USERID_KEY from .._meta import API_VTAG from ..login.decorators import login_required +from ..models import RequestContext from ..utils_aiohttp import envelope_json_response from . import _api @@ -23,16 +21,11 @@ routes = RouteTableDef() -class _RequestContext(RequestParams): - user_id: UserID = Field(..., alias=RQT_USERID_KEY) # type: ignore[literal-required] - product_name: str = Field(..., alias=RQ_PRODUCT_KEY) # type: ignore[literal-required] - - @routes.get(f"/{API_VTAG}/auth/api-keys", name="list_api_keys") @login_required @permission_required("user.apikey.*") async def list_api_keys(request: web.Request): - req_ctx = _RequestContext.parse_obj(request) + req_ctx = RequestContext.parse_obj(request) api_keys_names = await _api.list_api_keys( request.app, user_id=req_ctx.user_id, @@ -45,7 +38,7 @@ async def list_api_keys(request: web.Request): @login_required @permission_required("user.apikey.*") async def create_api_key(request: web.Request): - req_ctx = _RequestContext.parse_obj(request) + req_ctx = RequestContext.parse_obj(request) new = await parse_request_body_as(ApiKeyCreate, request) try: data = await _api.create_api_key( @@ -67,7 +60,7 @@ async def create_api_key(request: web.Request): @login_required @permission_required("user.apikey.*") async def delete_api_key(request: web.Request): - req_ctx = _RequestContext.parse_obj(request) + req_ctx = RequestContext.parse_obj(request) # NOTE: SEE https://github.com/ITISFoundation/osparc-simcore/issues/4920 body = await request.json() diff --git a/services/web/server/src/simcore_service_webserver/clusters/_handlers.py b/services/web/server/src/simcore_service_webserver/clusters/_handlers.py index 70752da883b..1fe3f4975a0 100644 --- a/services/web/server/src/simcore_service_webserver/clusters/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/clusters/_handlers.py @@ -10,15 +10,13 @@ ClusterPathParams, ClusterPing, ) -from models_library.users import UserID -from pydantic import BaseModel, Field, parse_obj_as +from pydantic import parse_obj_as from servicelib.aiohttp import status from servicelib.aiohttp.requests_validation import ( parse_request_body_as, parse_request_path_parameters_as, ) from servicelib.aiohttp.typing_extension import Handler -from servicelib.request_keys import RQT_USERID_KEY from .._meta import api_version_prefix from ..director_v2 import api as director_v2_api @@ -29,6 +27,7 @@ DirectorServiceError, ) from ..login.decorators import login_required +from ..models import RequestContext from ..security.decorators import permission_required from ..utils_aiohttp import envelope_json_response @@ -57,15 +56,6 @@ async def wrapper(request: web.Request) -> web.StreamResponse: return wrapper -# -# API components/schemas -# - - -class _RequestContext(BaseModel): - user_id: UserID = Field(..., alias=RQT_USERID_KEY) # type: ignore[literal-required] - - # # API handlers # @@ -78,7 +68,7 @@ class _RequestContext(BaseModel): @permission_required("clusters.create") @_handle_cluster_exceptions async def create_cluster(request: web.Request) -> web.Response: - req_ctx = _RequestContext.parse_obj(request) + req_ctx = RequestContext.parse_obj(request) new_cluster = await parse_request_body_as(ClusterCreate, request) created_cluster = await director_v2_api.create_cluster( @@ -94,7 +84,7 @@ async def create_cluster(request: web.Request) -> web.Response: @permission_required("clusters.read") @_handle_cluster_exceptions async def list_clusters(request: web.Request) -> web.Response: - req_ctx = _RequestContext.parse_obj(request) + req_ctx = RequestContext.parse_obj(request) clusters = await director_v2_api.list_clusters( app=request.app, @@ -109,7 +99,7 @@ async def list_clusters(request: web.Request) -> web.Response: @permission_required("clusters.read") @_handle_cluster_exceptions async def get_cluster(request: web.Request) -> web.Response: - req_ctx = _RequestContext.parse_obj(request) + req_ctx = RequestContext.parse_obj(request) path_params = parse_request_path_parameters_as(ClusterPathParams, request) cluster = await director_v2_api.get_cluster( @@ -126,7 +116,7 @@ async def get_cluster(request: web.Request) -> web.Response: @permission_required("clusters.write") @_handle_cluster_exceptions async def update_cluster(request: web.Request) -> web.Response: - req_ctx = _RequestContext.parse_obj(request) + req_ctx = RequestContext.parse_obj(request) path_params = parse_request_path_parameters_as(ClusterPathParams, request) cluster_patch = await parse_request_body_as(ClusterPatch, request) @@ -146,7 +136,7 @@ async def update_cluster(request: web.Request) -> web.Response: @permission_required("clusters.delete") @_handle_cluster_exceptions async def delete_cluster(request: web.Request) -> web.Response: - req_ctx = _RequestContext.parse_obj(request) + req_ctx = RequestContext.parse_obj(request) path_params = parse_request_path_parameters_as(ClusterPathParams, request) await director_v2_api.delete_cluster( @@ -165,7 +155,7 @@ async def delete_cluster(request: web.Request) -> web.Response: @permission_required("clusters.read") @_handle_cluster_exceptions async def get_cluster_details(request: web.Request) -> web.Response: - req_ctx = _RequestContext.parse_obj(request) + req_ctx = RequestContext.parse_obj(request) path_params = parse_request_path_parameters_as(ClusterPathParams, request) cluster_details = await director_v2_api.get_cluster_details( @@ -199,7 +189,7 @@ async def ping_cluster(request: web.Request) -> web.Response: @permission_required("clusters.read") @_handle_cluster_exceptions async def ping_cluster_cluster_id(request: web.Request) -> web.Response: - req_ctx = _RequestContext.parse_obj(request) + req_ctx = RequestContext.parse_obj(request) path_params = parse_request_path_parameters_as(ClusterPathParams, request) await director_v2_api.ping_specific_cluster( diff --git a/services/web/server/src/simcore_service_webserver/director_v2/_handlers.py b/services/web/server/src/simcore_service_webserver/director_v2/_handlers.py index f794fa6f148..111ca1f6298 100644 --- a/services/web/server/src/simcore_service_webserver/director_v2/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/director_v2/_handlers.py @@ -22,10 +22,10 @@ GroupExtraPropertiesRepo, ) -from .._constants import RQ_PRODUCT_KEY from .._meta import API_VTAG as VTAG from ..db.plugin import get_database_engine from ..login.decorators import login_required +from ..models import RequestContext from ..products import api as products_api from ..security.decorators import permission_required from ..users.exceptions import UserDefaultWalletNotFoundError @@ -43,11 +43,6 @@ routes = web.RouteTableDef() -class RequestContext(BaseModel): - user_id: UserID = Field(..., alias=RQT_USERID_KEY) # type: ignore[literal-required] - product_name: str = Field(..., alias=RQ_PRODUCT_KEY) # type: ignore[literal-required] - - class _ComputationStarted(BaseModel): pipeline_id: ProjectID = Field( ..., description="ID for created pipeline (=project identifier)" diff --git a/services/web/server/src/simcore_service_webserver/folders/_folders_handlers.py b/services/web/server/src/simcore_service_webserver/folders/_folders_handlers.py index b1a01ef61aa..e8a888cf541 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_folders_handlers.py +++ b/services/web/server/src/simcore_service_webserver/folders/_folders_handlers.py @@ -28,8 +28,8 @@ from ._exceptions_handlers import handle_plugin_requests_exceptions from ._models import ( FolderFilters, - FolderListFullSearchWithJsonStrQueryParams, - FolderListWithJsonStrQueryParams, + FolderSearchQueryParams, + FoldersListQueryParams, FoldersPathParams, FoldersRequestContext, ) @@ -66,8 +66,8 @@ async def create_folder(request: web.Request): @handle_plugin_requests_exceptions async def list_folders(request: web.Request): req_ctx = FoldersRequestContext.parse_obj(request) - query_params: FolderListWithJsonStrQueryParams = parse_request_query_parameters_as( - FolderListWithJsonStrQueryParams, request + query_params: FoldersListQueryParams = parse_request_query_parameters_as( + FoldersListQueryParams, request ) if not query_params.filters: @@ -106,10 +106,8 @@ async def list_folders(request: web.Request): @handle_plugin_requests_exceptions async def list_folders_full_search(request: web.Request): req_ctx = FoldersRequestContext.parse_obj(request) - query_params: FolderListFullSearchWithJsonStrQueryParams = ( - parse_request_query_parameters_as( - FolderListFullSearchWithJsonStrQueryParams, request - ) + query_params: FolderSearchQueryParams = parse_request_query_parameters_as( + FolderSearchQueryParams, request ) if not query_params.filters: diff --git a/services/web/server/src/simcore_service_webserver/folders/_models.py b/services/web/server/src/simcore_service_webserver/folders/_models.py index 899514a271b..766b34bf995 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_models.py +++ b/services/web/server/src/simcore_service_webserver/folders/_models.py @@ -2,8 +2,13 @@ from models_library.basic_types import IDStr from models_library.folders import FolderID +from models_library.rest_base import RequestParameters, StrictRequestParameters from models_library.rest_filters import Filters, FiltersQueryParameters -from models_library.rest_ordering import OrderBy, OrderDirection +from models_library.rest_ordering import ( + OrderBy, + OrderDirection, + create_ordering_query_model_classes, +) from models_library.rest_pagination import PageQueryParameters from models_library.users import UserID from models_library.utils.common_validators import ( @@ -11,8 +16,7 @@ null_or_none_str_to_none_validator, ) from models_library.workspaces import WorkspaceID -from pydantic import BaseModel, Extra, Field, Json, validator -from servicelib.aiohttp.requests_validation import RequestParams, StrictRequestParams +from pydantic import BaseModel, Extra, Field, validator from servicelib.request_keys import RQT_USERID_KEY from .._constants import RQ_PRODUCT_KEY @@ -20,12 +24,12 @@ _logger = logging.getLogger(__name__) -class FoldersRequestContext(RequestParams): +class FoldersRequestContext(RequestParameters): user_id: UserID = Field(..., alias=RQT_USERID_KEY) # type: ignore[literal-required] product_name: str = Field(..., alias=RQ_PRODUCT_KEY) # type: ignore[literal-required] -class FoldersPathParams(StrictRequestParams): +class FoldersPathParams(StrictRequestParameters): folder_id: FolderID @@ -36,35 +40,18 @@ class FolderFilters(Filters): ) -class FolderListSortParams(BaseModel): - # pylint: disable=unsubscriptable-object - order_by: Json[OrderBy] = Field( - default=OrderBy(field=IDStr("modified"), direction=OrderDirection.DESC), - description="Order by field (modified_at|name|description) and direction (asc|desc). The default sorting order is ascending.", - example='{"field": "name", "direction": "desc"}', - alias="order_by", - ) - - @validator("order_by", check_fields=False) - @classmethod - def _validate_order_by_field(cls, v): - if v.field not in { - "modified_at", - "name", - "description", - }: - msg = f"We do not support ordering by provided field {v.field}" - raise ValueError(msg) - if v.field == "modified_at": - v.field = "modified" - return v - - class Config: - extra = Extra.forbid +_FolderOrderQueryParams: type[RequestParameters] = create_ordering_query_model_classes( + ordering_fields={ + "modified_at", + "name", + }, + default=OrderBy(field=IDStr("modified_at"), direction=OrderDirection.DESC), + ordering_fields_api_to_column_map={"modified_at": "modified"}, +) -class FolderListWithJsonStrQueryParams( - PageQueryParameters, FolderListSortParams, FiltersQueryParameters[FolderFilters] +class FoldersListQueryParams( + PageQueryParameters, _FolderOrderQueryParams, FiltersQueryParameters[FolderFilters] # type: ignore[misc, valid-type] ): folder_id: FolderID | None = Field( default=None, @@ -88,8 +75,8 @@ class Config: )(null_or_none_str_to_none_validator) -class FolderListFullSearchWithJsonStrQueryParams( - PageQueryParameters, FolderListSortParams, FiltersQueryParameters[FolderFilters] +class FolderSearchQueryParams( + PageQueryParameters, _FolderOrderQueryParams, FiltersQueryParameters[FolderFilters] # type: ignore[misc, valid-type] ): text: str | None = Field( default=None, diff --git a/services/web/server/src/simcore_service_webserver/long_running_tasks.py b/services/web/server/src/simcore_service_webserver/long_running_tasks.py index a7e4e8c725b..cd9fa77e07e 100644 --- a/services/web/server/src/simcore_service_webserver/long_running_tasks.py +++ b/services/web/server/src/simcore_service_webserver/long_running_tasks.py @@ -1,25 +1,16 @@ from functools import wraps from aiohttp import web -from models_library.users import UserID from models_library.utils.fastapi_encoders import jsonable_encoder -from pydantic import Field from servicelib.aiohttp.long_running_tasks._constants import ( RQT_LONG_RUNNING_TASKS_CONTEXT_KEY, ) from servicelib.aiohttp.long_running_tasks.server import setup -from servicelib.aiohttp.requests_validation import RequestParams from servicelib.aiohttp.typing_extension import Handler -from servicelib.request_keys import RQT_USERID_KEY -from ._constants import RQ_PRODUCT_KEY from ._meta import API_VTAG from .login.decorators import login_required - - -class _RequestContext(RequestParams): - user_id: UserID = Field(..., alias=RQT_USERID_KEY) # type: ignore[literal-required] - product_name: str = Field(..., alias=RQ_PRODUCT_KEY) # type: ignore[literal-required] +from .models import RequestContext def _webserver_request_context_decorator(handler: Handler): @@ -28,7 +19,7 @@ async def _test_task_context_decorator( request: web.Request, ) -> web.StreamResponse: """this task context callback tries to get the user_id from the query if available""" - req_ctx = _RequestContext.parse_obj(request) + req_ctx = RequestContext.parse_obj(request) request[RQT_LONG_RUNNING_TASKS_CONTEXT_KEY] = jsonable_encoder(req_ctx) return await handler(request) diff --git a/services/web/server/src/simcore_service_webserver/models.py b/services/web/server/src/simcore_service_webserver/models.py new file mode 100644 index 00000000000..48ffd369586 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/models.py @@ -0,0 +1,11 @@ +from models_library.rest_base import RequestParameters +from models_library.users import UserID +from pydantic import Field +from servicelib.request_keys import RQT_USERID_KEY + +from ._constants import RQ_PRODUCT_KEY + + +class RequestContext(RequestParameters): + user_id: UserID = Field(..., alias=RQT_USERID_KEY) # type: ignore[literal-required] + product_name: str = Field(..., alias=RQ_PRODUCT_KEY) # type: ignore[literal-required] diff --git a/services/web/server/src/simcore_service_webserver/products/_handlers.py b/services/web/server/src/simcore_service_webserver/products/_handlers.py index bfdabef6d6f..1d7e4e4bc57 100644 --- a/services/web/server/src/simcore_service_webserver/products/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/products/_handlers.py @@ -4,13 +4,10 @@ from aiohttp import web from models_library.api_schemas_webserver.product import GetCreditPrice, GetProduct from models_library.basic_types import IDStr +from models_library.rest_base import RequestParameters, StrictRequestParameters from models_library.users import UserID from pydantic import Extra, Field -from servicelib.aiohttp.requests_validation import ( - RequestParams, - StrictRequestParams, - parse_request_path_parameters_as, -) +from servicelib.aiohttp.requests_validation import parse_request_path_parameters_as from servicelib.request_keys import RQT_USERID_KEY from simcore_service_webserver.utils_aiohttp import envelope_json_response @@ -27,7 +24,7 @@ _logger = logging.getLogger(__name__) -class _ProductsRequestContext(RequestParams): +class _ProductsRequestContext(RequestParameters): user_id: UserID = Field(..., alias=RQT_USERID_KEY) # type: ignore[literal-required] product_name: str = Field(..., alias=RQ_PRODUCT_KEY) # type: ignore[literal-required] @@ -49,7 +46,7 @@ async def _get_current_product_price(request: web.Request): return envelope_json_response(credit_price) -class _ProductsRequestParams(StrictRequestParams): +class _ProductsRequestParams(StrictRequestParameters): product_name: IDStr | Literal["current"] diff --git a/services/web/server/src/simcore_service_webserver/products/_invitations_handlers.py b/services/web/server/src/simcore_service_webserver/products/_invitations_handlers.py index a300a4c43e9..905be090f47 100644 --- a/services/web/server/src/simcore_service_webserver/products/_invitations_handlers.py +++ b/services/web/server/src/simcore_service_webserver/products/_invitations_handlers.py @@ -6,9 +6,10 @@ GenerateInvitation, InvitationGenerated, ) +from models_library.rest_base import RequestParameters from models_library.users import UserID from pydantic import Field -from servicelib.aiohttp.requests_validation import RequestParams, parse_request_body_as +from servicelib.aiohttp.requests_validation import parse_request_body_as from servicelib.request_keys import RQT_USERID_KEY from simcore_service_webserver.utils_aiohttp import envelope_json_response from yarl import URL @@ -26,7 +27,7 @@ _logger = logging.getLogger(__name__) -class _ProductsRequestContext(RequestParams): +class _ProductsRequestContext(RequestParameters): user_id: UserID = Field(..., alias=RQT_USERID_KEY) # type: ignore[literal-required] product_name: str = Field(..., alias=RQ_PRODUCT_KEY) # type: ignore[literal-required] 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 index 073c012a8ac..a39aaef626f 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_common_models.py +++ b/services/web/server/src/simcore_service_webserver/projects/_common_models.py @@ -5,16 +5,11 @@ """ 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 +from ..models import RequestContext - -class RequestContext(BaseModel): - user_id: UserID = Field(..., alias=RQT_USERID_KEY) # type: ignore[literal-required] - product_name: str = Field(..., alias=RQ_PRODUCT_KEY) # type: ignore[literal-required] +assert RequestContext.__name__ # nosec class ProjectPathParams(BaseModel): @@ -29,3 +24,6 @@ class RemoveQueryParams(BaseModel): force: bool = Field( default=False, description="Force removal (even if resource is active)" ) + + +__all__: tuple[str, ...] = ("RequestContext",) diff --git a/services/web/server/src/simcore_service_webserver/projects/_crud_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_crud_handlers.py index 7500a6a4d26..cdbbe479182 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_crud_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_crud_handlers.py @@ -51,12 +51,12 @@ from . import _crud_api_create, _crud_api_read, projects_api from ._common_models import ProjectPathParams, RequestContext from ._crud_handlers_models import ( - ProjectActiveParams, + ProjectActiveQueryParams, ProjectCreateHeaders, ProjectCreateParams, ProjectFilters, - ProjectListFullSearchWithJsonStrParams, - ProjectListWithJsonStrParams, + ProjectsListQueryParams, + ProjectsSearchQueryParams, ) from ._permalink_api import update_or_pop_permalink_in_project from .exceptions import ( @@ -188,8 +188,8 @@ async def list_projects(request: web.Request): """ req_ctx = RequestContext.parse_obj(request) - query_params: ProjectListWithJsonStrParams = parse_request_query_parameters_as( - ProjectListWithJsonStrParams, request + query_params: ProjectsListQueryParams = parse_request_query_parameters_as( + ProjectsListQueryParams, request ) if not query_params.filters: @@ -233,10 +233,8 @@ async def list_projects(request: web.Request): @_handle_projects_exceptions async def list_projects_full_search(request: web.Request): req_ctx = RequestContext.parse_obj(request) - query_params: ProjectListFullSearchWithJsonStrParams = ( - parse_request_query_parameters_as( - ProjectListFullSearchWithJsonStrParams, request - ) + query_params: ProjectsSearchQueryParams = parse_request_query_parameters_as( + ProjectsSearchQueryParams, request ) tag_ids_list = query_params.tag_ids_list() @@ -283,8 +281,8 @@ async def get_active_project(request: web.Request) -> web.Response: web.HTTPNotFound: If active project is not found """ req_ctx = RequestContext.parse_obj(request) - query_params: ProjectActiveParams = parse_request_query_parameters_as( - ProjectActiveParams, request + query_params: ProjectActiveQueryParams = parse_request_query_parameters_as( + ProjectActiveQueryParams, request ) try: diff --git a/services/web/server/src/simcore_service_webserver/projects/_crud_handlers_models.py b/services/web/server/src/simcore_service_webserver/projects/_crud_handlers_models.py index b1c499fd3a9..43800a164e3 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_crud_handlers_models.py +++ b/services/web/server/src/simcore_service_webserver/projects/_crud_handlers_models.py @@ -10,23 +10,20 @@ from models_library.folders import FolderID from models_library.projects import ProjectID from models_library.projects_nodes_io import NodeID +from models_library.rest_base import RequestParameters from models_library.rest_filters import Filters, FiltersQueryParameters -from models_library.rest_ordering import OrderBy, OrderDirection +from models_library.rest_ordering import ( + OrderBy, + OrderDirection, + create_ordering_query_model_classes, +) from models_library.rest_pagination import PageQueryParameters from models_library.utils.common_validators import ( empty_str_to_none_pre_validator, null_or_none_str_to_none_validator, ) from models_library.workspaces import WorkspaceID -from pydantic import ( - BaseModel, - Extra, - Field, - Json, - parse_obj_as, - root_validator, - validator, -) +from pydantic import BaseModel, Extra, Field, parse_obj_as, root_validator, validator from servicelib.common_headers import ( UNDEFINED_DEFAULT_SIMCORE_USER_AGENT_VALUE, X_SIMCORE_PARENT_NODE_ID, @@ -104,7 +101,21 @@ class ProjectFilters(Filters): ) -class ProjectListParams(PageQueryParameters): +ProjectsListOrderParams = create_ordering_query_model_classes( + ordering_fields={ + "type", + "uuid", + "name", + "description", + "prj_owner", + "creation_date", + "last_change_date", + }, + default=OrderBy(field=IDStr("last_change_date"), direction=OrderDirection.DESC), +) + + +class ProjectsListExtraQueryParams(RequestParameters): project_type: ProjectTypeAPI = Field(default=ProjectTypeAPI.all, alias="type") show_hidden: bool = Field( default=False, description="includes projects marked as hidden in the listing" @@ -140,45 +151,20 @@ def search_check_empty_string(cls, v): )(null_or_none_str_to_none_validator) -class ProjectListSortParams(BaseModel): - order_by: Json[OrderBy] = Field( # pylint: disable=unsubscriptable-object - default=OrderBy(field=IDStr("last_change_date"), direction=OrderDirection.DESC), - description="Order by field (type|uuid|name|description|prj_owner|creation_date|last_change_date) and direction (asc|desc). The default sorting order is ascending.", - example='{"field": "prj_owner", "direction": "desc"}', - alias="order_by", - ) - - @validator("order_by", check_fields=False) - @classmethod - def validate_order_by_field(cls, v): - if v.field not in { - "type", - "uuid", - "name", - "description", - "prj_owner", - "creation_date", - "last_change_date", - }: - msg = f"We do not support ordering by provided field {v.field}" - raise ValueError(msg) - return v - - class Config: - extra = Extra.forbid - - -class ProjectListWithJsonStrParams( - ProjectListParams, ProjectListSortParams, FiltersQueryParameters[ProjectFilters] +class ProjectsListQueryParams( + PageQueryParameters, + ProjectsListOrderParams, # type: ignore[misc, valid-type] + FiltersQueryParameters[ProjectFilters], + ProjectsListExtraQueryParams, ): ... -class ProjectActiveParams(BaseModel): +class ProjectActiveQueryParams(BaseModel): client_session_id: str -class ProjectListFullSearchParams(PageQueryParameters): +class ProjectSearchExtraQueryParams(PageQueryParameters): text: str | None = Field( default=None, description="Multi column full text search, across all folders and workspaces", @@ -196,8 +182,8 @@ class ProjectListFullSearchParams(PageQueryParameters): ) -class ProjectListFullSearchWithJsonStrParams( - ProjectListFullSearchParams, ProjectListSortParams +class ProjectsSearchQueryParams( + ProjectSearchExtraQueryParams, ProjectsListOrderParams # type: ignore[misc, valid-type] ): def tag_ids_list(self) -> list[int]: try: diff --git a/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_admin_handlers.py b/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_admin_handlers.py index d8b4749a37a..a0f7f60f0e8 100644 --- a/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_admin_handlers.py +++ b/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_admin_handlers.py @@ -19,19 +19,18 @@ PricingUnitWithCostCreate, PricingUnitWithCostUpdate, ) -from models_library.users import UserID -from pydantic import BaseModel, Extra, Field +from models_library.rest_base import StrictRequestParameters +from pydantic import BaseModel, Extra from servicelib.aiohttp.requests_validation import ( parse_request_body_as, parse_request_path_parameters_as, ) from servicelib.aiohttp.typing_extension import Handler from servicelib.rabbitmq._errors import RPCServerError -from servicelib.request_keys import RQT_USERID_KEY -from .._constants import RQ_PRODUCT_KEY from .._meta import API_VTAG as VTAG from ..login.decorators import login_required +from ..models import RequestContext from ..security.decorators import permission_required from ..utils_aiohttp import envelope_json_response from . import _pricing_plans_admin_api as admin_api @@ -55,11 +54,6 @@ async def wrapper(request: web.Request) -> web.StreamResponse: return wrapper -class _RequestContext(BaseModel): - user_id: UserID = Field(..., alias=RQT_USERID_KEY) # type: ignore[literal-required] - product_name: str = Field(..., alias=RQ_PRODUCT_KEY) # type: ignore[literal-required] - - # # API handlers # @@ -70,7 +64,7 @@ class _RequestContext(BaseModel): ## Admin Pricing Plan endpoints -class _GetPricingPlanPathParams(BaseModel): +class PricingPlanGetPathParams(StrictRequestParameters): pricing_plan_id: PricingPlanId class Config: @@ -85,7 +79,7 @@ class Config: @permission_required("resource-usage.write") @_handle_pricing_plan_admin_exceptions async def list_pricing_plans(request: web.Request): - req_ctx = _RequestContext.parse_obj(request) + req_ctx = RequestContext.parse_obj(request) pricing_plans_list = await admin_api.list_pricing_plans( app=request.app, @@ -116,8 +110,8 @@ async def list_pricing_plans(request: web.Request): @permission_required("resource-usage.write") @_handle_pricing_plan_admin_exceptions async def get_pricing_plan(request: web.Request): - req_ctx = _RequestContext.parse_obj(request) - path_params = parse_request_path_parameters_as(_GetPricingPlanPathParams, request) + req_ctx = RequestContext.parse_obj(request) + path_params = parse_request_path_parameters_as(PricingPlanGetPathParams, request) pricing_plan_get = await admin_api.get_pricing_plan( app=request.app, @@ -125,7 +119,8 @@ async def get_pricing_plan(request: web.Request): pricing_plan_id=path_params.pricing_plan_id, ) if pricing_plan_get.pricing_units is None: - raise ValueError("Pricing plan units should not be None") + msg = "Pricing plan units should not be None" + raise ValueError(msg) webserver_admin_pricing_plan_get = PricingPlanAdminGet( pricing_plan_id=pricing_plan_get.pricing_plan_id, @@ -159,7 +154,7 @@ async def get_pricing_plan(request: web.Request): @permission_required("resource-usage.write") @_handle_pricing_plan_admin_exceptions async def create_pricing_plan(request: web.Request): - req_ctx = _RequestContext.parse_obj(request) + req_ctx = RequestContext.parse_obj(request) body_params = await parse_request_body_as(CreatePricingPlanBodyParams, request) _data = PricingPlanCreate( @@ -208,8 +203,8 @@ async def create_pricing_plan(request: web.Request): @permission_required("resource-usage.write") @_handle_pricing_plan_admin_exceptions async def update_pricing_plan(request: web.Request): - req_ctx = _RequestContext.parse_obj(request) - path_params = parse_request_path_parameters_as(_GetPricingPlanPathParams, request) + req_ctx = RequestContext.parse_obj(request) + path_params = parse_request_path_parameters_as(PricingPlanGetPathParams, request) body_params = await parse_request_body_as(UpdatePricingPlanBodyParams, request) _data = PricingPlanUpdate( @@ -253,7 +248,7 @@ async def update_pricing_plan(request: web.Request): ## Admin Pricing Unit endpoints -class _GetPricingUnitPathParams(BaseModel): +class PricingUnitGetPathParams(BaseModel): pricing_plan_id: PricingPlanId pricing_unit_id: PricingUnitId @@ -269,8 +264,8 @@ class Config: @permission_required("resource-usage.write") @_handle_pricing_plan_admin_exceptions async def get_pricing_unit(request: web.Request): - req_ctx = _RequestContext.parse_obj(request) - path_params = parse_request_path_parameters_as(_GetPricingUnitPathParams, request) + req_ctx = RequestContext.parse_obj(request) + path_params = parse_request_path_parameters_as(PricingUnitGetPathParams, request) pricing_unit_get = await admin_api.get_pricing_unit( app=request.app, @@ -299,8 +294,8 @@ async def get_pricing_unit(request: web.Request): @permission_required("resource-usage.write") @_handle_pricing_plan_admin_exceptions async def create_pricing_unit(request: web.Request): - req_ctx = _RequestContext.parse_obj(request) - path_params = parse_request_path_parameters_as(_GetPricingPlanPathParams, request) + req_ctx = RequestContext.parse_obj(request) + path_params = parse_request_path_parameters_as(PricingPlanGetPathParams, request) body_params = await parse_request_body_as(CreatePricingUnitBodyParams, request) _data = PricingUnitWithCostCreate( @@ -338,8 +333,8 @@ async def create_pricing_unit(request: web.Request): @permission_required("resource-usage.write") @_handle_pricing_plan_admin_exceptions async def update_pricing_unit(request: web.Request): - req_ctx = _RequestContext.parse_obj(request) - path_params = parse_request_path_parameters_as(_GetPricingUnitPathParams, request) + req_ctx = RequestContext.parse_obj(request) + path_params = parse_request_path_parameters_as(PricingUnitGetPathParams, request) body_params = await parse_request_body_as(UpdatePricingUnitBodyParams, request) _data = PricingUnitWithCostUpdate( @@ -380,8 +375,8 @@ async def update_pricing_unit(request: web.Request): @permission_required("resource-usage.write") @_handle_pricing_plan_admin_exceptions async def list_connected_services_to_pricing_plan(request: web.Request): - req_ctx = _RequestContext.parse_obj(request) - path_params = parse_request_path_parameters_as(_GetPricingPlanPathParams, request) + req_ctx = RequestContext.parse_obj(request) + path_params = parse_request_path_parameters_as(PricingPlanGetPathParams, request) connected_services_list = await admin_api.list_connected_services_to_pricing_plan( app=request.app, @@ -409,8 +404,8 @@ async def list_connected_services_to_pricing_plan(request: web.Request): @permission_required("resource-usage.write") @_handle_pricing_plan_admin_exceptions async def connect_service_to_pricing_plan(request: web.Request): - req_ctx = _RequestContext.parse_obj(request) - path_params = parse_request_path_parameters_as(_GetPricingPlanPathParams, request) + req_ctx = RequestContext.parse_obj(request) + path_params = parse_request_path_parameters_as(PricingPlanGetPathParams, request) body_params = await parse_request_body_as( ConnectServiceToPricingPlanBodyParams, request ) diff --git a/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_handlers.py b/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_handlers.py index 86072f00e5e..dc2949113a6 100644 --- a/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_handlers.py +++ b/services/web/server/src/simcore_service_webserver/resource_usage/_pricing_plans_handlers.py @@ -3,15 +3,13 @@ from aiohttp import web from models_library.api_schemas_webserver.resource_usage import PricingUnitGet from models_library.resource_tracker import PricingPlanId, PricingUnitId -from models_library.users import UserID -from pydantic import BaseModel, Extra, Field +from models_library.rest_base import StrictRequestParameters from servicelib.aiohttp.requests_validation import parse_request_path_parameters_as from servicelib.aiohttp.typing_extension import Handler -from servicelib.request_keys import RQT_USERID_KEY -from .._constants import RQ_PRODUCT_KEY from .._meta import API_VTAG as VTAG from ..login.decorators import login_required +from ..models import RequestContext from ..security.decorators import permission_required from ..utils_aiohttp import envelope_json_response from ..wallets.errors import WalletAccessForbiddenError @@ -34,25 +32,13 @@ async def wrapper(request: web.Request) -> web.StreamResponse: return wrapper -class _RequestContext(BaseModel): - user_id: UserID = Field(..., alias=RQT_USERID_KEY) # type: ignore[literal-required] - product_name: str = Field(..., alias=RQ_PRODUCT_KEY) # type: ignore[literal-required] - - -# -# API handlers -# - routes = web.RouteTableDef() -class _GetPricingPlanUnitPathParams(BaseModel): +class PricingPlanUnitGetPathParams(StrictRequestParameters): pricing_plan_id: PricingPlanId pricing_unit_id: PricingUnitId - class Config: - extra = Extra.forbid - @routes.get( f"/{VTAG}/pricing-plans/{{pricing_plan_id}}/pricing-units/{{pricing_unit_id}}", @@ -62,9 +48,9 @@ class Config: @permission_required("resource-usage.read") @_handle_resource_usage_exceptions async def get_pricing_plan_unit(request: web.Request): - req_ctx = _RequestContext.parse_obj(request) + req_ctx = RequestContext.parse_obj(request) path_params = parse_request_path_parameters_as( - _GetPricingPlanUnitPathParams, request + PricingPlanUnitGetPathParams, request ) pricing_unit_get = await api.get_pricing_plan_unit( diff --git a/services/web/server/src/simcore_service_webserver/resource_usage/_service_runs_handlers.py b/services/web/server/src/simcore_service_webserver/resource_usage/_service_runs_handlers.py index f265e45faf1..cf98bff12a7 100644 --- a/services/web/server/src/simcore_service_webserver/resource_usage/_service_runs_handlers.py +++ b/services/web/server/src/simcore_service_webserver/resource_usage/_service_runs_handlers.py @@ -12,34 +12,24 @@ ServicesAggregatedUsagesTimePeriod, ServicesAggregatedUsagesType, ) -from models_library.rest_ordering import OrderBy, OrderDirection -from models_library.rest_pagination import ( - DEFAULT_NUMBER_OF_ITEMS_PER_PAGE, - MAXIMUM_NUMBER_OF_ITEMS_PER_PAGE, - Page, - PageQueryParameters, +from models_library.rest_base import RequestParameters +from models_library.rest_ordering import ( + OrderBy, + OrderDirection, + create_ordering_query_model_classes, ) +from models_library.rest_pagination import Page, PageQueryParameters from models_library.rest_pagination_utils import paginate_data -from models_library.users import UserID from models_library.wallets import WalletID -from pydantic import ( - BaseModel, - Extra, - Field, - Json, - NonNegativeInt, - parse_obj_as, - validator, -) +from pydantic import Extra, Field, Json, parse_obj_as from servicelib.aiohttp.requests_validation import parse_request_query_parameters_as from servicelib.aiohttp.typing_extension import Handler 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_VTAG as VTAG from ..login.decorators import login_required +from ..models import RequestContext from ..security.decorators import permission_required from ..wallets.errors import WalletAccessForbiddenError from . import _service_runs_api as api @@ -61,21 +51,40 @@ async def wrapper(request: web.Request) -> web.StreamResponse: return wrapper -class _RequestContext(BaseModel): - user_id: UserID = Field(..., alias=RQT_USERID_KEY) # type: ignore[literal-required] - product_name: str = Field(..., alias=RQ_PRODUCT_KEY) # type: ignore[literal-required] - - -ORDER_BY_DESCRIPTION = "Order by field (wallet_id|wallet_name|user_id|project_id|project_name|node_id|node_name|service_key|service_version|service_type|started_at|stopped_at|service_run_status|credit_cost|transaction_status) and direction (asc|desc). The default sorting order is ascending." +_ResorceUsagesListOrderQueryParams: type[ + RequestParameters +] = create_ordering_query_model_classes( + ordering_fields={ + "wallet_id", + "wallet_name", + "user_id", + "user_email", + "project_id", + "project_name", + "node_id", + "node_name", + "root_parent_project_id", + "root_parent_project_name", + "service_key", + "service_version", + "service_type", + "started_at", + "stopped_at", + "service_run_status", + "credit_cost", + "transaction_status", + }, + default=OrderBy(field=IDStr("started_at"), direction=OrderDirection.DESC), + ordering_fields_api_to_column_map={ + "credit_cost": "osparc_credits", + }, +) -class _ListServicesResourceUsagesQueryParams(BaseModel): +class ServicesResourceUsagesReportQueryParams( + _ResorceUsagesListOrderQueryParams # type: ignore[misc, valid-type] +): wallet_id: WalletID | None = Field(default=None) - order_by: Json[OrderBy] = Field( # pylint: disable=unsubscriptable-object - default=OrderBy(field=IDStr("started_at"), direction=OrderDirection.DESC), - description=ORDER_BY_DESCRIPTION, - example='{"field": "started_at", "direction": "desc"}', - ) filters: ( Json[ServiceResourceUsagesFilters] # pylint: disable=unsubscriptable-object | None @@ -85,56 +94,18 @@ class _ListServicesResourceUsagesQueryParams(BaseModel): example='{"started_at": {"from": "yyyy-mm-dd", "until": "yyyy-mm-dd"}}', ) - @validator("order_by", allow_reuse=True) - @classmethod - def validate_order_by_field(cls, v): - if v.field not in { - "wallet_id", - "wallet_name", - "user_id", - "user_email", - "project_id", - "project_name", - "node_id", - "node_name", - "root_parent_project_id", - "root_parent_project_name", - "service_key", - "service_version", - "service_type", - "started_at", - "stopped_at", - "service_run_status", - "credit_cost", - "transaction_status", - }: - raise ValueError(f"We do not support ordering by provided field {v.field}") - if v.field == "credit_cost": - v.field = "osparc_credits" - return v - class Config: extra = Extra.forbid -class _ListServicesResourceUsagesQueryParamsWithPagination( - _ListServicesResourceUsagesQueryParams +class ServicesResourceUsagesListQueryParams( + PageQueryParameters, ServicesResourceUsagesReportQueryParams ): - limit: int = Field( - default=DEFAULT_NUMBER_OF_ITEMS_PER_PAGE, - description="maximum number of items to return (pagination)", - ge=1, - lt=MAXIMUM_NUMBER_OF_ITEMS_PER_PAGE, - ) - offset: NonNegativeInt = Field( - default=0, description="index to the first item to return (pagination)" - ) - class Config: extra = Extra.forbid -class _ListServicesAggregatedUsagesQueryParams(PageQueryParameters): +class ServicesAggregatedUsagesListQueryParams(PageQueryParameters): aggregated_by: ServicesAggregatedUsagesType time_period: ServicesAggregatedUsagesTimePeriod wallet_id: WalletID @@ -155,10 +126,10 @@ class Config: @permission_required("resource-usage.read") @_handle_resource_usage_exceptions async def list_resource_usage_services(request: web.Request): - req_ctx = _RequestContext.parse_obj(request) - query_params: _ListServicesResourceUsagesQueryParamsWithPagination = ( + req_ctx = RequestContext.parse_obj(request) + query_params: ServicesResourceUsagesListQueryParams = ( parse_request_query_parameters_as( - _ListServicesResourceUsagesQueryParamsWithPagination, request + ServicesResourceUsagesListQueryParams, request ) ) @@ -196,10 +167,10 @@ async def list_resource_usage_services(request: web.Request): @permission_required("resource-usage.read") @_handle_resource_usage_exceptions async def list_osparc_credits_aggregated_usages(request: web.Request): - req_ctx = _RequestContext.parse_obj(request) - query_params: _ListServicesAggregatedUsagesQueryParams = ( + req_ctx = RequestContext.parse_obj(request) + query_params: ServicesAggregatedUsagesListQueryParams = ( parse_request_query_parameters_as( - _ListServicesAggregatedUsagesQueryParams, request + ServicesAggregatedUsagesListQueryParams, request ) ) @@ -236,10 +207,10 @@ async def list_osparc_credits_aggregated_usages(request: web.Request): @permission_required("resource-usage.read") @_handle_resource_usage_exceptions async def export_resource_usage_services(request: web.Request): - req_ctx = _RequestContext.parse_obj(request) - query_params: _ListServicesResourceUsagesQueryParams = ( + req_ctx = RequestContext.parse_obj(request) + query_params: ServicesResourceUsagesReportQueryParams = ( parse_request_query_parameters_as( - _ListServicesResourceUsagesQueryParams, request + ServicesResourceUsagesReportQueryParams, request ) ) download_url = await api.export_usage_services( diff --git a/services/web/server/src/simcore_service_webserver/tags/schemas.py b/services/web/server/src/simcore_service_webserver/tags/schemas.py index 01663e0d337..c9d4a9d90a1 100644 --- a/services/web/server/src/simcore_service_webserver/tags/schemas.py +++ b/services/web/server/src/simcore_service_webserver/tags/schemas.py @@ -2,18 +2,18 @@ from datetime import datetime from models_library.api_schemas_webserver._base import InputSchema, OutputSchema +from models_library.rest_base import RequestParameters, StrictRequestParameters from models_library.users import GroupID, UserID from pydantic import ConstrainedStr, Field, PositiveInt -from servicelib.aiohttp.requests_validation import RequestParams, StrictRequestParams from servicelib.request_keys import RQT_USERID_KEY from simcore_postgres_database.utils_tags import TagDict -class TagRequestContext(RequestParams): +class TagRequestContext(RequestParameters): user_id: UserID = Field(..., alias=RQT_USERID_KEY) # type: ignore[literal-required] -class TagPathParams(StrictRequestParams): +class TagPathParams(StrictRequestParameters): tag_id: PositiveInt diff --git a/services/web/server/src/simcore_service_webserver/users/_preferences_handlers.py b/services/web/server/src/simcore_service_webserver/users/_preferences_handlers.py index 0c537278f9c..3717fd0dd83 100644 --- a/services/web/server/src/simcore_service_webserver/users/_preferences_handlers.py +++ b/services/web/server/src/simcore_service_webserver/users/_preferences_handlers.py @@ -5,34 +5,25 @@ PatchPathParams, PatchRequestBody, ) -from models_library.products import ProductName -from models_library.users import UserID -from pydantic import BaseModel, Field from servicelib.aiohttp import status from servicelib.aiohttp.requests_validation import ( parse_request_body_as, parse_request_path_parameters_as, ) from servicelib.aiohttp.typing_extension import Handler -from servicelib.request_keys import RQT_USERID_KEY from simcore_postgres_database.utils_user_preferences import ( CouldNotCreateOrUpdateUserPreferenceError, ) -from .._constants import RQ_PRODUCT_KEY from .._meta import API_VTAG from ..login.decorators import login_required +from ..models import RequestContext from . import _preferences_api from .exceptions import FrontendUserPreferenceIsNotDefinedError routes = web.RouteTableDef() -class _RequestContext(BaseModel): - user_id: UserID = Field(..., alias=RQT_USERID_KEY) # type: ignore[literal-required] - product_name: ProductName = Field(..., alias=RQ_PRODUCT_KEY) # type: ignore[literal-required] - - def _handle_users_exceptions(handler: Handler): @functools.wraps(handler) async def wrapper(request: web.Request) -> web.StreamResponse: @@ -55,7 +46,7 @@ async def wrapper(request: web.Request) -> web.StreamResponse: @login_required @_handle_users_exceptions async def set_frontend_preference(request: web.Request) -> web.Response: - req_ctx = _RequestContext.parse_obj(request) + req_ctx = RequestContext.parse_obj(request) req_body = await parse_request_body_as(PatchRequestBody, request) req_path_params = parse_request_path_parameters_as(PatchPathParams, request) diff --git a/services/web/server/src/simcore_service_webserver/wallets/_groups_handlers.py b/services/web/server/src/simcore_service_webserver/wallets/_groups_handlers.py index 1115a239d62..0f0e2552986 100644 --- a/services/web/server/src/simcore_service_webserver/wallets/_groups_handlers.py +++ b/services/web/server/src/simcore_service_webserver/wallets/_groups_handlers.py @@ -6,20 +6,19 @@ import logging from aiohttp import web -from models_library.users import GroupID, UserID +from models_library.users import GroupID from models_library.wallets import WalletID -from pydantic import BaseModel, Extra, Field +from pydantic import BaseModel, Extra from servicelib.aiohttp import status from servicelib.aiohttp.requests_validation import ( parse_request_body_as, parse_request_path_parameters_as, ) from servicelib.aiohttp.typing_extension import Handler -from servicelib.request_keys import RQT_USERID_KEY -from .._constants import RQ_PRODUCT_KEY from .._meta import api_version_prefix as VTAG from ..login.decorators import login_required +from ..models import RequestContext from ..security.decorators import permission_required from ..utils_aiohttp import envelope_json_response from . import _groups_api @@ -30,11 +29,6 @@ _logger = logging.getLogger(__name__) -class _RequestContext(BaseModel): - user_id: UserID = Field(..., alias=RQT_USERID_KEY) # type: ignore[literal-required] - product_name: str = Field(..., alias=RQ_PRODUCT_KEY) # type: ignore[literal-required] - - def _handle_wallets_groups_exceptions(handler: Handler): @functools.wraps(handler) async def wrapper(request: web.Request) -> web.StreamResponse: @@ -81,7 +75,7 @@ class Config: @permission_required("wallets.*") @_handle_wallets_groups_exceptions async def create_wallet_group(request: web.Request): - req_ctx = _RequestContext.parse_obj(request) + req_ctx = RequestContext.parse_obj(request) path_params = parse_request_path_parameters_as(_WalletsGroupsPathParams, request) body_params = await parse_request_body_as(_WalletsGroupsBodyParams, request) @@ -104,7 +98,7 @@ async def create_wallet_group(request: web.Request): @permission_required("wallets.*") @_handle_wallets_groups_exceptions async def list_wallet_groups(request: web.Request): - req_ctx = _RequestContext.parse_obj(request) + req_ctx = RequestContext.parse_obj(request) path_params = parse_request_path_parameters_as(WalletsPathParams, request) wallets: list[ @@ -127,7 +121,7 @@ async def list_wallet_groups(request: web.Request): @permission_required("wallets.*") @_handle_wallets_groups_exceptions async def update_wallet_group(request: web.Request): - req_ctx = _RequestContext.parse_obj(request) + req_ctx = RequestContext.parse_obj(request) path_params = parse_request_path_parameters_as(_WalletsGroupsPathParams, request) body_params = await parse_request_body_as(_WalletsGroupsBodyParams, request) @@ -151,7 +145,7 @@ async def update_wallet_group(request: web.Request): @permission_required("wallets.*") @_handle_wallets_groups_exceptions async def delete_wallet_group(request: web.Request): - req_ctx = _RequestContext.parse_obj(request) + req_ctx = RequestContext.parse_obj(request) path_params = parse_request_path_parameters_as(_WalletsGroupsPathParams, request) await _groups_api.delete_wallet_group( diff --git a/services/web/server/src/simcore_service_webserver/wallets/_handlers.py b/services/web/server/src/simcore_service_webserver/wallets/_handlers.py index dc6855f2c01..954ed6b263b 100644 --- a/services/web/server/src/simcore_service_webserver/wallets/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/wallets/_handlers.py @@ -9,12 +9,11 @@ WalletGetWithAvailableCredits, ) from models_library.error_codes import create_error_code +from models_library.rest_base import RequestParameters, StrictRequestParameters from models_library.users import UserID from models_library.wallets import WalletID from pydantic import Field from servicelib.aiohttp.requests_validation import ( - RequestParams, - StrictRequestParams, parse_request_body_as, parse_request_path_parameters_as, ) @@ -106,19 +105,18 @@ async def wrapper(request: web.Request) -> web.StreamResponse: return wrapper -# # wallets COLLECTION ------------------------- # routes = web.RouteTableDef() -class WalletsRequestContext(RequestParams): +class WalletsRequestContext(RequestParameters): user_id: UserID = Field(..., alias=RQT_USERID_KEY) # type: ignore[literal-required] product_name: str = Field(..., alias=RQ_PRODUCT_KEY) # type: ignore[literal-required] -class WalletsPathParams(StrictRequestParams): +class WalletsPathParams(StrictRequestParameters): wallet_id: WalletID diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_groups_handlers.py b/services/web/server/src/simcore_service_webserver/workspaces/_groups_handlers.py index d4ae7c4b74f..c75ab891ef6 100644 --- a/services/web/server/src/simcore_service_webserver/workspaces/_groups_handlers.py +++ b/services/web/server/src/simcore_service_webserver/workspaces/_groups_handlers.py @@ -6,20 +6,19 @@ import logging from aiohttp import web -from models_library.users import GroupID, UserID +from models_library.users import GroupID from models_library.workspaces import WorkspaceID -from pydantic import BaseModel, Extra, Field +from pydantic import BaseModel, Extra from servicelib.aiohttp import status from servicelib.aiohttp.requests_validation import ( parse_request_body_as, parse_request_path_parameters_as, ) from servicelib.aiohttp.typing_extension import Handler -from servicelib.request_keys import RQT_USERID_KEY -from .._constants import RQ_PRODUCT_KEY from .._meta import api_version_prefix as VTAG from ..login.decorators import login_required +from ..models import RequestContext from ..security.decorators import permission_required from ..utils_aiohttp import envelope_json_response from . import _groups_api @@ -30,11 +29,6 @@ _logger = logging.getLogger(__name__) -class _RequestContext(BaseModel): - user_id: UserID = Field(..., alias=RQT_USERID_KEY) # type: ignore[literal-required] - product_name: str = Field(..., alias=RQ_PRODUCT_KEY) # type: ignore[literal-required] - - def _handle_workspaces_groups_exceptions(handler: Handler): @functools.wraps(handler) async def wrapper(request: web.Request) -> web.StreamResponse: @@ -82,7 +76,7 @@ class Config: @permission_required("workspaces.*") @_handle_workspaces_groups_exceptions async def create_workspace_group(request: web.Request): - req_ctx = _RequestContext.parse_obj(request) + req_ctx = RequestContext.parse_obj(request) path_params = parse_request_path_parameters_as(_WorkspacesGroupsPathParams, request) body_params = await parse_request_body_as(_WorkspacesGroupsBodyParams, request) @@ -105,7 +99,7 @@ async def create_workspace_group(request: web.Request): @permission_required("workspaces.*") @_handle_workspaces_groups_exceptions async def list_workspace_groups(request: web.Request): - req_ctx = _RequestContext.parse_obj(request) + req_ctx = RequestContext.parse_obj(request) path_params = parse_request_path_parameters_as(WorkspacesPathParams, request) workspaces: list[ @@ -128,7 +122,7 @@ async def list_workspace_groups(request: web.Request): @permission_required("workspaces.*") @_handle_workspaces_groups_exceptions async def replace_workspace_group(request: web.Request): - req_ctx = _RequestContext.parse_obj(request) + req_ctx = RequestContext.parse_obj(request) path_params = parse_request_path_parameters_as(_WorkspacesGroupsPathParams, request) body_params = await parse_request_body_as(_WorkspacesGroupsBodyParams, request) @@ -152,7 +146,7 @@ async def replace_workspace_group(request: web.Request): @permission_required("workspaces.*") @_handle_workspaces_groups_exceptions async def delete_workspace_group(request: web.Request): - req_ctx = _RequestContext.parse_obj(request) + req_ctx = RequestContext.parse_obj(request) path_params = parse_request_path_parameters_as(_WorkspacesGroupsPathParams, request) await _groups_api.delete_workspace_group( diff --git a/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_handlers.py b/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_handlers.py index fa9a2e4aa67..f4e4b6b8088 100644 --- a/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_handlers.py +++ b/services/web/server/src/simcore_service_webserver/workspaces/_workspaces_handlers.py @@ -9,16 +9,19 @@ WorkspaceGetPage, ) from models_library.basic_types import IDStr -from models_library.rest_ordering import OrderBy, OrderDirection +from models_library.rest_base import RequestParameters, StrictRequestParameters +from models_library.rest_ordering import ( + OrderBy, + OrderDirection, + create_ordering_query_model_classes, +) from models_library.rest_pagination import Page, PageQueryParameters from models_library.rest_pagination_utils import paginate_data from models_library.users import UserID from models_library.workspaces import WorkspaceID -from pydantic import Extra, Field, Json, parse_obj_as, validator +from pydantic import Field, parse_obj_as from servicelib.aiohttp import status from servicelib.aiohttp.requests_validation import ( - RequestParams, - StrictRequestParams, parse_request_body_as, parse_request_path_parameters_as, parse_request_query_parameters_as, @@ -61,40 +64,32 @@ async def wrapper(request: web.Request) -> web.StreamResponse: routes = web.RouteTableDef() -class WorkspacesRequestContext(RequestParams): +class WorkspacesRequestContext(RequestParameters): user_id: UserID = Field(..., alias=RQT_USERID_KEY) # type: ignore[literal-required] product_name: str = Field(..., alias=RQ_PRODUCT_KEY) # type: ignore[literal-required] -class WorkspacesPathParams(StrictRequestParams): +class WorkspacesPathParams(StrictRequestParameters): workspace_id: WorkspaceID -class WorkspacesListWithJsonStrQueryParams(PageQueryParameters): - # pylint: disable=unsubscriptable-object - order_by: Json[OrderBy] = Field( - default=OrderBy(field=IDStr("modified"), direction=OrderDirection.DESC), - description="Order by field (modified_at|name|description) and direction (asc|desc). The default sorting order is ascending.", - example='{"field": "name", "direction": "desc"}', - alias="order_by", - ) +WorkspacesListOrderQueryParams: type[ + RequestParameters +] = create_ordering_query_model_classes( + ordering_fields={ + "modified_at", + "name", + }, + default=OrderBy(field=IDStr("modified_at"), direction=OrderDirection.DESC), + ordering_fields_api_to_column_map={"modified_at": "modified"}, +) - @validator("order_by", check_fields=False) - @classmethod - def validate_order_by_field(cls, v): - if v.field not in { - "modified_at", - "name", - "description", - }: - msg = f"We do not support ordering by provided field {v.field}" - raise ValueError(msg) - if v.field == "modified_at": - v.field = "modified" - return v - class Config: - extra = Extra.forbid +class WorkspacesListQueryParams( + PageQueryParameters, + WorkspacesListOrderQueryParams, # type: ignore[misc, valid-type] +): + ... @routes.post(f"/{VTAG}/workspaces", name="create_workspace") @@ -123,8 +118,8 @@ async def create_workspace(request: web.Request): @handle_workspaces_exceptions async def list_workspaces(request: web.Request): req_ctx = WorkspacesRequestContext.parse_obj(request) - query_params: WorkspacesListWithJsonStrQueryParams = ( - parse_request_query_parameters_as(WorkspacesListWithJsonStrQueryParams, request) + query_params: WorkspacesListQueryParams = parse_request_query_parameters_as( + WorkspacesListQueryParams, request ) workspaces: WorkspaceGetPage = await _workspaces_api.list_workspaces( diff --git a/services/web/server/tests/unit/with_dbs/03/resource_usage/test_usage_services__list.py b/services/web/server/tests/unit/with_dbs/03/resource_usage/test_usage_services__list.py index 9c8a29f2b6c..3af86589cfe 100644 --- a/services/web/server/tests/unit/with_dbs/03/resource_usage/test_usage_services__list.py +++ b/services/web/server/tests/unit/with_dbs/03/resource_usage/test_usage_services__list.py @@ -26,8 +26,8 @@ _SERVICE_RUN_GET = ServiceRunPage( items=[ - ServiceRunGet( - **{ + ServiceRunGet.parse_obj( + { "service_run_id": "comp_1_5c2110be-441b-11ee-a0e8-02420a000040_1", "wallet_id": 1, "wallet_name": "the super wallet!", @@ -55,12 +55,11 @@ @pytest.fixture def mock_list_usage_services(mocker: MockerFixture) -> tuple: - mock_list_usage = mocker.patch( + return mocker.patch( "simcore_service_webserver.resource_usage._service_runs_api.service_runs.get_service_run_page", spec=True, return_value=_SERVICE_RUN_GET, ) - return mock_list_usage @pytest.fixture() @@ -79,7 +78,10 @@ def setup_wallets_db( .returning(sa.literal_column("*")) ) row = result.fetchone() + assert row + yield cast(int, row[0]) + con.execute(wallets.delete()) @@ -160,6 +162,8 @@ async def test_list_service_usage_with_order_by_query_param( setup_wallets_db, mock_list_usage_services, ): + assert client.app + # without any additional query parameter url = client.app.router["list_resource_usage_services"].url_for() resp = await client.get(f"{url}") @@ -237,9 +241,13 @@ async def test_list_service_usage_with_order_by_query_param( _, error = await assert_status(resp, status.HTTP_422_UNPROCESSABLE_ENTITY) assert mock_list_usage_services.called assert error["status"] == status.HTTP_422_UNPROCESSABLE_ENTITY - assert error["errors"][0]["message"].startswith( - "value is not a valid enumeration member" - ) + + errors = {(e["code"], e["field"]) for e in error["errors"]} + assert { + ("value_error", "order_by.field"), + ("type_error.enum", "order_by.direction"), + } == errors + assert len(errors) == 2 # without field _filter = {"direction": "asc"} @@ -253,6 +261,8 @@ async def test_list_service_usage_with_order_by_query_param( assert mock_list_usage_services.called assert error["status"] == status.HTTP_422_UNPROCESSABLE_ENTITY assert error["errors"][0]["message"].startswith("field required") + assert error["errors"][0]["code"] == "value_error.missing" + assert error["errors"][0]["field"] == "order_by.field" @pytest.mark.parametrize("user_role", [(UserRole.USER)]) @@ -262,6 +272,8 @@ async def test_list_service_usage_with_filters_query_param( setup_wallets_db, mock_list_usage_services, ): + assert client.app + # with unable to decode filter query parameter url = ( client.app.router["list_resource_usage_services"] diff --git a/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces.py b/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces.py index e2ace9daa6a..c45d2b43783 100644 --- a/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces.py +++ b/services/web/server/tests/unit/with_dbs/04/workspaces/test_workspaces.py @@ -8,6 +8,7 @@ import pytest from aiohttp.test_utils import TestClient from models_library.api_schemas_webserver.workspaces import WorkspaceGet +from models_library.rest_ordering import OrderDirection from pytest_simcore.helpers.assert_checks import assert_status from pytest_simcore.helpers.webserver_login import UserInfoDict from pytest_simcore.helpers.webserver_parametrizations import ( @@ -17,6 +18,26 @@ from servicelib.aiohttp import status from simcore_service_webserver.db.models import UserRole from simcore_service_webserver.projects.models import ProjectDict +from simcore_service_webserver.workspaces._workspaces_handlers import ( + WorkspacesListQueryParams, +) + + +def test_workspaces_order_query_model_post_validator(): + + # on default + query_params = WorkspacesListQueryParams.parse_obj({}) + assert query_params.order_by + assert query_params.order_by.field == "modified" + assert query_params.order_by.direction == OrderDirection.DESC + + # on partial default + query_params = WorkspacesListQueryParams.parse_obj( + {"order_by": {"field": "modified_at"}} + ) + assert query_params.order_by + assert query_params.order_by.field == "modified" + assert query_params.order_by.direction == OrderDirection.ASC @pytest.mark.parametrize(*standard_role_response(), ids=str) diff --git a/tests/e2e-playwright/tests/conftest.py b/tests/e2e-playwright/tests/conftest.py index a8a1f8d979d..6fd15e8218c 100644 --- a/tests/e2e-playwright/tests/conftest.py +++ b/tests/e2e-playwright/tests/conftest.py @@ -11,6 +11,7 @@ import os import random import re +import time import urllib.parse from collections.abc import Callable, Iterator from contextlib import ExitStack @@ -443,6 +444,7 @@ def _( re.compile(r"/projects/[^:]+:open"), timeout=timeout + 5 * SECOND ) as response_info, ): + open_with_resources_clicked = False # Project detail view pop-ups shows if press_open: open_button = page.get_by_test_id("openResource") @@ -451,11 +453,13 @@ def _( open_button.click() # Open project with default resources open_button = page.get_by_test_id("openWithResources") + time.sleep(2) # wait until the study options are filled up # it returns a Long Running Task with page.expect_response( re.compile(rf"/projects\?from_study\={template_id}") ) as lrt: open_button.click() + open_with_resources_clicked = True lrt_data = lrt.value.json() lrt_data = lrt_data["data"] with log_context( @@ -495,7 +499,15 @@ def wait_for_done(response): open_button.click() if is_product_billable: # Open project with default resources - page.get_by_test_id("openWithResources").click() + open_button = page.get_by_test_id("openWithResources") + time.sleep(2) # wait until the study options are filled up + open_button.click() + open_with_resources_clicked = True + if is_product_billable and not open_with_resources_clicked: + # Open project with default resources + open_button = page.get_by_test_id("openWithResources") + time.sleep(2) # wait until the study options are filled up + open_button.click() project_data = response_info.value.json() assert project_data project_uuid = project_data["data"]["uuid"] diff --git a/tests/e2e/portal-files/VTK_file.js b/tests/e2e/portal-files/VTK_file.js index 081c5fcdc56..77a293052d7 100644 --- a/tests/e2e/portal-files/VTK_file.js +++ b/tests/e2e/portal-files/VTK_file.js @@ -60,7 +60,7 @@ async function runTutorial () { await utils.takeScreenshot(page, screenshotPrefix + 'teapot'); } catch(err) { - await tutorial.setTutorialFailed(true); + await tutorial.setTutorialFailed(); console.log('Tutorial error: ' + err); } finally { diff --git a/tests/e2e/portal/2D_Plot.js b/tests/e2e/portal/2D_Plot.js index 5a41e3d4eb1..f7b311344e7 100644 --- a/tests/e2e/portal/2D_Plot.js +++ b/tests/e2e/portal/2D_Plot.js @@ -51,7 +51,7 @@ async function runTutorial () { await utils.takeScreenshot(page, screenshotPrefix + 'iFrame2'); } catch(err) { - await tutorial.setTutorialFailed(true); + await tutorial.setTutorialFailed(); console.log('Tutorial error: ' + err); } finally { diff --git a/tests/e2e/portal/3D_Anatomical.js b/tests/e2e/portal/3D_Anatomical.js index e13f76c36fb..d2f933bf912 100644 --- a/tests/e2e/portal/3D_Anatomical.js +++ b/tests/e2e/portal/3D_Anatomical.js @@ -39,7 +39,7 @@ async function runTutorial () { await tutorial.checkNodeOutputs(1, outFiles); } catch(err) { - await tutorial.setTutorialFailed(true); + await tutorial.setTutorialFailed(); console.log('Tutorial error: ' + err); } finally { diff --git a/tests/e2e/portal/3D_EM.js b/tests/e2e/portal/3D_EM.js index 1b2449256c4..0934baf3001 100644 --- a/tests/e2e/portal/3D_EM.js +++ b/tests/e2e/portal/3D_EM.js @@ -39,7 +39,7 @@ async function runTutorial () { await tutorial.checkNodeOutputs(2, outFiles); } catch(err) { - await tutorial.setTutorialFailed(true); + await tutorial.setTutorialFailed(); console.log('Tutorial error: ' + err); } finally { diff --git a/tests/e2e/portal/BIOS_VNS_Calibrator.js b/tests/e2e/portal/BIOS_VNS_Calibrator.js index 440444b17f9..e6959fedc85 100644 --- a/tests/e2e/portal/BIOS_VNS_Calibrator.js +++ b/tests/e2e/portal/BIOS_VNS_Calibrator.js @@ -40,7 +40,7 @@ async function runTutorial () { await tutorial.restoreIFrame(); } catch(err) { - await tutorial.setTutorialFailed(true); + await tutorial.setTutorialFailed(); console.log('Tutorial error: ' + err); } finally { diff --git a/tests/e2e/portal/Bornstein.js b/tests/e2e/portal/Bornstein.js index 27358911baa..fa823b0382e 100644 --- a/tests/e2e/portal/Bornstein.js +++ b/tests/e2e/portal/Bornstein.js @@ -40,7 +40,7 @@ async function runTutorial () { await tutorial.checkNodeOutputs(0, outFiles); } catch(err) { - await tutorial.setTutorialFailed(true); + await tutorial.setTutorialFailed(); console.log('Tutorial error: ' + err); } finally { diff --git a/tests/e2e/portal/CC_Human.js b/tests/e2e/portal/CC_Human.js index ce102e74596..b02ff7bfbe0 100644 --- a/tests/e2e/portal/CC_Human.js +++ b/tests/e2e/portal/CC_Human.js @@ -53,7 +53,7 @@ async function runTutorial () { await tutorial.checkNodeOutputs(3, outFiles2); } catch(err) { - await tutorial.setTutorialFailed(true); + await tutorial.setTutorialFailed(); console.log('Tutorial error: ' + err); } finally { diff --git a/tests/e2e/portal/CC_Rabbit.js b/tests/e2e/portal/CC_Rabbit.js index 44b6e137a63..94b122e44b8 100644 --- a/tests/e2e/portal/CC_Rabbit.js +++ b/tests/e2e/portal/CC_Rabbit.js @@ -54,7 +54,7 @@ async function runTutorial () { await tutorial.checkNodeOutputs(3, outFiles2); } catch(err) { - await tutorial.setTutorialFailed(true); + await tutorial.setTutorialFailed(); console.log('Tutorial error: ' + err); } finally { diff --git a/tests/e2e/portal/Kember.js b/tests/e2e/portal/Kember.js index dade7ecac5c..b77cc181073 100644 --- a/tests/e2e/portal/Kember.js +++ b/tests/e2e/portal/Kember.js @@ -63,7 +63,7 @@ async function runTutorial () { await tutorial.takeScreenshot("viewer_after"); } catch(err) { - await tutorial.setTutorialFailed(true); + await tutorial.setTutorialFailed(); console.log('Tutorial error: ' + err); } finally { diff --git a/tests/e2e/portal/Mattward.js b/tests/e2e/portal/Mattward.js index b09c09db092..c6cde9101dc 100644 --- a/tests/e2e/portal/Mattward.js +++ b/tests/e2e/portal/Mattward.js @@ -45,7 +45,7 @@ async function runTutorial () { await tutorial.checkNodeOutputs(0, outFiles); } catch(err) { - await tutorial.setTutorialFailed(true); + await tutorial.setTutorialFailed(); console.log('Tutorial error: ' + err); } finally { diff --git a/tests/e2e/portal/opencor.js b/tests/e2e/portal/opencor.js index 46ca143a48f..0d95104fe6e 100644 --- a/tests/e2e/portal/opencor.js +++ b/tests/e2e/portal/opencor.js @@ -39,7 +39,7 @@ async function runTutorial () { await tutorial.checkNodeOutputs(0, outFiles); } catch(err) { - await tutorial.setTutorialFailed(true); + await tutorial.setTutorialFailed(); console.log('Tutorial error: ' + err); } finally { diff --git a/tests/e2e/publications/SarValidation.js b/tests/e2e/publications/SarValidation.js index 3c5c4eba4b0..2ea10b448fa 100644 --- a/tests/e2e/publications/SarValidation.js +++ b/tests/e2e/publications/SarValidation.js @@ -40,7 +40,7 @@ async function runTutorial () { await tutorial.testSARValidation(sarNodeId); } catch(err) { - await tutorial.setTutorialFailed(true, false); + await tutorial.setTutorialFailed(); console.log('Tutorial error: ' + err); } finally { diff --git a/tests/e2e/publications/Voila.js b/tests/e2e/publications/Voila.js index 85cb9bfea7a..c59fd305755 100644 --- a/tests/e2e/publications/Voila.js +++ b/tests/e2e/publications/Voila.js @@ -43,7 +43,7 @@ async function runTutorial () { await tutorial.waitForVoilaRendered(iframe); } catch(err) { - await tutorial.setTutorialFailed(true, false); + await tutorial.setTutorialFailed(); console.log('Tutorial error: ' + err); } finally { diff --git a/tests/e2e/s4l/sim4life-dipole.js b/tests/e2e/s4l/sim4life-dipole.js index ff9d17bb66c..40f0e08a7da 100644 --- a/tests/e2e/s4l/sim4life-dipole.js +++ b/tests/e2e/s4l/sim4life-dipole.js @@ -49,7 +49,7 @@ async function runTutorial() { await tutorial.testS4LDipole(s4lNodeId); } catch (err) { - tutorial.setTutorialFailed(true, false); + tutorial.setTutorialFailed(err); console.log('Tutorial error: ' + err); throw "Tutorial Failed"; } diff --git a/tests/e2e/s4l/sim4life-lite.js b/tests/e2e/s4l/sim4life-lite.js index 1746ef2356c..4ac3ed41d80 100644 --- a/tests/e2e/s4l/sim4life-lite.js +++ b/tests/e2e/s4l/sim4life-lite.js @@ -65,7 +65,7 @@ async function runTutorial(user, pass, newUser, parallelUserIdx) { await tutorial.testS4L(s4lNodeId); } catch (err) { - await tutorial.setTutorialFailed(true); + await tutorial.setTutorialFailed(); console.log('Tutorial error: ' + err); throw "Tutorial Failed"; } diff --git a/tests/e2e/s4l/sim4life.js b/tests/e2e/s4l/sim4life.js index 1313453a78f..3580b6731d5 100644 --- a/tests/e2e/s4l/sim4life.js +++ b/tests/e2e/s4l/sim4life.js @@ -40,7 +40,7 @@ async function runTutorial() { await tutorial.testS4L(s4lNodeId); } catch (err) { - await tutorial.setTutorialFailed(true); + await tutorial.setTutorialFailed(); console.log('Tutorial error: ' + err); } finally { diff --git a/tests/e2e/tutorials/isolve-gpu.js b/tests/e2e/tutorials/isolve-gpu.js index bd5e562552c..4e163046154 100644 --- a/tests/e2e/tutorials/isolve-gpu.js +++ b/tests/e2e/tutorials/isolve-gpu.js @@ -42,7 +42,7 @@ async function runTutorial() { await tutorial.checkNodeLogsFunctional(); } catch(err) { - await tutorial.setTutorialFailed(true); + await tutorial.setTutorialFailed(); console.log('Tutorial error: ' + err); } finally { diff --git a/tests/e2e/tutorials/isolve-mpi.js b/tests/e2e/tutorials/isolve-mpi.js index 24229990048..809cbfcda94 100644 --- a/tests/e2e/tutorials/isolve-mpi.js +++ b/tests/e2e/tutorials/isolve-mpi.js @@ -40,7 +40,7 @@ async function runTutorial() { await tutorial.checkNodeLogsFunctional(); } catch (err) { - await tutorial.setTutorialFailed(true, err); + await tutorial.setTutorialFailed(err); console.log('Tutorial error: ' + err); } finally { diff --git a/tests/e2e/tutorials/jupyterlabs.js b/tests/e2e/tutorials/jupyterlabs.js index 6d8ee355b9c..90929bae7b2 100644 --- a/tests/e2e/tutorials/jupyterlabs.js +++ b/tests/e2e/tutorials/jupyterlabs.js @@ -78,7 +78,7 @@ async function runTutorial() { } } catch (err) { - await tutorial.setTutorialFailed(true); + await tutorial.setTutorialFailed(); console.log('Tutorial error: ' + err); } finally { diff --git a/tests/e2e/tutorials/rclone_large.js b/tests/e2e/tutorials/rclone_large.js index 0f53aa9a77d..12d7f859a50 100644 --- a/tests/e2e/tutorials/rclone_large.js +++ b/tests/e2e/tutorials/rclone_large.js @@ -64,12 +64,12 @@ async function runTutorial() { await tutorial.takeScreenshot("after_run_all_menu"); - + await tutorial.waitFor(60000); // we are creating 12 x 1 GB files with 75 % probability } } catch (err) { - await tutorial.setTutorialFailed(true); + await tutorial.setTutorialFailed(); console.log('Tutorial error: ' + err); } finally { diff --git a/tests/e2e/tutorials/rclone_small.js b/tests/e2e/tutorials/rclone_small.js index 2253c24a925..f8a3cbe443a 100644 --- a/tests/e2e/tutorials/rclone_small.js +++ b/tests/e2e/tutorials/rclone_small.js @@ -68,7 +68,7 @@ async function runTutorial() { } } catch (err) { - await tutorial.setTutorialFailed(true); + await tutorial.setTutorialFailed(); console.log('Tutorial error: ' + err); } finally { diff --git a/tests/e2e/tutorials/sleepers.js b/tests/e2e/tutorials/sleepers.js index 5db94b03b5a..2034a6e6f9a 100644 --- a/tests/e2e/tutorials/sleepers.js +++ b/tests/e2e/tutorials/sleepers.js @@ -39,7 +39,7 @@ async function runTutorial() { await tutorial.checkNodeLogsFunctional(); } catch(err) { - await tutorial.setTutorialFailed(true); + await tutorial.setTutorialFailed(); console.log('Tutorial error: ' + err); } finally { diff --git a/tests/e2e/tutorials/ti-plan.js b/tests/e2e/tutorials/ti-plan.js index d4b18a48ee8..638d0d72a9b 100644 --- a/tests/e2e/tutorials/ti-plan.js +++ b/tests/e2e/tutorials/ti-plan.js @@ -136,7 +136,7 @@ async function runTutorial() { .then(() => page.click(id)) .catch(() => console.log("Preparing Inputs window not found")); - tutorial.setTutorialFailed(true, false); + tutorial.setTutorialFailed(err); console.log('Tutorial error: ' + err); throw "Tutorial Failed"; } diff --git a/tests/e2e/tutorials/tutorialBase.js b/tests/e2e/tutorials/tutorialBase.js index f258629a122..0d649cb384d 100644 --- a/tests/e2e/tutorials/tutorialBase.js +++ b/tests/e2e/tutorials/tutorialBase.js @@ -414,12 +414,6 @@ class TutorialBase { await auto.showLogger(this.__page, show); } - async takeLoggerScreenshot() { - await this.takeScreenshot("logger_before"); - await this.showLogger(true); - await this.takeScreenshot("logger_after"); - } - async runPipeline() { await this.takeScreenshot("runStudy_before"); await auto.runStudy(this.__page); @@ -835,11 +829,8 @@ class TutorialBase { return this.__reasonFailed; } - async setTutorialFailed(failed, loggerScreenshot = true, reason = "") { - if (failed && loggerScreenshot) { - await this.takeLoggerScreenshot(); - } - this.__failed = failed; + async setTutorialFailed(reason = "") { + this.__failed = true; this.__reasonFailed = reason } }