diff --git a/api/specs/web-server/_admin.py b/api/specs/web-server/_admin.py index 767661a0dfc..87c72ce371f 100644 --- a/api/specs/web-server/_admin.py +++ b/api/specs/web-server/_admin.py @@ -28,7 +28,7 @@ response_model=Envelope[Union[EmailTestFailed, EmailTestPassed]], ) async def test_email( - _test: TestEmail, x_simcore_products_name: str | None = Header(default=None) + _body: TestEmail, x_simcore_products_name: str | None = Header(default=None) ): # X-Simcore-Products-Name ... diff --git a/api/specs/web-server/_groups.py b/api/specs/web-server/_groups.py index 530460c6d8c..6f0f1f1e616 100644 --- a/api/specs/web-server/_groups.py +++ b/api/specs/web-server/_groups.py @@ -4,6 +4,7 @@ # pylint: disable=too-many-arguments +from enum import Enum from typing import Annotated, Any from fastapi import APIRouter, Depends, status @@ -87,19 +88,24 @@ async def delete_group(_path: Annotated[GroupsPathParams, Depends()]): """ +_extra_tags: list[str | Enum] = ["users"] + + @router.get( "/groups/{gid}/users", response_model=Envelope[list[GroupUserGet]], + tags=_extra_tags, ) async def get_all_group_users(_path: Annotated[GroupsPathParams, Depends()]): """ - Gets users in organization groups + Gets users in organization or primary groups """ @router.post( "/groups/{gid}/users", status_code=status.HTTP_204_NO_CONTENT, + tags=_extra_tags, ) async def add_group_user( _path: Annotated[GroupsPathParams, Depends()], @@ -113,6 +119,7 @@ async def add_group_user( @router.get( "/groups/{gid}/users/{uid}", response_model=Envelope[GroupUserGet], + tags=_extra_tags, ) async def get_group_user( _path: Annotated[GroupsUsersPathParams, Depends()], @@ -125,6 +132,7 @@ async def get_group_user( @router.patch( "/groups/{gid}/users/{uid}", response_model=Envelope[GroupUserGet], + tags=_extra_tags, ) async def update_group_user( _path: Annotated[GroupsUsersPathParams, Depends()], @@ -138,6 +146,7 @@ async def update_group_user( @router.delete( "/groups/{gid}/users/{uid}", status_code=status.HTTP_204_NO_CONTENT, + tags=_extra_tags, ) async def delete_group_user( _path: Annotated[GroupsUsersPathParams, Depends()], diff --git a/api/specs/web-server/_licensed_items_checkouts.py b/api/specs/web-server/_licensed_items_checkouts.py new file mode 100644 index 00000000000..cfc51a7c424 --- /dev/null +++ b/api/specs/web-server/_licensed_items_checkouts.py @@ -0,0 +1,57 @@ +""" Helper script to generate OAS automatically +""" + +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments + +from typing import Annotated + +from _common import as_query +from fastapi import APIRouter, Depends +from models_library.api_schemas_webserver.licensed_items_purchases import ( + LicensedItemPurchaseGet, +) +from models_library.generics import Envelope +from models_library.rest_error import EnvelopedError +from models_library.rest_pagination import Page +from simcore_service_webserver._meta import API_VTAG +from simcore_service_webserver.licenses._exceptions_handlers import _TO_HTTP_ERROR_MAP +from simcore_service_webserver.licenses._licensed_items_checkouts_models import ( + LicensedItemCheckoutPathParams, + LicensedItemsCheckoutsListQueryParams, +) +from simcore_service_webserver.wallets._handlers import WalletsPathParams + +router = APIRouter( + prefix=f"/{API_VTAG}", + tags=[ + "licenses", + ], + responses={ + i.status_code: {"model": EnvelopedError} for i in _TO_HTTP_ERROR_MAP.values() + }, +) + + +@router.get( + "/wallets/{wallet_id}/licensed-items-checkouts", + response_model=Page[LicensedItemPurchaseGet], + tags=["wallets"], +) +async def list_licensed_item_checkouts_for_wallet( + _path: Annotated[WalletsPathParams, Depends()], + _query: Annotated[as_query(LicensedItemsCheckoutsListQueryParams), Depends()], +): + ... + + +@router.get( + "/licensed-items-checkouts/{licensed_item_checkout_id}", + response_model=Envelope[LicensedItemPurchaseGet], +) +async def get_licensed_item_checkout( + _path: Annotated[LicensedItemCheckoutPathParams, Depends()], +): + ... diff --git a/api/specs/web-server/_users.py b/api/specs/web-server/_users.py index 95915497c52..89d5eaaba2f 100644 --- a/api/specs/web-server/_users.py +++ b/api/specs/web-server/_users.py @@ -4,6 +4,7 @@ # pylint: disable=too-many-arguments +from enum import Enum from typing import Annotated from fastapi import APIRouter, Depends, status @@ -13,8 +14,10 @@ MyProfilePatch, MyTokenCreate, MyTokenGet, + UserForAdminGet, UserGet, - UsersSearchQueryParams, + UsersForAdminSearchQueryParams, + UsersSearch, ) from models_library.api_schemas_webserver.users_preferences import PatchRequestBody from models_library.generics import Envelope @@ -29,7 +32,7 @@ from simcore_service_webserver.users._notifications_rest import _NotificationPathParams from simcore_service_webserver.users._tokens_rest import _TokenPathParams -router = APIRouter(prefix=f"/{API_VTAG}", tags=["user"]) +router = APIRouter(prefix=f"/{API_VTAG}", tags=["users"]) @router.get( @@ -44,7 +47,7 @@ async def get_my_profile(): "/me", status_code=status.HTTP_204_NO_CONTENT, ) -async def update_my_profile(_profile: MyProfilePatch): +async def update_my_profile(_body: MyProfilePatch): ... @@ -54,7 +57,7 @@ async def update_my_profile(_profile: MyProfilePatch): deprecated=True, description="Use PATCH instead", ) -async def replace_my_profile(_profile: MyProfilePatch): +async def replace_my_profile(_body: MyProfilePatch): ... @@ -64,7 +67,7 @@ async def replace_my_profile(_profile: MyProfilePatch): ) async def set_frontend_preference( preference_id: PreferenceIdentifier, - body_item: PatchRequestBody, + _body: PatchRequestBody, ): ... @@ -82,7 +85,7 @@ async def list_tokens(): response_model=Envelope[MyTokenGet], status_code=status.HTTP_201_CREATED, ) -async def create_token(_token: MyTokenCreate): +async def create_token(_body: MyTokenCreate): ... @@ -90,7 +93,9 @@ async def create_token(_token: MyTokenCreate): "/me/tokens/{service}", response_model=Envelope[MyTokenGet], ) -async def get_token(_params: Annotated[_TokenPathParams, Depends()]): +async def get_token( + _path: Annotated[_TokenPathParams, Depends()], +): ... @@ -98,7 +103,7 @@ async def get_token(_params: Annotated[_TokenPathParams, Depends()]): "/me/tokens/{service}", status_code=status.HTTP_204_NO_CONTENT, ) -async def delete_token(_params: Annotated[_TokenPathParams, Depends()]): +async def delete_token(_path: Annotated[_TokenPathParams, Depends()]): ... @@ -114,7 +119,9 @@ async def list_user_notifications(): "/me/notifications", status_code=status.HTTP_204_NO_CONTENT, ) -async def create_user_notification(_notification: UserNotificationCreate): +async def create_user_notification( + _body: UserNotificationCreate, +): ... @@ -123,8 +130,8 @@ async def create_user_notification(_notification: UserNotificationCreate): status_code=status.HTTP_204_NO_CONTENT, ) async def mark_notification_as_read( - _params: Annotated[_NotificationPathParams, Depends()], - _notification: UserNotificationPatch, + _path: Annotated[_NotificationPathParams, Depends()], + _body: UserNotificationPatch, ): ... @@ -137,24 +144,43 @@ async def list_user_permissions(): ... -@router.get( +# +# USERS public +# + + +@router.post( "/users:search", response_model=Envelope[list[UserGet]], - tags=[ - "po", - ], + description="Search among users who are publicly visible to the caller (i.e., me) based on their privacy settings.", ) -async def search_users(_params: Annotated[UsersSearchQueryParams, Depends()]): +async def search_users(_body: UsersSearch): + ... + + +# +# USERS admin +# + +_extra_tags: list[str | Enum] = ["admin"] + + +@router.get( + "/admin/users:search", + response_model=Envelope[list[UserForAdminGet]], + tags=_extra_tags, +) +async def search_users_for_admin( + _query: Annotated[UsersForAdminSearchQueryParams, Depends()] +): # NOTE: see `Search` in `Common Custom Methods` in https://cloud.google.com/apis/design/custom_methods ... @router.post( - "/users:pre-register", - response_model=Envelope[UserGet], - tags=[ - "po", - ], + "/admin/users:pre-register", + response_model=Envelope[UserForAdminGet], + tags=_extra_tags, ) -async def pre_register_user(_body: PreRegisteredUserGet): +async def pre_register_user_for_admin(_body: PreRegisteredUserGet): ... diff --git a/api/specs/web-server/openapi.py b/api/specs/web-server/openapi.py index 6c3bc639fb4..cfcaf183591 100644 --- a/api/specs/web-server/openapi.py +++ b/api/specs/web-server/openapi.py @@ -38,6 +38,7 @@ "_long_running_tasks", "_licensed_items", "_licensed_items_purchases", + "_licensed_items_checkouts", "_metamodeling", "_nih_sparc", "_nih_sparc_redirections", diff --git a/packages/common-library/src/common_library/unset.py b/packages/common-library/src/common_library/exclude.py similarity index 64% rename from packages/common-library/src/common_library/unset.py rename to packages/common-library/src/common_library/exclude.py index 3d4dfcbc947..6f635dfe643 100644 --- a/packages/common-library/src/common_library/unset.py +++ b/packages/common-library/src/common_library/exclude.py @@ -10,3 +10,7 @@ class UnSet: def as_dict_exclude_unset(**params) -> dict[str, Any]: return {k: v for k, v in params.items() if not isinstance(v, UnSet)} + + +def as_dict_exclude_none(**params) -> dict[str, Any]: + return {k: v for k, v in params.items() if v is not None} diff --git a/packages/common-library/tests/test_unset.py b/packages/common-library/tests/test_exclude.py similarity index 59% rename from packages/common-library/tests/test_unset.py rename to packages/common-library/tests/test_exclude.py index 0fece0d466c..78f5712161e 100644 --- a/packages/common-library/tests/test_unset.py +++ b/packages/common-library/tests/test_exclude.py @@ -1,6 +1,6 @@ from typing import Any -from common_library.unset import UnSet, as_dict_exclude_unset +from common_library.exclude import UnSet, as_dict_exclude_none, as_dict_exclude_unset def test_as_dict_exclude_unset(): @@ -13,3 +13,10 @@ def f( assert f(par1="hi") == {"par1": "hi"} assert f(par2=4) == {"par2": 4} assert f(par1="hi", par2=4) == {"par1": "hi", "par2": 4} + + # still expected behavior + assert as_dict_exclude_unset(par1=None) == {"par1": None} + + +def test_as_dict_exclude_none(): + assert as_dict_exclude_none(par1=None) == {} diff --git a/packages/models-library/src/models_library/api_schemas_webserver/groups.py b/packages/models-library/src/models_library/api_schemas_webserver/groups.py index 7af1eeb2f96..ec9738044b4 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/groups.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/groups.py @@ -29,7 +29,7 @@ ) from ..users import UserID, UserNameID from ..utils.common_validators import create__check_only_one_is_set__root_validator -from ._base import InputSchema, OutputSchema +from ._base import InputSchema, OutputSchema, OutputSchemaWithoutCamelCase S = TypeVar("S", bound=BaseModel) @@ -248,8 +248,7 @@ def from_model( ) -class GroupUserGet(BaseModel): - # OutputSchema +class GroupUserGet(OutputSchemaWithoutCamelCase): # Identifiers id: Annotated[UserID | None, Field(description="the user's id")] = None @@ -275,7 +274,14 @@ class GroupUserGet(BaseModel): ] = None # Access Rights - access_rights: GroupAccessRights = Field(..., alias="accessRights") + access_rights: Annotated[ + GroupAccessRights | None, + Field( + alias="accessRights", + description="If group is standard, these are these are the access rights of the user to it." + "None if primary group.", + ), + ] = None model_config = ConfigDict( populate_by_name=True, @@ -293,7 +299,23 @@ class GroupUserGet(BaseModel): "write": False, "delete": False, }, - } + }, + "examples": [ + # unique member on a primary group with two different primacy settings + { + "id": "16", + "userName": "mrprivate", + "gid": "55", + }, + { + "id": "56", + "userName": "mrpublic", + "login": "mrpublic@email.me", + "first_name": "Mr", + "last_name": "Public", + "gid": "42", + }, + ], }, ) diff --git a/packages/models-library/src/models_library/api_schemas_webserver/licensed_items_checkouts.py b/packages/models-library/src/models_library/api_schemas_webserver/licensed_items_checkouts.py index a3ee122ddee..c8fd22ce581 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/licensed_items_checkouts.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/licensed_items_checkouts.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import NamedTuple -from pydantic import PositiveInt +from pydantic import BaseModel, PositiveInt from ..licensed_items import LicensedItemID from ..products import ProductName @@ -10,8 +10,29 @@ from ..wallets import WalletID from ._base import OutputSchema +# RPC -class LicensedItemCheckoutGet(OutputSchema): + +class LicensedItemCheckoutRpcGet(BaseModel): + licensed_item_checkout_id: LicensedItemCheckoutID + licensed_item_id: LicensedItemID + wallet_id: WalletID + user_id: UserID + product_name: ProductName + started_at: datetime + stopped_at: datetime | None + num_of_seats: int + + +class LicensedItemCheckoutRpcGetPage(NamedTuple): + items: list[LicensedItemCheckoutRpcGet] + total: PositiveInt + + +# Rest + + +class LicensedItemCheckoutRestGet(OutputSchema): licensed_item_checkout_id: LicensedItemCheckoutID licensed_item_id: LicensedItemID wallet_id: WalletID @@ -22,6 +43,6 @@ class LicensedItemCheckoutGet(OutputSchema): num_of_seats: int -class LicensedItemUsageGetPage(NamedTuple): - items: list[LicensedItemCheckoutGet] +class LicensedItemCheckoutRestGetPage(NamedTuple): + items: list[LicensedItemCheckoutRestGet] total: PositiveInt diff --git a/packages/models-library/src/models_library/api_schemas_webserver/users.py b/packages/models-library/src/models_library/api_schemas_webserver/users.py index 6fcccddaa3a..f5f49bf726c 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/users.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/users.py @@ -3,21 +3,31 @@ from enum import Enum from typing import Annotated, Any, Literal, Self +import annotated_types from common_library.basic_types import DEFAULT_FACTORY from common_library.dict_tools import remap_keys from common_library.users_enums import UserStatus from models_library.groups import AccessRightsDict -from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator +from pydantic import ( + ConfigDict, + EmailStr, + Field, + StringConstraints, + ValidationInfo, + field_validator, +) from ..basic_types import IDStr from ..emails import LowerCaseEmailStr -from ..groups import AccessRightsDict, Group, GroupsByTypeTuple +from ..groups import AccessRightsDict, Group, GroupID, GroupsByTypeTuple from ..products import ProductName +from ..rest_base import RequestParameters from ..users import ( FirstNameStr, LastNameStr, MyProfile, UserID, + UserNameID, UserPermission, UserThirdPartyToken, ) @@ -185,7 +195,37 @@ def _validate_user_name(cls, value: str): # -class UsersSearchQueryParams(BaseModel): +class UsersGetParams(RequestParameters): + user_id: UserID + + +class UsersSearch(InputSchema): + match_: Annotated[ + str, + StringConstraints(strip_whitespace=True, min_length=1, max_length=80), + Field( + description="Search string to match with usernames and public profiles (e.g. emails, first/last name)", + alias="match", + ), + ] + limit: Annotated[int, annotated_types.Interval(ge=1, le=50)] = 10 + + +class UserGet(OutputSchema): + # Public profile of a user subject to its privacy settings + user_id: UserID + group_id: GroupID + user_name: UserNameID + first_name: str | None = None + last_name: str | None = None + email: EmailStr | None = None + + @classmethod + def from_model(cls, data): + return cls.model_validate(data, from_attributes=True) + + +class UsersForAdminSearchQueryParams(RequestParameters): email: Annotated[ str, Field( @@ -196,7 +236,8 @@ class UsersSearchQueryParams(BaseModel): ] -class UserGet(OutputSchema): +class UserForAdminGet(OutputSchema): + # ONLY for admins first_name: str | None last_name: str | None email: LowerCaseEmailStr diff --git a/packages/models-library/src/models_library/groups.py b/packages/models-library/src/models_library/groups.py index a7d4810d534..c0d8692b2e7 100644 --- a/packages/models-library/src/models_library/groups.py +++ b/packages/models-library/src/models_library/groups.py @@ -108,7 +108,7 @@ class GroupMember(BaseModel): last_name: str | None # group access - access_rights: AccessRightsDict + access_rights: AccessRightsDict | None = None model_config = ConfigDict(from_attributes=True) diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/1e3c9c804fec_set_privacy_hide_email_to_true.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/1e3c9c804fec_set_privacy_hide_email_to_true.py new file mode 100644 index 00000000000..58e1115a1bf --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/1e3c9c804fec_set_privacy_hide_email_to_true.py @@ -0,0 +1,33 @@ +"""set privacy_hide_email to true. Reverts "set privacy_hide_email to false temporarily" (5e27063c3ac9) + +Revision ID: 1e3c9c804fec +Revises: d31c23845017 +Create Date: 2025-01-03 10:16:58.531083+00:00 + +""" +from alembic import op +from sqlalchemy.sql import expression + +# revision identifiers, used by Alembic. +revision = "1e3c9c804fec" +down_revision = "d31c23845017" +branch_labels = None +depends_on = None + + +def upgrade(): + # server_default of privacy_hide_email to true + with op.batch_alter_table("users") as batch_op: + batch_op.alter_column("privacy_hide_email", server_default=expression.true()) + + # Reset all to default: Revert existing values in the database to true + op.execute("UPDATE users SET privacy_hide_email = true") + + +def downgrade(): + # Change the server_default of privacy_hide_email to false + with op.batch_alter_table("users") as batch_op: + batch_op.alter_column("privacy_hide_email", server_default=expression.false()) + + # Reset all to default: Update existing values in the database + op.execute("UPDATE users SET privacy_hide_email = false") diff --git a/packages/postgres-database/src/simcore_postgres_database/utils_users.py b/packages/postgres-database/src/simcore_postgres_database/utils_users.py index 082cb7c2952..ac5426bafde 100644 --- a/packages/postgres-database/src/simcore_postgres_database/utils_users.py +++ b/packages/postgres-database/src/simcore_postgres_database/utils_users.py @@ -10,6 +10,7 @@ import sqlalchemy as sa from aiopg.sa.connection import SAConnection from aiopg.sa.result import RowProxy +from sqlalchemy import Column from .errors import UniqueViolation from .models.users import UserRole, UserStatus, users @@ -214,7 +215,44 @@ async def is_email_used(conn: SAConnection, email: str) -> bool: users_pre_registration_details.c.pre_email == email ) ) - if pre_registered: - return True - - return False + return bool(pre_registered) + + +# +# Privacy settings +# + + +def is_private(hide_attribute: Column, caller_id: int): + return hide_attribute.is_(True) & (users.c.id != caller_id) + + +def is_public(hide_attribute: Column, caller_id: int): + return hide_attribute.is_(False) | (users.c.id == caller_id) + + +def visible_user_profile_cols(caller_id: int): + """Returns user profile columns with visibility constraints applied based on privacy settings.""" + return ( + sa.case( + ( + is_private(users.c.privacy_hide_email, caller_id), + None, + ), + else_=users.c.email, + ).label("email"), + sa.case( + ( + is_private(users.c.privacy_hide_fullname, caller_id), + None, + ), + else_=users.c.first_name, + ).label("first_name"), + sa.case( + ( + is_private(users.c.privacy_hide_fullname, caller_id), + None, + ), + else_=users.c.last_name, + ).label("last_name"), + ) diff --git a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/licenses/licensed_items.py b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/licenses/licensed_items.py index f767882d247..0f86ab63d79 100644 --- a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/licenses/licensed_items.py +++ b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/licenses/licensed_items.py @@ -6,7 +6,7 @@ LicensedItemGetPage, ) from models_library.api_schemas_webserver.licensed_items_checkouts import ( - LicensedItemCheckoutGet, + LicensedItemCheckoutRpcGet, ) from models_library.licensed_items import LicensedItemID from models_library.products import ProductName @@ -78,7 +78,7 @@ async def checkout_licensed_item_for_wallet( licensed_item_id: LicensedItemID, num_of_seats: int, service_run_id: ServiceRunID, -) -> LicensedItemCheckoutGet: +) -> LicensedItemCheckoutRpcGet: result = await rabbitmq_rpc_client.request( WEBSERVER_RPC_NAMESPACE, TypeAdapter(RPCMethodName).validate_python("checkout_licensed_item_for_wallet"), @@ -89,7 +89,7 @@ async def checkout_licensed_item_for_wallet( num_of_seats=num_of_seats, service_run_id=service_run_id, ) - assert isinstance(result, LicensedItemCheckoutGet) # nosec + assert isinstance(result, LicensedItemCheckoutRpcGet) # nosec return result @@ -100,7 +100,7 @@ async def release_licensed_item_for_wallet( product_name: ProductName, user_id: UserID, licensed_item_checkout_id: LicensedItemCheckoutID, -) -> LicensedItemCheckoutGet: +) -> LicensedItemCheckoutRpcGet: result = await rabbitmq_rpc_client.request( WEBSERVER_RPC_NAMESPACE, TypeAdapter(RPCMethodName).validate_python("release_licensed_item_for_wallet"), @@ -108,5 +108,5 @@ async def release_licensed_item_for_wallet( user_id=user_id, licensed_item_checkout_id=licensed_item_checkout_id, ) - assert isinstance(result, LicensedItemCheckoutGet) # nosec + assert isinstance(result, LicensedItemCheckoutRpcGet) # nosec return result diff --git a/services/catalog/src/simcore_service_catalog/api/rest/_services.py b/services/catalog/src/simcore_service_catalog/api/rest/_services.py index e2abc23d179..78362d63733 100644 --- a/services/catalog/src/simcore_service_catalog/api/rest/_services.py +++ b/services/catalog/src/simcore_service_catalog/api/rest/_services.py @@ -9,6 +9,7 @@ from fastapi import APIRouter, Depends, Header, HTTPException, status from models_library.api_schemas_catalog.services import ServiceGet, ServiceUpdate from models_library.services import ServiceKey, ServiceType, ServiceVersion +from models_library.services_authoring import Author from models_library.services_metadata_published import ServiceMetaDataPublished from pydantic import ValidationError from pydantic.types import PositiveInt @@ -127,7 +128,11 @@ async def list_services( name="nodetails", description="nodetails", type=ServiceType.COMPUTATIONAL, - authors=[{"name": "nodetails", "email": "nodetails@nodetails.com"}], + authors=[ + Author.model_construct( + name="nodetails", email="nodetails@nodetails.com" + ) + ], contact="nodetails@nodetails.com", inputs={}, outputs={}, diff --git a/services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/services/director_v2/_thin_client.py b/services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/services/director_v2/_thin_client.py index 412e7377d19..aef8823ce7d 100644 --- a/services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/services/director_v2/_thin_client.py +++ b/services/dynamic-scheduler/src/simcore_service_dynamic_scheduler/services/director_v2/_thin_client.py @@ -1,8 +1,8 @@ import datetime from typing import cast +from common_library.exclude import as_dict_exclude_none from common_library.json_serialization import json_dumps -from common_library.unset import UnSet, as_dict_exclude_unset from fastapi import FastAPI, status from httpx import Response, Timeout from models_library.api_schemas_dynamic_scheduler.dynamic_services import ( @@ -133,14 +133,11 @@ async def dynamic_service_retrieve( @retry_on_errors() @expect_status(status.HTTP_200_OK) async def get_dynamic_services( - self, - *, - user_id: UserID | None | UnSet = UnSet.VALUE, - project_id: ProjectID | None | UnSet = UnSet.VALUE, + self, *, user_id: UserID | None = None, project_id: ProjectID | None = None ) -> Response: return await self.client.get( "/dynamic_services", - params=as_dict_exclude_unset(user_id=user_id, project_id=project_id), + params=as_dict_exclude_none(user_id=user_id, project_id=project_id), ) @retry_on_errors() diff --git a/services/static-webserver/client/source/class/osparc/dashboard/CardBase.js b/services/static-webserver/client/source/class/osparc/dashboard/CardBase.js index a6d8742f2c3..2be758253a0 100644 --- a/services/static-webserver/client/source/class/osparc/dashboard/CardBase.js +++ b/services/static-webserver/client/source/class/osparc/dashboard/CardBase.js @@ -148,54 +148,59 @@ qx.Class.define("osparc.dashboard.CardBase", { return false; }, - // groups -> [orgMembs, orgs, [productEveryone], [everyone]]; - setIconAndTooltip: function(shareIcon, accessRights, groups) { - shareIcon.setSource(osparc.dashboard.CardBase.SHARE_ICON); - if (osparc.data.model.Study.canIWrite(accessRights)) { - shareIcon.set({ - toolTipText: qx.locale.Manager.tr("Share") - }); + populateShareIcon: async function(shareIcon, accessRights) { + const gids = Object.keys(accessRights).map(key => parseInt(key)); + + const groupsStore = osparc.store.Groups.getInstance(); + + // Icon + const groupEveryone = groupsStore.getEveryoneGroup(); + const groupProductEveryone = groupsStore.getEveryoneProductGroup(); + const organizations = groupsStore.getOrganizations(); + const organizationIds = Object.keys(organizations).map(key => parseInt(key)); + if (gids.includes(groupEveryone.getGroupId()) || gids.includes(groupProductEveryone.getGroupId())) { + shareIcon.setSource(osparc.dashboard.CardBase.SHARED_ALL); + } else if (organizationIds.filter(value => gids.includes(value)).length) { // find intersection + shareIcon.setSource(osparc.dashboard.CardBase.SHARED_ORGS); + } else if (gids.length === 1) { + shareIcon.setSource(osparc.dashboard.CardBase.SHARE_ICON); + } else { + shareIcon.setSource(osparc.dashboard.CardBase.SHARED_USER); } - let sharedGrps = []; - const myGroupId = osparc.auth.Data.getInstance().getGroupId(); - for (let i=0; i { + const idx = gids.indexOf(group.getGroupId()); + if (idx > -1) { + sharedGrps.push(group); + gids.splice(idx, 1); } - const sharedGrp = []; - const gids = Object.keys(accessRights); - for (let j=0; j group.getGroupId() === gid); - if (grp) { - sharedGrp.push(grp); + }); + // once the groups were removed, the remaining group ids are users' primary groups ids + const usersStore = osparc.store.Users.getInstance(); + const myGroupId = groupsStore.getMyGroupId(); + for (let i=0; i hint.show(), this); shareIcon.addListener("mouseout", () => hint.exclude(), this); }, - - // groups -> [orgMembs, orgs, [productEveryone], [everyone]]; - populateShareIcon: function(shareIcon, accessRights) { - const groupsStore = osparc.store.Groups.getInstance(); - const orgMembs = Object.values(groupsStore.getReachableUsers()); - const orgs = Object.values(groupsStore.getOrganizations()); - const productEveryone = [groupsStore.getEveryoneProductGroup()]; - const everyone = [groupsStore.getEveryoneGroup()]; - const groups = [orgMembs, orgs, productEveryone, everyone]; - osparc.dashboard.CardBase.setIconAndTooltip(shareIcon, accessRights, groups); - }, }, properties: { diff --git a/services/static-webserver/client/source/class/osparc/data/Resources.js b/services/static-webserver/client/source/class/osparc/data/Resources.js index 16dbadc0b37..bc9068bca8c 100644 --- a/services/static-webserver/client/source/class/osparc/data/Resources.js +++ b/services/static-webserver/client/source/class/osparc/data/Resources.js @@ -878,6 +878,22 @@ qx.Class.define("osparc.data.Resources", { } } }, + /* + * USERS + */ + "users": { + useCache: false, // osparc.store.Groups handles the cache + endpoints: { + get: { + method: "GET", + url: statics.API + "/groups/{gid}/users" + }, + search: { + method: "POST", + url: statics.API + "/users:search" + } + } + }, /* * WALLETS */ @@ -958,15 +974,15 @@ qx.Class.define("osparc.data.Resources", { } } }, - "users": { + "poUsers": { endpoints: { search: { method: "GET", - url: statics.API + "/users:search?email={email}" + url: statics.API + "/admin/users:search?email={email}" }, preRegister: { method: "POST", - url: statics.API + "/users:pre-register" + url: statics.API + "/admin/users:pre-register" } } }, diff --git a/services/static-webserver/client/source/class/osparc/data/Roles.js b/services/static-webserver/client/source/class/osparc/data/Roles.js index b3a87e6c1c4..6b172bf80f1 100644 --- a/services/static-webserver/client/source/class/osparc/data/Roles.js +++ b/services/static-webserver/client/source/class/osparc/data/Roles.js @@ -165,7 +165,7 @@ qx.Class.define("osparc.data.Roles", { } }, - __createIntoFromRoles: function(roles, showWording = true) { + __createRolesLayout: function(roles, showWording = true) { const rolesLayout = new qx.ui.container.Composite(new qx.ui.layout.HBox(5)).set({ alignY: "middle", paddingRight: 10 @@ -202,23 +202,34 @@ qx.Class.define("osparc.data.Roles", { }, createRolesOrgInfo: function() { - return this.__createIntoFromRoles(osparc.data.Roles.ORG); + return this.__createRolesLayout(osparc.data.Roles.ORG); }, createRolesWalletInfo: function() { - return this.__createIntoFromRoles(osparc.data.Roles.WALLET); + return this.__createRolesLayout(osparc.data.Roles.WALLET); }, createRolesStudyInfo: function() { - return this.__createIntoFromRoles(osparc.data.Roles.STUDY); + return this.__createRolesLayout(osparc.data.Roles.STUDY); }, createRolesServicesInfo: function() { - return this.__createIntoFromRoles(osparc.data.Roles.SERVICES); + return this.__createRolesLayout(osparc.data.Roles.SERVICES); }, createRolesWorkspaceInfo: function(showWording = true) { - return this.__createIntoFromRoles(osparc.data.Roles.WORKSPACE, showWording); - } + return this.__createRolesLayout(osparc.data.Roles.WORKSPACE, showWording); + }, + + replaceSpacerWithWidget: function(rolesLayout, widget) { + if (rolesLayout && rolesLayout.getChildren()) { + // remove spacer + rolesLayout.remove(rolesLayout.getChildren()[0]); + // add widget + rolesLayout.addAt(widget, 0, { + flex: 1 + }); + } + }, } }); diff --git a/services/static-webserver/client/source/class/osparc/data/model/Study.js b/services/static-webserver/client/source/class/osparc/data/model/Study.js index af4b639cd44..f03a01ff741 100644 --- a/services/static-webserver/client/source/class/osparc/data/model/Study.js +++ b/services/static-webserver/client/source/class/osparc/data/model/Study.js @@ -274,10 +274,9 @@ qx.Class.define("osparc.data.model.Study", { }, canIWrite: function(studyAccessRights) { - const myGroupId = osparc.auth.Data.getInstance().getGroupId(); const groupsStore = osparc.store.Groups.getInstance(); const orgIDs = groupsStore.getOrganizationIds(); - orgIDs.push(myGroupId); + orgIDs.push(groupsStore.getMyGroupId()); if (orgIDs.length) { return osparc.share.CollaboratorsStudy.canGroupsWrite(studyAccessRights, (orgIDs)); } @@ -285,10 +284,9 @@ qx.Class.define("osparc.data.model.Study", { }, canIDelete: function(studyAccessRights) { - const myGroupId = osparc.auth.Data.getInstance().getGroupId(); const groupsStore = osparc.store.Groups.getInstance(); const orgIDs = groupsStore.getOrganizationIds(); - orgIDs.push(myGroupId); + orgIDs.push(groupsStore.getMyGroupId()); if (orgIDs.length) { return osparc.share.CollaboratorsStudy.canGroupsDelete(studyAccessRights, (orgIDs)); } diff --git a/services/static-webserver/client/source/class/osparc/data/model/User.js b/services/static-webserver/client/source/class/osparc/data/model/User.js index fbdc80c6adf..7294987345c 100644 --- a/services/static-webserver/client/source/class/osparc/data/model/User.js +++ b/services/static-webserver/client/source/class/osparc/data/model/User.js @@ -28,28 +28,30 @@ qx.Class.define("osparc.data.model.User", { construct: function(userData) { this.base(arguments); - let description = ""; - if (userData["first_name"]) { - description = userData["first_name"]; - if (userData["last_name"]) { - description += " " + userData["last_name"]; + const userId = ("id" in userData) ? parseInt(userData["id"]) : parseInt(userData["userId"]); + const groupId = ("gid" in userData) ? parseInt(userData["gid"]) : parseInt(userData["groupId"]); + const username = userData["userName"]; + const email = ("login" in userData) ? userData["login"] : userData["email"]; + const firstName = ("first_name" in userData) ? userData["first_name"] : userData["firstName"]; + const lastName = ("last_name" in userData) ? userData["last_name"] : userData["lastName"]; + let description = [firstName, lastName].join(" ").trim(); // the null values will be replaced by empty strings + if (email) { + if (description) { + description += " - " } - description += " - "; + description += email; } - if (userData["login"]) { - description += userData["login"]; - } - const thumbnail = osparc.utils.Avatar.emailToThumbnail(userData["login"], userData["userName"]); + const thumbnail = osparc.utils.Avatar.emailToThumbnail(email, username); this.set({ - userId: parseInt(userData["id"]), - groupId: parseInt(userData["gid"]), - username: userData["userName"], - firstName: userData["first_name"], - lastName: userData["last_name"], - email: userData["login"], - label: userData["userName"], - description, + userId, + groupId, + username, + firstName, + lastName, + email, thumbnail, + label: username, + description, }); }, diff --git a/services/static-webserver/client/source/class/osparc/desktop/organizations/MembersList.js b/services/static-webserver/client/source/class/osparc/desktop/organizations/MembersList.js index acd68c25680..91ef4845139 100644 --- a/services/static-webserver/client/source/class/osparc/desktop/organizations/MembersList.js +++ b/services/static-webserver/client/source/class/osparc/desktop/organizations/MembersList.js @@ -23,13 +23,8 @@ qx.Class.define("osparc.desktop.organizations.MembersList", { this._setLayout(new qx.ui.layout.VBox(10)); - this._add(this.__createIntroText()); - this._add(this.__getMemberInvitation()); - this._add(this.__getRolesToolbar()); - this._add(this.__getMembersFilter()); - this._add(this.__getMembersList(), { - flex: 1 - }); + this.__createNewMemberLayout(); + this.__createMembersList(); }, statics: { @@ -80,7 +75,7 @@ qx.Class.define("osparc.desktop.organizations.MembersList", { members: { __currentOrg: null, __introLabel: null, - __memberInvitation: null, + __addMembersButton: null, __membersModel: null, setCurrentOrg: function(orgModel) { @@ -91,7 +86,29 @@ qx.Class.define("osparc.desktop.organizations.MembersList", { this.__reloadOrgMembers(); }, - __createIntroText: function() { + __createNewMemberLayout: function() { + const vBox = new qx.ui.container.Composite(new qx.ui.layout.VBox(5)); + vBox.add(this.__createAddMembersText()); + vBox.add(this.__getMemberInvitation()); + this._add(vBox); + }, + + __createMembersList: function() { + const vBox = new qx.ui.container.Composite(new qx.ui.layout.VBox(5)); + const rolesLayout = this.__getRolesToolbar(); + const membersFilter = this.__getMembersFilter(); + membersFilter.setPaddingRight(10); + osparc.data.Roles.replaceSpacerWithWidget(rolesLayout, membersFilter); + vBox.add(rolesLayout); + vBox.add(this.__getMembersList(), { + flex: 1 + }); + this._add(vBox, { + flex: 1 + }); + }, + + __createAddMembersText: function() { const intro = this.__introLabel = new qx.ui.basic.Label().set({ alignX: "left", rich: true, @@ -101,25 +118,39 @@ qx.Class.define("osparc.desktop.organizations.MembersList", { }, __getMemberInvitation: function() { - const hBox = this.__memberInvitation = new qx.ui.container.Composite(new qx.ui.layout.HBox(10).set({ - alignY: "middle" - })); - - const newMemberUserName = new qx.ui.form.TextField().set({ - required: true, - placeholder: this.tr(" New Member's username") - }); - hBox.add(newMemberUserName, { - flex: 1 + const addBtn = this.__addMembersButton = new qx.ui.form.Button().set({ + appearance: "strong-button", + label: this.tr("Add Members..."), + allowGrowX: false, }); - - const addBtn = new qx.ui.form.Button(this.tr("Add")); addBtn.addListener("execute", function() { - this.__addMember(newMemberUserName.getValue()); + const serializedData = this.__currentOrg.serialize(); + serializedData["resourceType"] = "organization"; + const showOrganizations = false; + const collaboratorsManager = new osparc.share.NewCollaboratorsManager(serializedData, showOrganizations); + collaboratorsManager.setCaption("Add Members"); + collaboratorsManager.getActionButton().setLabel(this.tr("Add")); + collaboratorsManager.addListener("addCollaborators", e => { + const selectedMembers = e.getData(); + if (selectedMembers.length) { + const promises = []; + const usersStore = osparc.store.Users.getInstance(); + selectedMembers.forEach(selectedMemberGId => promises.push(usersStore.getUser(selectedMemberGId))); + Promise.all(promises) + .then(users => { + users.forEach(user => this.__addMember(user.getUsername())); + }) + .catch(err => { + console.error(err); + }) + .finally(collaboratorsManager.close()); + } else { + collaboratorsManager.close(); + } + }, this); }, this); - hBox.add(addBtn); - return hBox; + return addBtn; }, __getRolesToolbar: function() { @@ -127,10 +158,8 @@ qx.Class.define("osparc.desktop.organizations.MembersList", { }, __getMembersFilter: function() { - const filter = new osparc.filter.TextFilter("text", "organizationMembersList").set({ - allowStretchX: true, - margin: [0, 10, 5, 10] - }); + const filter = new osparc.filter.TextFilter("text", "organizationMembersList"); + filter.setCompact(true); return filter; }, @@ -212,11 +241,11 @@ qx.Class.define("osparc.desktop.organizations.MembersList", { const canIDelete = organization.getAccessRights()["delete"]; const introText = canIWrite ? - this.tr("You can add new members and promote or demote existing ones.
In order to add new members, type their username or email if this is public.") : + this.tr("You can add new members and change their roles.") : this.tr("You can't add new members to this Organization. Please contact an Administrator or Manager."); this.__introLabel.setValue(introText); - this.__memberInvitation.set({ + this.__addMembersButton.set({ enabled: canIWrite }); diff --git a/services/static-webserver/client/source/class/osparc/desktop/organizations/OrganizationsList.js b/services/static-webserver/client/source/class/osparc/desktop/organizations/OrganizationsList.js index ff773341ff0..c86917fed36 100644 --- a/services/static-webserver/client/source/class/osparc/desktop/organizations/OrganizationsList.js +++ b/services/static-webserver/client/source/class/osparc/desktop/organizations/OrganizationsList.js @@ -37,8 +37,12 @@ qx.Class.define("osparc.desktop.organizations.OrganizationsList", { }); this._add(intro); - this._add(this.__getOrganizationsFilter()); - this._add(osparc.data.Roles.createRolesOrgInfo()); + const rolesLayout = osparc.data.Roles.createRolesOrgInfo(); + const orgsFilter = this.__getOrganizationsFilter(); + orgsFilter.setPaddingRight(10); + osparc.data.Roles.replaceSpacerWithWidget(rolesLayout, orgsFilter); + this._add(rolesLayout); + this._add(this.__getOrganizationsList(), { flex: 1 }); diff --git a/services/static-webserver/client/source/class/osparc/desktop/wallets/MembersList.js b/services/static-webserver/client/source/class/osparc/desktop/wallets/MembersList.js index dc27a0cfee3..a55b47e9ec4 100644 --- a/services/static-webserver/client/source/class/osparc/desktop/wallets/MembersList.js +++ b/services/static-webserver/client/source/class/osparc/desktop/wallets/MembersList.js @@ -120,9 +120,6 @@ qx.Class.define("osparc.desktop.wallets.MembersList", { const vBox = this.__memberInvitation = new qx.ui.container.Composite(new qx.ui.layout.VBox(5)); vBox.exclude(); - const label = new qx.ui.basic.Label(this.tr("Select from the list below and click Share")); - vBox.add(label); - const addMemberBtn = new qx.ui.form.Button(this.tr("Add Members...")).set({ appearance: "strong-button", allowGrowX: false diff --git a/services/static-webserver/client/source/class/osparc/filter/TextFilter.js b/services/static-webserver/client/source/class/osparc/filter/TextFilter.js index 4566bb86f5d..7e31f4740af 100644 --- a/services/static-webserver/client/source/class/osparc/filter/TextFilter.js +++ b/services/static-webserver/client/source/class/osparc/filter/TextFilter.js @@ -35,8 +35,7 @@ qx.Class.define("osparc.filter.TextFilter", { allowStretchY: false }); - this.__textField = this.getChildControl("textfield"); - + this.getChildControl("textfield"); this.getChildControl("clearbutton"); this.__attachEventHandlers(); @@ -46,18 +45,23 @@ qx.Class.define("osparc.filter.TextFilter", { appearance: { refine: true, init: "textfilter" - } + }, + + compact: { + check: "Boolean", + init: false, + apply: "__applyCompact", + }, }, members: { - __textField: null, - /** * Function that resets the field and dispatches the update. */ reset: function() { - this.__textField.resetValue(); - this.__textField.fireDataEvent("input", ""); + const textField = this.getChildControl("textfield"); + textField.resetValue(); + textField.fireDataEvent("input", ""); }, _createChildControlImpl: function(id) { @@ -78,7 +82,7 @@ qx.Class.define("osparc.filter.TextFilter", { case "clearbutton": control = new osparc.ui.basic.IconButton("@MaterialIcons/close/12", () => { this.reset(); - this.__textField.focus(); + this.getChildControl("textfield").focus(); }); this._add(control, { right: 0, @@ -89,8 +93,21 @@ qx.Class.define("osparc.filter.TextFilter", { return control || this.base(arguments, id); }, + __applyCompact: function(compact) { + this.set({ + allowStretchX: compact, + allowGrowX: compact, + maxHeight: compact ? 30 : null, + margin: compact ? 0 : null, + }); + + this.getChildControl("textfield").set({ + margin: compact ? 0 : null, + }); + }, + __attachEventHandlers: function() { - this.__textField.addListener("input", evt => { + this.getChildControl("textfield").addListener("input", evt => { this._filterChange(evt.getData().trim().toLowerCase()); }); } diff --git a/services/static-webserver/client/source/class/osparc/po/PreRegistration.js b/services/static-webserver/client/source/class/osparc/po/PreRegistration.js index f2fe853b1df..8a1f0e767df 100644 --- a/services/static-webserver/client/source/class/osparc/po/PreRegistration.js +++ b/services/static-webserver/client/source/class/osparc/po/PreRegistration.js @@ -99,7 +99,7 @@ qx.Class.define("osparc.po.PreRegistration", { return } - osparc.data.Resources.fetch("users", "preRegister", params) + osparc.data.Resources.fetch("poUsers", "preRegister", params) .then(data => { if (data.length) { findingStatus.setValue(this.tr("Pre-Registered as:")); diff --git a/services/static-webserver/client/source/class/osparc/po/Users.js b/services/static-webserver/client/source/class/osparc/po/Users.js index feef74218f1..eb011712b42 100644 --- a/services/static-webserver/client/source/class/osparc/po/Users.js +++ b/services/static-webserver/client/source/class/osparc/po/Users.js @@ -83,7 +83,7 @@ qx.Class.define("osparc.po.Users", { email: userEmail.getValue() } }; - osparc.data.Resources.fetch("users", "search", params) + osparc.data.Resources.fetch("poUsers", "search", params) .then(data => { findingStatus.setValue(data.length + this.tr(" user(s) found")); this.__populateFoundUsersLayout(data); diff --git a/services/static-webserver/client/source/class/osparc/share/AddCollaborators.js b/services/static-webserver/client/source/class/osparc/share/AddCollaborators.js index da0394cd010..788650a1af3 100644 --- a/services/static-webserver/client/source/class/osparc/share/AddCollaborators.js +++ b/services/static-webserver/client/source/class/osparc/share/AddCollaborators.js @@ -51,10 +51,6 @@ qx.Class.define("osparc.share.AddCollaborators", { _createChildControlImpl: function(id) { let control; switch (id) { - case "intro-text": - control = new qx.ui.basic.Label(this.tr("Select from the list below and click Share")); - this._add(control); - break; case "buttons-layout": control = new qx.ui.container.Composite(new qx.ui.layout.HBox()); this._add(control); @@ -89,8 +85,6 @@ qx.Class.define("osparc.share.AddCollaborators", { }, __buildLayout: function() { - this.getChildControl("intro-text"); - const addCollaboratorBtn = this.getChildControl("share-with"); addCollaboratorBtn.addListener("execute", () => { const collaboratorsManager = new osparc.share.NewCollaboratorsManager(this.__serializedDataCopy); diff --git a/services/static-webserver/client/source/class/osparc/share/NewCollaboratorsManager.js b/services/static-webserver/client/source/class/osparc/share/NewCollaboratorsManager.js index fcbe5befff5..ee149c77422 100644 --- a/services/static-webserver/client/source/class/osparc/share/NewCollaboratorsManager.js +++ b/services/static-webserver/client/source/class/osparc/share/NewCollaboratorsManager.js @@ -11,7 +11,7 @@ qx.Class.define("osparc.share.NewCollaboratorsManager", { construct: function(resourceData, showOrganizations = true) { this.base(arguments, "collaboratorsManager", this.tr("Share with")); this.set({ - layout: new qx.ui.layout.VBox(), + layout: new qx.ui.layout.VBox(5), allowMinimize: false, allowMaximize: false, showMinimize: false, @@ -29,8 +29,8 @@ qx.Class.define("osparc.share.NewCollaboratorsManager", { this.__renderLayout(); this.__selectedCollaborators = []; - this.__visibleCollaborators = {}; - this.__reloadCollaborators(); + this.__potentialCollaborators = {}; + this.__reloadPotentialCollaborators(); this.center(); this.open(); @@ -43,34 +43,43 @@ qx.Class.define("osparc.share.NewCollaboratorsManager", { members: { __resourceData: null, __showOrganizations: null, - __introLabel: null, __textFilter: null, + __searchButton: null, __collabButtonsContainer: null, - __orgsButton: null, __shareButton: null, __selectedCollaborators: null, - __visibleCollaborators: null, + __potentialCollaborators: null, getActionButton: function() { return this.__shareButton; }, __renderLayout: function() { - const introText = this.tr("In order to start Sharing with other members, you first need to belong to an Organization."); - const introLabel = this.__introLabel = new qx.ui.basic.Label(introText).set({ + const introLabel = new qx.ui.basic.Label().set({ + value: this.tr("Select users or organizations from the list bellow. Search them if they aren't listed."), rich: true, wrap: true, - visibility: "excluded", - padding: 8 + paddingBottom: 5 }); this.add(introLabel); - const filter = this.__textFilter = new osparc.filter.TextFilter("name", "collaboratorsManager").set({ - allowStretchX: true, - margin: [0, 10, 5, 10] - }); + const toolbar = new qx.ui.container.Composite(new qx.ui.layout.HBox(10).set({ + alignY: "middle", + })); + const filter = this.__textFilter = new osparc.filter.TextFilter("name", "collaboratorsManager"); + filter.setCompact(true); this.addListener("appear", () => filter.getChildControl("textfield").focus()); - this.add(filter); + toolbar.add(filter, { + flex: 1 + }); + const searchButton = this.__searchButton = new osparc.ui.form.FetchButton(this.tr("Search"), "@FontAwesome5Solid/search/12").set({ + maxHeight: 30, + }); + const command = new qx.ui.command.Command("Enter"); + searchButton.setCommand(command); + searchButton.addListener("execute", () => this.__searchUsers(), this); + toolbar.add(searchButton); + this.add(toolbar); const collabButtonsContainer = this.__collabButtonsContainer = new qx.ui.container.Composite(new qx.ui.layout.VBox()); const scrollContainer = new qx.ui.container.Scroll(); @@ -82,13 +91,6 @@ qx.Class.define("osparc.share.NewCollaboratorsManager", { const buttons = new qx.ui.container.Composite(new qx.ui.layout.HBox().set({ alignX: "right" })); - // Quick access for users that still don't belong to any organization - const orgsButton = this.__orgsButton = new qx.ui.form.Button(this.tr("My Organizations...")).set({ - appearance: "form-button", - visibility: "excluded", - }); - orgsButton.addListener("execute", () => osparc.desktop.organizations.OrganizationsWindow.openWindow(), this); - buttons.add(orgsButton); const shareButton = this.__shareButton = new osparc.ui.form.FetchButton(this.tr("Share")).set({ appearance: "form-button", enabled: false, @@ -98,42 +100,57 @@ qx.Class.define("osparc.share.NewCollaboratorsManager", { this.add(buttons); }, - __reloadCollaborators: function() { - let includeProductEveryone = false; + __searchUsers: function() { + const text = this.__textFilter.getChildControl("textfield").getValue(); + this.__searchButton.setFetching(true); + osparc.store.Users.getInstance().searchUsers(text) + .then(users => { + users.forEach(user => user["collabType"] = 2); + this.__addPotentialCollaborators(users); + }) + .catch(err => { + console.error(err); + osparc.FlashMessenger.getInstance().logAs(err.message, "ERROR"); + }) + .finally(() => this.__searchButton.setFetching(false)); + }, + + __showProductEveryone: function() { + let showProductEveryone = false; if (this.__showOrganizations === false) { - includeProductEveryone = false; + showProductEveryone = false; } else if (this.__resourceData && this.__resourceData["resourceType"] === "study") { // studies can't be shared with ProductEveryone - includeProductEveryone = false; + showProductEveryone = false; } else if (this.__resourceData && this.__resourceData["resourceType"] === "template") { // only users with permissions can share templates with ProductEveryone - includeProductEveryone = osparc.data.Permissions.getInstance().canDo("study.everyone.share"); + showProductEveryone = osparc.data.Permissions.getInstance().canDo("study.everyone.share"); } else if (this.__resourceData && this.__resourceData["resourceType"] === "service") { // all users can share services with ProductEveryone - includeProductEveryone = true; + showProductEveryone = true; } - const potentialCollaborators = osparc.store.Groups.getInstance().getPotentialCollaborators(false, includeProductEveryone) - this.__visibleCollaborators = potentialCollaborators; - const anyCollaborator = Object.keys(potentialCollaborators).length; - // tell the user that belonging to an organization is required to start sharing - this.__introLabel.setVisibility(anyCollaborator ? "excluded" : "visible"); - this.__orgsButton.setVisibility(anyCollaborator ? "excluded" : "visible"); - - // or start sharing - this.__textFilter.setVisibility(anyCollaborator ? "visible" : "excluded"); - this.__collabButtonsContainer.setVisibility(anyCollaborator ? "visible" : "excluded"); - this.__shareButton.setVisibility(anyCollaborator ? "visible" : "excluded"); - this.__addEditors(); + return showProductEveryone; + }, + + __reloadPotentialCollaborators: function() { + const includeProductEveryone = this.__showProductEveryone(); + this.__potentialCollaborators = osparc.store.Groups.getInstance().getPotentialCollaborators(false, includeProductEveryone); + const potentialCollaborators = Object.values(this.__potentialCollaborators); + this.__addPotentialCollaborators(potentialCollaborators); }, __collaboratorButton: function(collaborator) { const collaboratorButton = new osparc.filter.CollaboratorToggleButton(collaborator); + collaboratorButton.groupId = collaborator.getGroupId(); collaboratorButton.addListener("changeValue", e => { const selected = e.getData(); if (selected) { this.__selectedCollaborators.push(collaborator.getGroupId()); } else { - this.__selectedCollaborators.remove(collaborator.getGroupId()); + const idx = this.__selectedCollaborators.indexOf(collaborator.getGroupId()); + if (idx > -1) { + this.__selectedCollaborators.splice(idx, 1); + } } this.__shareButton.setEnabled(Boolean(this.__selectedCollaborators.length)); }, this); @@ -141,11 +158,9 @@ qx.Class.define("osparc.share.NewCollaboratorsManager", { return collaboratorButton; }, - __addEditors: function() { - const visibleCollaborators = Object.values(this.__visibleCollaborators); - + __addPotentialCollaborators: function(potentialCollaborators) { // sort them first - visibleCollaborators.sort((a, b) => { + potentialCollaborators.sort((a, b) => { if (a["collabType"] > b["collabType"]) { return 1; } @@ -159,27 +174,35 @@ qx.Class.define("osparc.share.NewCollaboratorsManager", { }); let existingCollabs = []; - if (this.__resourceData && this.__resourceData["accessRights"]) { - // study/template/service/wallet - if (this.__resourceData["resourceType"] === "wallet") { + if (this.__resourceData) { + if (this.__resourceData["groupMembers"] && this.__resourceData["resourceType"] === "organization") { + // organization + existingCollabs = Object.keys(this.__resourceData["groupMembers"]); + } else if (this.__resourceData["accessRights"] && this.__resourceData["resourceType"] === "wallet") { + // wallet // array of objects existingCollabs = this.__resourceData["accessRights"].map(collab => collab["gid"]); - } else { + } else if (this.__resourceData["accessRights"]) { + // study/template/service/ // object existingCollabs = Object.keys(this.__resourceData["accessRights"]); } } const existingCollaborators = existingCollabs.map(c => parseInt(c)); - visibleCollaborators.forEach(visibleCollaborator => { - // do not list the visibleCollaborators that are already collaborators - if (existingCollaborators.includes(visibleCollaborator.getGroupId())) { + potentialCollaborators.forEach(potentialCollaborator => { + // do not list the potentialCollaborators that are already collaborators + if (existingCollaborators.includes(potentialCollaborator.getGroupId())) { + return; + } + // do not list those that were already listed + if (this.__collabButtonsContainer.getChildren().find(c => "groupId" in c && c["groupId"] === potentialCollaborator.getGroupId())) { return; } - if (this.__showOrganizations === false && visibleCollaborator["collabType"] !== 2) { + if (this.__showOrganizations === false && potentialCollaborator["collabType"] !== 2) { return; } - this.__collabButtonsContainer.add(this.__collaboratorButton(visibleCollaborator)); + this.__collabButtonsContainer.add(this.__collaboratorButton(potentialCollaborator)); }); }, diff --git a/services/static-webserver/client/source/class/osparc/store/Groups.js b/services/static-webserver/client/source/class/osparc/store/Groups.js index e954de7aba6..e897d6b4285 100644 --- a/services/static-webserver/client/source/class/osparc/store/Groups.js +++ b/services/static-webserver/client/source/class/osparc/store/Groups.js @@ -45,11 +45,6 @@ qx.Class.define("osparc.store.Groups", { check: "osparc.data.model.Group", init: {} }, - - reachableUsers: { - check: "Object", - init: {} - }, }, events: { @@ -115,8 +110,7 @@ qx.Class.define("osparc.store.Groups", { // reset group's group members group.setGroupMembers({}); orgMembers.forEach(orgMember => { - const user = new osparc.data.model.UserMember(orgMember); - this.__addToUsersCache(user, groupId); + this.__addMemberToCache(orgMember, groupId); }); } }); @@ -126,8 +120,9 @@ qx.Class.define("osparc.store.Groups", { return new Promise(resolve => { this.__fetchGroups() .then(orgs => { - // reset Reachable Users - this.resetReachableUsers(); + // reset Users + const usersStore = osparc.store.Users.getInstance(); + usersStore.resetUsers(); const promises = Object.keys(orgs).map(orgId => this.__fetchGroupMembers(orgId)); Promise.all(promises) .then(() => resolve()) @@ -152,8 +147,9 @@ qx.Class.define("osparc.store.Groups", { allGroupsAndUsers[organization.getGroupId()] = organization; }); - Object.values(this.getReachableUsers()).forEach(reachableUser => { - allGroupsAndUsers[reachableUser.getGroupId()] = reachableUser; + const users = osparc.store.Users.getInstance().getUsers(); + users.forEach(user => { + allGroupsAndUsers[user.getGroupId()] = user; }); return allGroupsAndUsers; @@ -174,9 +170,11 @@ qx.Class.define("osparc.store.Groups", { groupMe["collabType"] = 2; groups.push(groupMe); - Object.values(this.getReachableUsers()).forEach(member => { - member["collabType"] = 2; - groups.push(member); + const usersStore = osparc.store.Users.getInstance(); + const users = usersStore.getUsers(); + users.forEach(user => { + user["collabType"] = 2; + groups.push(user); }); Object.values(this.getOrganizations()).forEach(org => { @@ -202,6 +200,12 @@ qx.Class.define("osparc.store.Groups", { const potentialCollaborators = {}; const orgs = this.getOrganizations(); const productEveryone = this.getEveryoneProductGroup(); + + if (includeProductEveryone && productEveryone) { + productEveryone["collabType"] = 0; + potentialCollaborators[productEveryone.getGroupId()] = productEveryone; + } + Object.values(orgs).forEach(org => { if (org.getAccessRights()["read"]) { // maybe because of migration script, some users have access to the product everyone group @@ -213,20 +217,20 @@ qx.Class.define("osparc.store.Groups", { potentialCollaborators[org.getGroupId()] = org; } }); - const members = this.getReachableUsers(); - for (const gid of Object.keys(members)) { - members[gid]["collabType"] = 2; - potentialCollaborators[gid] = members[gid]; - } + if (includeMe) { const myGroup = this.getGroupMe(); myGroup["collabType"] = 2; potentialCollaborators[myGroup.getGroupId()] = myGroup; } - if (includeProductEveryone && productEveryone) { - productEveryone["collabType"] = 0; - potentialCollaborators[productEveryone.getGroupId()] = productEveryone; - } + + const usersStore = osparc.store.Users.getInstance(); + const users = usersStore.getUsers(); + users.forEach(user => { + user["collabType"] = 2; + potentialCollaborators[user.getGroupId()] = user; + }); + return potentialCollaborators; }, @@ -240,16 +244,18 @@ qx.Class.define("osparc.store.Groups", { getUserByUserId: function(userId) { if (userId) { - const visibleMembers = this.getReachableUsers(); - return Object.values(visibleMembers).find(member => member.getUserId() === userId); + const usersStore = osparc.store.Users.getInstance(); + const users = usersStore.getUsers(); + return users.find(user => user.getUserId() === userId); } return null; }, getUserByGroupId: function(groupId) { if (groupId) { - const visibleMembers = this.getReachableUsers(); - return Object.values(visibleMembers).find(member => member.getGroupId() === groupId); + const usersStore = osparc.store.Users.getInstance(); + const users = usersStore.getUsers(); + return users.find(user => user.getGroupId() === groupId); } return null; }, @@ -419,14 +425,15 @@ qx.Class.define("osparc.store.Groups", { delete this.getOrganizations()[groupId]; }, - __addToUsersCache: function(user, orgId = null) { + __addMemberToCache: function(orgMember, orgId = null) { + const userMember = new osparc.data.model.UserMember(orgMember); if (orgId) { const organization = this.getOrganization(orgId); if (organization) { - organization.addGroupMember(user); + organization.addGroupMember(userMember); } } - this.getReachableUsers()[user.getGroupId()] = user; + osparc.store.Users.getInstance().addUser(orgMember); }, __removeUserFromCache: function(userId, orgId) { diff --git a/services/static-webserver/client/source/class/osparc/store/Users.js b/services/static-webserver/client/source/class/osparc/store/Users.js new file mode 100644 index 00000000000..59da1a2fb9a --- /dev/null +++ b/services/static-webserver/client/source/class/osparc/store/Users.js @@ -0,0 +1,77 @@ +/* ************************************************************************ + + osparc - the simcore frontend + + https://osparc.io + + Copyright: + 2024 IT'IS Foundation, https://itis.swiss + + License: + MIT: https://opensource.org/licenses/MIT + + Authors: + * Odei Maiz (odeimaiz) + +************************************************************************ */ + +qx.Class.define("osparc.store.Users", { + extend: qx.core.Object, + type: "singleton", + + properties: { + users: { + check: "Array", + init: [], + nullable: false, + }, + }, + + members: { + fetchUser: function(groupId) { + const params = { + url: { + gid: groupId + } + }; + return osparc.data.Resources.fetch("users", "get", params) + .then(userData => { + const user = this.addUser(userData[0]); + return user; + }); + }, + + getUser: function(groupId, fetchIfNotFound = true) { + const userFound = this.getUsers().find(user => user.getGroupId() === groupId); + if (userFound) { + return new Promise(resolve => resolve(userFound)); + } else if (fetchIfNotFound) { + return this.fetchUser(groupId); + } + return new Promise(reject => reject()); + }, + + addUser: function(userData) { + const user = new osparc.data.model.User(userData); + const userFound = this.getUsers().find(usr => usr.getGroupId() === user.getGroupId()); + if (!userFound) { + this.getUsers().push(user); + } + return user; + }, + + searchUsers: function(text) { + const params = { + data: { + match: text + } + }; + return osparc.data.Resources.fetch("users", "search", params) + .then(usersData => { + const users = []; + usersData.forEach(userData => users.push(this.addUser(userData))); + return users; + }); + }, + } +}); 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 5e20d12a5ab..2012853da80 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 @@ -611,8 +611,9 @@ paths: get: tags: - groups + - users summary: Get All Group Users - description: Gets users in organization groups + description: Gets users in organization or primary groups operationId: get_all_group_users parameters: - name: gid @@ -633,6 +634,7 @@ paths: post: tags: - groups + - users summary: Add Group User description: Adds a user to an organization group using their username, user ID, or email (subject to privacy settings) @@ -659,6 +661,7 @@ paths: get: tags: - groups + - users summary: Get Group User description: Gets specific user in an organization group operationId: get_group_user @@ -689,6 +692,7 @@ paths: patch: tags: - groups + - users summary: Update Group User description: Updates user (access-rights) to an organization group operationId: update_group_user @@ -725,6 +729,7 @@ paths: delete: tags: - groups + - users summary: Delete Group User description: Removes a user from an organization group operationId: delete_group_user @@ -1133,7 +1138,7 @@ paths: /v0/me: get: tags: - - user + - users summary: Get My Profile operationId: get_my_profile responses: @@ -1145,7 +1150,7 @@ paths: $ref: '#/components/schemas/Envelope_MyProfileGet_' put: tags: - - user + - users summary: Replace My Profile description: Use PATCH instead operationId: replace_my_profile @@ -1161,7 +1166,7 @@ paths: deprecated: true patch: tags: - - user + - users summary: Update My Profile operationId: update_my_profile requestBody: @@ -1176,7 +1181,7 @@ paths: /v0/me/preferences/{preference_id}: patch: tags: - - user + - users summary: Set Frontend Preference operationId: set_frontend_preference parameters: @@ -1198,7 +1203,7 @@ paths: /v0/me/tokens: get: tags: - - user + - users summary: List Tokens operationId: list_tokens responses: @@ -1210,7 +1215,7 @@ paths: $ref: '#/components/schemas/Envelope_list_MyTokenGet__' post: tags: - - user + - users summary: Create Token operationId: create_token requestBody: @@ -1229,7 +1234,7 @@ paths: /v0/me/tokens/{service}: get: tags: - - user + - users summary: Get Token operationId: get_token parameters: @@ -1248,7 +1253,7 @@ paths: $ref: '#/components/schemas/Envelope_MyTokenGet_' delete: tags: - - user + - users summary: Delete Token operationId: delete_token parameters: @@ -1264,7 +1269,7 @@ paths: /v0/me/notifications: get: tags: - - user + - users summary: List User Notifications operationId: list_user_notifications responses: @@ -1276,7 +1281,7 @@ paths: $ref: '#/components/schemas/Envelope_list_UserNotification__' post: tags: - - user + - users summary: Create User Notification operationId: create_user_notification requestBody: @@ -1291,7 +1296,7 @@ paths: /v0/me/notifications/{notification_id}: patch: tags: - - user + - users summary: Mark Notification As Read operationId: mark_notification_as_read parameters: @@ -1313,7 +1318,7 @@ paths: /v0/me/permissions: get: tags: - - user + - users summary: List User Permissions operationId: list_user_permissions responses: @@ -1324,12 +1329,33 @@ paths: schema: $ref: '#/components/schemas/Envelope_list_MyPermissionGet__' /v0/users:search: - get: + post: tags: - - user - - po + - users summary: Search Users + description: Search among users who are publicly visible to the caller (i.e., + me) based on their privacy settings. operationId: search_users + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UsersSearch' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope_list_UserGet__' + /v0/admin/users:search: + get: + tags: + - users + - admin + summary: Search Users For Admin + operationId: search_users_for_admin parameters: - name: email in: query @@ -1345,14 +1371,14 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Envelope_list_UserGet__' - /v0/users:pre-register: + $ref: '#/components/schemas/Envelope_list_UserForAdminGet__' + /v0/admin/users:pre-register: post: tags: - - user - - po - summary: Pre Register User - operationId: pre_register_user + - users + - admin + summary: Pre Register User For Admin + operationId: pre_register_user_for_admin requestBody: content: application/json: @@ -1365,7 +1391,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Envelope_UserGet_' + $ref: '#/components/schemas/Envelope_UserForAdminGet_' /v0/wallets: get: tags: @@ -3252,6 +3278,121 @@ paths: schema: $ref: '#/components/schemas/EnvelopedError' description: Bad Request + /v0/wallets/{wallet_id}/licensed-items-checkouts: + get: + tags: + - licenses + - wallets + summary: List Licensed Item Checkouts For Wallet + operationId: list_licensed_item_checkouts_for_wallet + parameters: + - name: wallet_id + in: path + required: true + schema: + type: integer + exclusiveMinimum: true + title: Wallet Id + minimum: 0 + - name: order_by + in: query + required: false + schema: + type: string + contentMediaType: application/json + contentSchema: {} + default: '{"field":"started_at","direction":"desc"}' + title: Order By + - name: limit + in: query + required: false + schema: + type: integer + default: 20 + title: Limit + - name: offset + in: query + required: false + schema: + type: integer + default: 0 + title: Offset + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Page_LicensedItemPurchaseGet_' + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Not Found + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Forbidden + '402': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Payment Required + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Bad Request + /v0/licensed-items-checkouts/{licensed_item_checkout_id}: + get: + tags: + - licenses + summary: Get Licensed Item Checkout + operationId: get_licensed_item_checkout + parameters: + - name: licensed_item_checkout_id + in: path + required: true + schema: + type: string + format: uuid + title: Licensed Item Checkout Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope_LicensedItemPurchaseGet_' + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Not Found + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Forbidden + '402': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Payment Required + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Bad Request /v0/projects/{project_uuid}/checkpoint/{ref_id}/iterations: get: tags: @@ -8556,11 +8697,11 @@ components: title: Error type: object title: Envelope[Union[WalletGet, NoneType]] - Envelope_UserGet_: + Envelope_UserForAdminGet_: properties: data: anyOf: - - $ref: '#/components/schemas/UserGet' + - $ref: '#/components/schemas/UserForAdminGet' - type: 'null' error: anyOf: @@ -8568,7 +8709,7 @@ components: - type: 'null' title: Error type: object - title: Envelope[UserGet] + title: Envelope[UserForAdminGet] Envelope_WalletGetWithAvailableCredits_: properties: data: @@ -9202,6 +9343,22 @@ components: title: Error type: object title: Envelope[list[TaskGet]] + Envelope_list_UserForAdminGet__: + properties: + data: + anyOf: + - items: + $ref: '#/components/schemas/UserForAdminGet' + type: array + - type: 'null' + title: Data + error: + anyOf: + - {} + - type: 'null' + title: Error + type: object + title: Envelope[list[UserForAdminGet]] Envelope_list_UserGet__: properties: data: @@ -10166,11 +10323,14 @@ components: description: the user gravatar id hash deprecated: true accessRights: - $ref: '#/components/schemas/GroupAccessRights' + anyOf: + - $ref: '#/components/schemas/GroupAccessRights' + - type: 'null' + description: If group is standard, these are these are the access rights + of the user to it.None if primary group. type: object required: - userName - - accessRights title: GroupUserGet example: accessRights: @@ -14356,7 +14516,7 @@ components: - number - e_tag title: UploadedPart - UserGet: + UserForAdminGet: properties: firstName: anyOf: @@ -14447,6 +14607,45 @@ components: - country - registered - status + title: UserForAdminGet + UserGet: + properties: + userId: + type: integer + exclusiveMinimum: true + title: Userid + minimum: 0 + groupId: + type: integer + exclusiveMinimum: true + title: Groupid + minimum: 0 + userName: + type: string + maxLength: 100 + minLength: 1 + title: Username + firstName: + anyOf: + - type: string + - type: 'null' + title: Firstname + lastName: + anyOf: + - type: string + - type: 'null' + title: Lastname + email: + anyOf: + - type: string + format: email + - type: 'null' + title: Email + type: object + required: + - userId + - groupId + - userName title: UserGet UserNotification: properties: @@ -14578,6 +14777,25 @@ components: - BANNED - DELETED title: UserStatus + UsersSearch: + properties: + match: + type: string + maxLength: 80 + minLength: 1 + title: Match + description: Search string to match with usernames and public profiles (e.g. + emails, first/last name) + limit: + type: integer + maximum: 50 + minimum: 1 + title: Limit + default: 10 + type: object + required: + - match + title: UsersSearch Viewer: properties: title: diff --git a/services/web/server/src/simcore_service_webserver/folders/_folders_db.py b/services/web/server/src/simcore_service_webserver/folders/_folders_db.py index 38ad97f44ed..32ff9e4d3a5 100644 --- a/services/web/server/src/simcore_service_webserver/folders/_folders_db.py +++ b/services/web/server/src/simcore_service_webserver/folders/_folders_db.py @@ -10,7 +10,7 @@ import sqlalchemy as sa from aiohttp import web -from common_library.unset import UnSet, as_dict_exclude_unset +from common_library.exclude import UnSet, as_dict_exclude_unset from models_library.folders import ( FolderDB, FolderID, diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_repository.py b/services/web/server/src/simcore_service_webserver/groups/_groups_repository.py index 7ba1b3fd25a..0d8b24b83fe 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_repository.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_repository.py @@ -1,9 +1,11 @@ import re from copy import deepcopy +from typing import Literal import sqlalchemy as sa from aiohttp import web from common_library.groups_enums import GroupType +from common_library.users_enums import UserRole from models_library.basic_types import IDStr from models_library.groups import ( AccessRightsDict, @@ -23,6 +25,7 @@ pass_or_acquire_connection, transaction_context, ) +from simcore_postgres_database.utils_users import is_public, visible_user_profile_cols from sqlalchemy import and_ from sqlalchemy.dialects.postgresql import insert from sqlalchemy.engine.row import Row @@ -89,31 +92,39 @@ def _to_group_info_tuple(group: Row) -> GroupInfoTuple: def _check_group_permissions( - group: Row, user_id: int, gid: int, permission: str + group: Row, + caller_id: UserID, + group_id: GroupID, + permission: Literal["read", "write", "delete"], ) -> None: if not group.access_rights[permission]: raise UserInsufficientRightsError( - user_id=user_id, gid=gid, permission=permission + user_id=caller_id, gid=group_id, permission=permission ) async def _get_group_and_access_rights_or_raise( conn: AsyncConnection, *, - user_id: UserID, - gid: GroupID, + caller_id: UserID, + group_id: GroupID, + permission: Literal["read", "write", "delete"] | None, ) -> Row: - result = await conn.stream( + result = await conn.execute( sa.select( *_GROUP_COLUMNS, user_to_groups.c.access_rights, ) - .select_from(user_to_groups.join(groups, user_to_groups.c.gid == groups.c.gid)) - .where((user_to_groups.c.uid == user_id) & (user_to_groups.c.gid == gid)) + .select_from(groups.join(user_to_groups, user_to_groups.c.gid == groups.c.gid)) + .where((user_to_groups.c.uid == caller_id) & (user_to_groups.c.gid == group_id)) ) - row = await result.fetchone() + row = result.first() if not row: - raise GroupNotFoundError(gid=gid) + raise GroupNotFoundError(gid=group_id) + + if permission: + _check_group_permissions(row, caller_id, group_id, permission) + return row @@ -129,8 +140,10 @@ async def get_group_from_gid( group_id: GroupID, ) -> Group | None: async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: - row = await conn.stream(groups.select().where(groups.c.gid == group_id)) - result = await row.first() + row = await conn.execute( + sa.select(*_GROUP_COLUMNS).where(groups.c.gid == group_id) + ) + result = row.first() if result: return Group.model_validate(result, from_attributes=True) return None @@ -262,10 +275,8 @@ async def get_user_group( """ async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: row = await _get_group_and_access_rights_or_raise( - conn, user_id=user_id, gid=group_id + conn, caller_id=user_id, group_id=group_id, permission="read" ) - _check_group_permissions(row, user_id, group_id, "read") - group, access_rights = _to_group_info_tuple(row) return group, access_rights @@ -283,7 +294,10 @@ async def get_product_group_for_user( """ async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: row = await _get_group_and_access_rights_or_raise( - conn, user_id=user_id, gid=product_gid + conn, + caller_id=user_id, + group_id=product_gid, + permission=None, ) group, access_rights = _to_group_info_tuple(row) return group, access_rights @@ -302,10 +316,12 @@ async def create_standard_group( async with transaction_context(get_asyncpg_engine(app), connection) as conn: user = await conn.scalar( - sa.select(users.c.primary_gid).where(users.c.id == user_id) + sa.select( + users.c.primary_gid, + ).where(users.c.id == user_id) ) if not user: - raise UserNotFoundError(uid=user_id) + raise UserNotFoundError(user_id=user_id) result = await conn.stream( # pylint: disable=no-value-for-parameter @@ -348,17 +364,17 @@ async def update_standard_group( async with transaction_context(get_asyncpg_engine(app), connection) as conn: row = await _get_group_and_access_rights_or_raise( - conn, user_id=user_id, gid=group_id + conn, caller_id=user_id, group_id=group_id, permission="write" ) assert row.gid == group_id # nosec - _check_group_permissions(row, user_id, group_id, "write") + # NOTE: update does not include access-rights access_rights = AccessRightsDict(**row.access_rights) # type: ignore[typeddict-item] result = await conn.stream( # pylint: disable=no-value-for-parameter groups.update() .values(**values) - .where((groups.c.gid == row.gid) & (groups.c.type == GroupType.STANDARD)) + .where((groups.c.gid == group_id) & (groups.c.type == GroupType.STANDARD)) .returning(*_GROUP_COLUMNS) ) row = await result.fetchone() @@ -376,15 +392,14 @@ async def delete_standard_group( group_id: GroupID, ) -> None: async with transaction_context(get_asyncpg_engine(app), connection) as conn: - group = await _get_group_and_access_rights_or_raise( - conn, user_id=user_id, gid=group_id + await _get_group_and_access_rights_or_raise( + conn, caller_id=user_id, group_id=group_id, permission="delete" ) - _check_group_permissions(group, user_id, group_id, "delete") await conn.execute( # pylint: disable=no-value-for-parameter groups.delete().where( - (groups.c.gid == group.gid) & (groups.c.type == GroupType.STANDARD) + (groups.c.gid == group_id) & (groups.c.type == GroupType.STANDARD) ) ) @@ -398,7 +413,7 @@ async def get_user_from_email( app: web.Application, connection: AsyncConnection | None = None, *, - caller_user_id: UserID, + caller_id: UserID, email: str, ) -> Row: """ @@ -410,10 +425,7 @@ async def get_user_from_email( result = await conn.stream( sa.select(users.c.id).where( (users.c.email == email) - & ( - users.c.privacy_hide_email.is_(False) - | (users.c.id == caller_user_id) - ) + & is_public(users.c.privacy_hide_email, caller_id=caller_id) ) ) user = await result.fetchone() @@ -427,48 +439,28 @@ async def get_user_from_email( # -def _group_user_cols(caller_user_id: int): +def _group_user_cols(caller_id: UserID): return ( users.c.id, users.c.name, - # privacy settings - sa.case( - ( - users.c.privacy_hide_email.is_(True) & (users.c.id != caller_user_id), - None, - ), - else_=users.c.email, - ).label("email"), - sa.case( - ( - users.c.privacy_hide_fullname.is_(True) - & (users.c.id != caller_user_id), - None, - ), - else_=users.c.first_name, - ).label("first_name"), - sa.case( - ( - users.c.privacy_hide_fullname.is_(True) - & (users.c.id != caller_user_id), - None, - ), - else_=users.c.last_name, - ).label("last_name"), + *visible_user_profile_cols(caller_id), users.c.primary_gid, ) -async def _get_user_in_group( - conn: AsyncConnection, *, caller_user_id, group_id: GroupID, user_id: int +async def _get_user_in_group_or_raise( + conn: AsyncConnection, *, caller_id: UserID, group_id: GroupID, user_id: UserID ) -> Row: - # now get the user + # NOTE: that the caller_id might be different that the target user_id result = await conn.stream( - sa.select(*_group_user_cols(caller_user_id), user_to_groups.c.access_rights) + sa.select( + *_group_user_cols(caller_id), + user_to_groups.c.access_rights, + ) .select_from( users.join(user_to_groups, users.c.id == user_to_groups.c.uid), ) - .where(and_(user_to_groups.c.gid == group_id, users.c.id == user_id)) + .where((user_to_groups.c.gid == group_id) & (users.c.id == user_id)) ) row = await result.fetchone() if not row: @@ -480,49 +472,82 @@ async def list_users_in_group( app: web.Application, connection: AsyncConnection | None = None, *, - user_id: UserID, + caller_id: UserID, group_id: GroupID, ) -> list[GroupMember]: async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: - # first check if the group exists - group = await _get_group_and_access_rights_or_raise( - conn, user_id=user_id, gid=group_id - ) - _check_group_permissions(group, user_id, group_id, "read") - - # now get the list + # GET GROUP & caller access-rights (if non PRIMARY) query = ( sa.select( - *_group_user_cols(user_id), + *_GROUP_COLUMNS, user_to_groups.c.access_rights, ) - .select_from(users.join(user_to_groups)) - .where(user_to_groups.c.gid == group_id) + .select_from( + groups.join( + user_to_groups, user_to_groups.c.gid == groups.c.gid, isouter=True + ).join(users, users.c.id == user_to_groups.c.uid) + ) + .where( + (user_to_groups.c.gid == group_id) + & ( + (user_to_groups.c.uid == caller_id) + | ( + (groups.c.type == GroupType.PRIMARY) + & users.c.role.in_([r for r in UserRole if r > UserRole.GUEST]) + ) + ) + ) ) - result = await conn.stream(query) - return [GroupMember.model_validate(row) async for row in result] + result = await conn.execute(query) + group_row = result.first() + if not group_row: + raise GroupNotFoundError(gid=group_id) + + # Drop access-rights if primary group + if group_row.type == GroupType.PRIMARY: + query = sa.select( + *_group_user_cols(caller_id), + ) + else: + _check_group_permissions( + group_row, caller_id=caller_id, group_id=group_id, permission="read" + ) + query = sa.select( + *_group_user_cols(caller_id), + user_to_groups.c.access_rights, + ) + + # GET users + query = query.select_from(users.join(user_to_groups, isouter=True)).where( + user_to_groups.c.gid == group_id + ) + + aresult = await conn.stream(query) + return [ + GroupMember.model_validate(row, from_attributes=True) + async for row in aresult + ] async def get_user_in_group( app: web.Application, connection: AsyncConnection | None = None, *, - user_id: UserID, + caller_id: UserID, group_id: GroupID, the_user_id_in_group: int, ) -> GroupMember: async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: # first check if the group exists - group = await _get_group_and_access_rights_or_raise( - conn, user_id=user_id, gid=group_id + await _get_group_and_access_rights_or_raise( + conn, caller_id=caller_id, group_id=group_id, permission="read" ) - _check_group_permissions(group, user_id, group_id, "read") # get the user with its permissions - the_user = await _get_user_in_group( + the_user = await _get_user_in_group_or_raise( conn, - caller_user_id=user_id, + caller_id=caller_id, group_id=group_id, user_id=the_user_id_in_group, ) @@ -533,7 +558,7 @@ async def update_user_in_group( app: web.Application, connection: AsyncConnection | None = None, *, - user_id: UserID, + caller_id: UserID, group_id: GroupID, the_user_id_in_group: UserID, access_rights: AccessRightsDict, @@ -545,15 +570,14 @@ async def update_user_in_group( async with transaction_context(get_asyncpg_engine(app), connection) as conn: # first check if the group exists - group = await _get_group_and_access_rights_or_raise( - conn, user_id=user_id, gid=group_id + await _get_group_and_access_rights_or_raise( + conn, caller_id=caller_id, group_id=group_id, permission="write" ) - _check_group_permissions(group, user_id, group_id, "write") # now check the user exists - the_user = await _get_user_in_group( + the_user = await _get_user_in_group_or_raise( conn, - caller_user_id=user_id, + caller_id=caller_id, group_id=group_id, user_id=the_user_id_in_group, ) @@ -580,21 +604,20 @@ async def delete_user_from_group( app: web.Application, connection: AsyncConnection | None = None, *, - user_id: UserID, + caller_id: UserID, group_id: GroupID, the_user_id_in_group: UserID, ) -> None: async with transaction_context(get_asyncpg_engine(app), connection) as conn: # first check if the group exists - group = await _get_group_and_access_rights_or_raise( - conn, user_id=user_id, gid=group_id + await _get_group_and_access_rights_or_raise( + conn, caller_id=caller_id, group_id=group_id, permission="write" ) - _check_group_permissions(group, user_id, group_id, "write") # check the user exists - await _get_user_in_group( + await _get_user_in_group_or_raise( conn, - caller_user_id=user_id, + caller_id=caller_id, group_id=group_id, user_id=the_user_id_in_group, ) @@ -638,7 +661,7 @@ async def add_new_user_in_group( app: web.Application, connection: AsyncConnection | None = None, *, - user_id: UserID, + caller_id: UserID, group_id: GroupID, # either user_id or user_name new_user_id: UserID | None = None, @@ -650,10 +673,9 @@ async def add_new_user_in_group( """ async with transaction_context(get_asyncpg_engine(app), connection) as conn: # first check if the group exists - group = await _get_group_and_access_rights_or_raise( - conn, user_id=user_id, gid=group_id + await _get_group_and_access_rights_or_raise( + conn, caller_id=caller_id, group_id=group_id, permission="write" ) - _check_group_permissions(group, user_id, group_id, "write") query = sa.select(users.c.id) if new_user_id is not None: @@ -678,20 +700,23 @@ async def add_new_user_in_group( await conn.execute( # pylint: disable=no-value-for-parameter user_to_groups.insert().values( - uid=new_user_id, gid=group.gid, access_rights=user_access_rights + uid=new_user_id, gid=group_id, access_rights=user_access_rights ) ) except UniqueViolation as exc: raise UserAlreadyInGroupError( uid=new_user_id, gid=group_id, - user_id=user_id, + user_id=caller_id, access_rights=access_rights, ) from exc async def auto_add_user_to_groups( - app: web.Application, connection: AsyncConnection | None = None, *, user: dict + app: web.Application, + connection: AsyncConnection | None = None, + *, + user: dict, ) -> None: user_id: UserID = user["id"] diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_rest.py b/services/web/server/src/simcore_service_webserver/groups/_groups_rest.py index 3f5f778a7bc..32b5e507382 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_rest.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_rest.py @@ -164,7 +164,7 @@ async def delete_group(request: web.Request): @permission_required("groups.*") @handle_plugin_requests_exceptions async def get_all_group_users(request: web.Request): - """Gets users in organization groups""" + """Gets users in organization or primary groups""" req_ctx = GroupsRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(GroupsPathParams, request) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_service.py b/services/web/server/src/simcore_service_webserver/groups/_groups_service.py index 9bb5587759b..f53a7be17c6 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_service.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_service.py @@ -158,7 +158,7 @@ async def list_group_members( app: web.Application, user_id: UserID, group_id: GroupID ) -> list[GroupMember]: return await _groups_repository.list_users_in_group( - app, user_id=user_id, group_id=group_id + app, caller_id=user_id, group_id=group_id ) @@ -171,7 +171,7 @@ async def get_group_member( return await _groups_repository.get_user_in_group( app, - user_id=user_id, + caller_id=user_id, group_id=group_id, the_user_id_in_group=the_user_id_in_group, ) @@ -186,7 +186,7 @@ async def update_group_member( ) -> GroupMember: return await _groups_repository.update_user_in_group( app, - user_id=user_id, + caller_id=user_id, group_id=group_id, the_user_id_in_group=the_user_id_in_group, access_rights=access_rights, @@ -201,7 +201,7 @@ async def delete_group_member( ) -> None: return await _groups_repository.delete_user_from_group( app, - user_id=user_id, + caller_id=user_id, group_id=group_id, the_user_id_in_group=the_user_id_in_group, ) @@ -261,13 +261,13 @@ async def add_user_in_group( if new_by_user_email: user = await _groups_repository.get_user_from_email( - app, email=new_by_user_email, caller_user_id=user_id + app, email=new_by_user_email, caller_id=user_id ) new_by_user_id = user.id return await _groups_repository.add_new_user_in_group( app, - user_id=user_id, + caller_id=user_id, group_id=group_id, new_user_id=new_by_user_id, new_user_name=new_by_user_name, diff --git a/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_checkouts_models.py b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_checkouts_models.py new file mode 100644 index 00000000000..43d6a290d6f --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_checkouts_models.py @@ -0,0 +1,56 @@ +from datetime import datetime +from typing import NamedTuple + +from models_library.basic_types import IDStr +from models_library.licensed_items import LicensedItemID +from models_library.products import ProductName +from models_library.resource_tracker_licensed_items_checkouts import ( + LicensedItemCheckoutID, +) +from models_library.rest_base import RequestParameters, StrictRequestParameters +from models_library.rest_ordering import ( + OrderBy, + OrderDirection, + create_ordering_query_model_class, +) +from models_library.rest_pagination import PageQueryParameters +from models_library.users import UserID +from models_library.wallets import WalletID +from pydantic import BaseModel, PositiveInt + + +class LicensedItemCheckoutGet(BaseModel): + licensed_item_checkout_id: LicensedItemCheckoutID + licensed_item_id: LicensedItemID + wallet_id: WalletID + user_id: UserID + product_name: ProductName + started_at: datetime + stopped_at: datetime | None + num_of_seats: int + + +class LicensedItemCheckoutGetPage(NamedTuple): + items: list[LicensedItemCheckoutGet] + total: PositiveInt + + +class LicensedItemCheckoutPathParams(StrictRequestParameters): + licensed_item_checkout_id: LicensedItemCheckoutID + + +_LicensedItemsCheckoutsListOrderQueryParams: type[ + RequestParameters +] = create_ordering_query_model_class( + ordering_fields={ + "started_at", + }, + default=OrderBy(field=IDStr("started_at"), direction=OrderDirection.DESC), +) + + +class LicensedItemsCheckoutsListQueryParams( + PageQueryParameters, + _LicensedItemsCheckoutsListOrderQueryParams, # type: ignore[misc, valid-type] +): + ... diff --git a/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_checkouts_rest.py b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_checkouts_rest.py new file mode 100644 index 00000000000..1a9c7285d0a --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_checkouts_rest.py @@ -0,0 +1,129 @@ +import logging + +from aiohttp import web +from models_library.api_schemas_webserver.licensed_items_checkouts import ( + LicensedItemCheckoutRestGet, + LicensedItemCheckoutRestGetPage, +) +from models_library.rest_ordering import OrderBy +from models_library.rest_pagination import Page +from models_library.rest_pagination_utils import paginate_data +from servicelib.aiohttp.requests_validation import ( + parse_request_path_parameters_as, + parse_request_query_parameters_as, +) +from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON +from servicelib.rest_constants import RESPONSE_MODEL_POLICY + +from .._meta import API_VTAG as VTAG +from ..login.decorators import login_required +from ..security.decorators import permission_required +from ..utils_aiohttp import envelope_json_response +from ..wallets._handlers import WalletsPathParams +from . import _licensed_items_checkouts_service +from ._exceptions_handlers import handle_plugin_requests_exceptions +from ._licensed_items_checkouts_models import ( + LicensedItemCheckoutGet, + LicensedItemCheckoutGetPage, + LicensedItemCheckoutPathParams, +) +from ._models import LicensedItemsPurchasesListQueryParams, LicensedItemsRequestContext + +_logger = logging.getLogger(__name__) + + +routes = web.RouteTableDef() + + +@routes.get( + f"/{VTAG}/licensed-items-checkouts/{{licensed_item_checkout_id}}", + name="get_licensed_item_checkout", +) +@login_required +@permission_required("catalog/licensed-items.*") +@handle_plugin_requests_exceptions +async def get_licensed_item_checkout(request: web.Request): + req_ctx = LicensedItemsRequestContext.model_validate(request) + path_params = parse_request_path_parameters_as( + LicensedItemCheckoutPathParams, request + ) + + checkout_item: LicensedItemCheckoutGet = ( + await _licensed_items_checkouts_service.get_licensed_item_checkout( + app=request.app, + product_name=req_ctx.product_name, + user_id=req_ctx.user_id, + licensed_item_checkout_id=path_params.licensed_item_checkout_id, + ) + ) + + output = LicensedItemCheckoutRestGet.model_construct( + licensed_item_checkout_id=checkout_item.licensed_item_checkout_id, + licensed_item_id=checkout_item.licensed_item_id, + wallet_id=checkout_item.wallet_id, + user_id=checkout_item.user_id, + product_name=checkout_item.product_name, + started_at=checkout_item.started_at, + stopped_at=checkout_item.stopped_at, + num_of_seats=checkout_item.num_of_seats, + ) + + return envelope_json_response(output) + + +@routes.get( + f"/{VTAG}/wallets/{{wallet_id}}/licensed-items-checkouts", + name="list_licensed_item_checkouts_for_wallet", +) +@login_required +@permission_required("catalog/licensed-items.*") +@handle_plugin_requests_exceptions +async def list_licensed_item_checkouts_for_wallet(request: web.Request): + req_ctx = LicensedItemsRequestContext.model_validate(request) + path_params = parse_request_path_parameters_as(WalletsPathParams, request) + query_params: LicensedItemsPurchasesListQueryParams = ( + parse_request_query_parameters_as( + LicensedItemsPurchasesListQueryParams, request + ) + ) + + result: LicensedItemCheckoutGetPage = await _licensed_items_checkouts_service.list_licensed_items_checkouts_for_wallet( + app=request.app, + product_name=req_ctx.product_name, + user_id=req_ctx.user_id, + wallet_id=path_params.wallet_id, + offset=query_params.offset, + limit=query_params.limit, + order_by=OrderBy.model_construct(**query_params.order_by.model_dump()), + ) + + get_page = LicensedItemCheckoutRestGetPage( + total=result.total, + items=[ + LicensedItemCheckoutRestGet.model_construct( + licensed_item_checkout_id=checkout_item.licensed_item_checkout_id, + licensed_item_id=checkout_item.licensed_item_id, + wallet_id=checkout_item.wallet_id, + user_id=checkout_item.user_id, + product_name=checkout_item.product_name, + started_at=checkout_item.started_at, + stopped_at=checkout_item.stopped_at, + num_of_seats=checkout_item.num_of_seats, + ) + for checkout_item in result.items + ], + ) + + page = Page[LicensedItemCheckoutRestGet].model_validate( + paginate_data( + chunk=get_page.items, + request_url=request.url, + total=get_page.total, + limit=query_params.limit, + offset=query_params.offset, + ) + ) + return web.Response( + text=page.model_dump_json(**RESPONSE_MODEL_POLICY), + content_type=MIMETYPE_APPLICATION_JSON, + ) diff --git a/services/web/server/src/simcore_service_webserver/licenses/_licensed_checkouts_api.py b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_checkouts_service.py similarity index 55% rename from services/web/server/src/simcore_service_webserver/licenses/_licensed_checkouts_api.py rename to services/web/server/src/simcore_service_webserver/licenses/_licensed_items_checkouts_service.py index ba140d565d5..87a8aaf14c5 100644 --- a/services/web/server/src/simcore_service_webserver/licenses/_licensed_checkouts_api.py +++ b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_checkouts_service.py @@ -2,14 +2,12 @@ from models_library.api_schemas_resource_usage_tracker import ( licensed_items_checkouts as rut_licensed_items_checkouts, ) -from models_library.api_schemas_webserver import ( - licensed_items_checkouts as webserver_licensed_items_checkouts, -) from models_library.licensed_items import LicensedItemID from models_library.products import ProductName from models_library.resource_tracker_licensed_items_checkouts import ( LicensedItemCheckoutID, ) +from models_library.rest_ordering import OrderBy from models_library.services_types import ServiceRunID from models_library.users import UserID from models_library.wallets import WalletID @@ -20,6 +18,94 @@ from ..rabbitmq import get_rabbitmq_rpc_client from ..users.api import get_user from ..wallets.api import get_wallet_by_user +from ._licensed_items_checkouts_models import ( + LicensedItemCheckoutGet, + LicensedItemCheckoutGetPage, +) + + +async def list_licensed_items_checkouts_for_wallet( + app: web.Application, + *, + # access context + product_name: ProductName, + user_id: UserID, + wallet_id: WalletID, + offset: int, + limit: int, + order_by: OrderBy, +) -> LicensedItemCheckoutGetPage: + # Check whether user has access to the wallet + await get_wallet_by_user( + app, + user_id=user_id, + wallet_id=wallet_id, + product_name=product_name, + ) + + rpc_client = get_rabbitmq_rpc_client(app) + + result = await licensed_items_checkouts.get_licensed_items_checkouts_page( + rpc_client, + product_name=product_name, + filter_wallet_id=wallet_id, + offset=offset, + limit=limit, + order_by=order_by, + ) + + return LicensedItemCheckoutGetPage( + total=result.total, + items=[ + LicensedItemCheckoutGet.model_construct( + licensed_item_checkout_id=checkout_item.licensed_item_checkout_id, + licensed_item_id=checkout_item.licensed_item_id, + wallet_id=checkout_item.wallet_id, + user_id=checkout_item.user_id, + product_name=checkout_item.product_name, + started_at=checkout_item.started_at, + stopped_at=checkout_item.stopped_at, + num_of_seats=checkout_item.num_of_seats, + ) + for checkout_item in result.items + ], + ) + + +async def get_licensed_item_checkout( + app: web.Application, + *, + # access context + product_name: ProductName, + user_id: UserID, + licensed_item_checkout_id: LicensedItemCheckoutID, +) -> LicensedItemCheckoutGet: + rpc_client = get_rabbitmq_rpc_client(app) + + checkout_item = await licensed_items_checkouts.get_licensed_item_checkout( + rpc_client, + product_name=product_name, + licensed_item_checkout_id=licensed_item_checkout_id, + ) + + # Check whether user has access to the wallet + await get_wallet_by_user( + app, + user_id=user_id, + wallet_id=checkout_item.wallet_id, + product_name=product_name, + ) + + return LicensedItemCheckoutGet.model_construct( + licensed_item_checkout_id=checkout_item.licensed_item_checkout_id, + licensed_item_id=checkout_item.licensed_item_id, + wallet_id=checkout_item.wallet_id, + user_id=checkout_item.user_id, + product_name=checkout_item.product_name, + started_at=checkout_item.started_at, + stopped_at=checkout_item.stopped_at, + num_of_seats=checkout_item.num_of_seats, + ) async def checkout_licensed_item_for_wallet( @@ -33,7 +119,7 @@ async def checkout_licensed_item_for_wallet( licensed_item_id: LicensedItemID, num_of_seats: int, service_run_id: ServiceRunID, -) -> webserver_licensed_items_checkouts.LicensedItemCheckoutGet: +) -> LicensedItemCheckoutGet: # Check whether user has access to the wallet await get_wallet_by_user( app, @@ -58,7 +144,7 @@ async def checkout_licensed_item_for_wallet( ) ) - return webserver_licensed_items_checkouts.LicensedItemCheckoutGet( + return LicensedItemCheckoutGet.model_construct( licensed_item_checkout_id=licensed_item_get.licensed_item_checkout_id, licensed_item_id=licensed_item_get.licensed_item_id, wallet_id=licensed_item_get.wallet_id, @@ -78,7 +164,7 @@ async def release_licensed_item_for_wallet( user_id: UserID, # release args licensed_item_checkout_id: LicensedItemCheckoutID, -) -> webserver_licensed_items_checkouts.LicensedItemCheckoutGet: +) -> LicensedItemCheckoutGet: rpc_client = get_rabbitmq_rpc_client(app) checkout_item = await licensed_items_checkouts.get_licensed_item_checkout( @@ -103,7 +189,7 @@ async def release_licensed_item_for_wallet( ) ) - return webserver_licensed_items_checkouts.LicensedItemCheckoutGet( + return LicensedItemCheckoutGet.model_construct( licensed_item_checkout_id=licensed_item_get.licensed_item_checkout_id, licensed_item_id=licensed_item_get.licensed_item_id, wallet_id=licensed_item_get.wallet_id, diff --git a/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_purchases_handlers.py b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_purchases_rest.py similarity index 94% rename from services/web/server/src/simcore_service_webserver/licenses/_licensed_items_purchases_handlers.py rename to services/web/server/src/simcore_service_webserver/licenses/_licensed_items_purchases_rest.py index 95f48ebbd0e..5ae0738ebe1 100644 --- a/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_purchases_handlers.py +++ b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_purchases_rest.py @@ -20,7 +20,7 @@ from ..security.decorators import permission_required from ..utils_aiohttp import envelope_json_response from ..wallets._handlers import WalletsPathParams -from . import _licensed_items_purchases_api +from . import _licensed_items_purchases_service from ._exceptions_handlers import handle_plugin_requests_exceptions from ._models import ( LicensedItemsPurchasesListQueryParams, @@ -47,7 +47,7 @@ async def get_licensed_item_purchase(request: web.Request): ) licensed_item_purchase_get: LicensedItemPurchaseGet = ( - await _licensed_items_purchases_api.get_licensed_item_purchase( + await _licensed_items_purchases_service.get_licensed_item_purchase( app=request.app, product_name=req_ctx.product_name, user_id=req_ctx.user_id, @@ -75,7 +75,7 @@ async def list_wallet_licensed_items_purchases(request: web.Request): ) licensed_item_purchase_get_page: LicensedItemPurchaseGetPage = ( - await _licensed_items_purchases_api.list_licensed_items_purchases( + await _licensed_items_purchases_service.list_licensed_items_purchases( app=request.app, product_name=req_ctx.product_name, user_id=req_ctx.user_id, diff --git a/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_purchases_api.py b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_purchases_service.py similarity index 99% rename from services/web/server/src/simcore_service_webserver/licenses/_licensed_items_purchases_api.py rename to services/web/server/src/simcore_service_webserver/licenses/_licensed_items_purchases_service.py index b42d593bed1..2cfa6355f83 100644 --- a/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_purchases_api.py +++ b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_purchases_service.py @@ -75,6 +75,7 @@ async def list_licensed_items_purchases( async def get_licensed_item_purchase( app: web.Application, + *, product_name: ProductName, user_id: UserID, licensed_item_purchase_id: LicensedItemPurchaseID, diff --git a/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_db.py b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_repository.py similarity index 100% rename from services/web/server/src/simcore_service_webserver/licenses/_licensed_items_db.py rename to services/web/server/src/simcore_service_webserver/licenses/_licensed_items_repository.py diff --git a/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_handlers.py b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_rest.py similarity index 89% rename from services/web/server/src/simcore_service_webserver/licenses/_licensed_items_handlers.py rename to services/web/server/src/simcore_service_webserver/licenses/_licensed_items_rest.py index 355d9658ebb..4f0a936c041 100644 --- a/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_handlers.py +++ b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_rest.py @@ -21,7 +21,7 @@ from ..login.decorators import login_required from ..security.decorators import permission_required from ..utils_aiohttp import envelope_json_response -from . import _licensed_items_api +from . import _licensed_items_service from ._exceptions_handlers import handle_plugin_requests_exceptions from ._models import ( LicensedItemsBodyParams, @@ -47,7 +47,7 @@ async def list_licensed_items(request: web.Request): ) licensed_item_get_page: LicensedItemGetPage = ( - await _licensed_items_api.list_licensed_items( + await _licensed_items_service.list_licensed_items( app=request.app, product_name=req_ctx.product_name, offset=query_params.offset, @@ -81,10 +81,12 @@ async def get_licensed_item(request: web.Request): req_ctx = LicensedItemsRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(LicensedItemsPathParams, request) - licensed_item_get: LicensedItemGet = await _licensed_items_api.get_licensed_item( - app=request.app, - licensed_item_id=path_params.licensed_item_id, - product_name=req_ctx.product_name, + licensed_item_get: LicensedItemGet = ( + await _licensed_items_service.get_licensed_item( + app=request.app, + licensed_item_id=path_params.licensed_item_id, + product_name=req_ctx.product_name, + ) ) return envelope_json_response(licensed_item_get) @@ -102,7 +104,7 @@ async def purchase_licensed_item(request: web.Request): path_params = parse_request_path_parameters_as(LicensedItemsPathParams, request) body_params = await parse_request_body_as(LicensedItemsBodyParams, request) - await _licensed_items_api.purchase_licensed_item( + await _licensed_items_service.purchase_licensed_item( app=request.app, user_id=req_ctx.user_id, licensed_item_id=path_params.licensed_item_id, diff --git a/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_api.py b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_service.py similarity index 96% rename from services/web/server/src/simcore_service_webserver/licenses/_licensed_items_api.py rename to services/web/server/src/simcore_service_webserver/licenses/_licensed_items_service.py index 1f839ae31fa..374da33bbbe 100644 --- a/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_api.py +++ b/services/web/server/src/simcore_service_webserver/licenses/_licensed_items_service.py @@ -25,7 +25,7 @@ from ..users.api import get_user from ..wallets.api import get_wallet_with_available_credits_by_user_and_wallet from ..wallets.errors import WalletNotEnoughCreditsError -from . import _licensed_items_db +from . import _licensed_items_repository from ._models import LicensedItemsBodyParams from .errors import LicensedItemPricingPlanMatchError @@ -39,7 +39,7 @@ async def get_licensed_item( product_name: ProductName, ) -> LicensedItemGet: - licensed_item_db = await _licensed_items_db.get( + licensed_item_db = await _licensed_items_repository.get( app, licensed_item_id=licensed_item_id, product_name=product_name ) return LicensedItemGet( @@ -61,7 +61,7 @@ async def list_licensed_items( limit: int, order_by: OrderBy, ) -> LicensedItemGetPage: - total_count, licensed_item_db_list = await _licensed_items_db.list_( + total_count, licensed_item_db_list = await _licensed_items_repository.list_( app, product_name=product_name, offset=offset, limit=limit, order_by=order_by ) return LicensedItemGetPage( diff --git a/services/web/server/src/simcore_service_webserver/licenses/_rpc.py b/services/web/server/src/simcore_service_webserver/licenses/_rpc.py index 8be794e4016..261eb51c3aa 100644 --- a/services/web/server/src/simcore_service_webserver/licenses/_rpc.py +++ b/services/web/server/src/simcore_service_webserver/licenses/_rpc.py @@ -2,7 +2,7 @@ from models_library.api_schemas_webserver import WEBSERVER_RPC_NAMESPACE from models_library.api_schemas_webserver.licensed_items import LicensedItemGetPage from models_library.api_schemas_webserver.licensed_items_checkouts import ( - LicensedItemCheckoutGet, + LicensedItemCheckoutRpcGet, ) from models_library.basic_types import IDStr from models_library.licensed_items import LicensedItemID @@ -20,7 +20,7 @@ ) from ..rabbitmq import get_rabbitmq_rpc_server -from . import _licensed_checkouts_api, _licensed_items_api +from . import _licensed_items_checkouts_service, _licensed_items_service router = RPCRouter() @@ -34,7 +34,7 @@ async def get_licensed_items( limit: int, ) -> LicensedItemGetPage: licensed_item_get_page: LicensedItemGetPage = ( - await _licensed_items_api.list_licensed_items( + await _licensed_items_service.list_licensed_items( app=app, product_name=product_name, offset=offset, @@ -68,15 +68,27 @@ async def checkout_licensed_item_for_wallet( licensed_item_id: LicensedItemID, num_of_seats: int, service_run_id: ServiceRunID, -) -> LicensedItemCheckoutGet: - return await _licensed_checkouts_api.checkout_licensed_item_for_wallet( - app, - licensed_item_id=licensed_item_id, - wallet_id=wallet_id, - product_name=product_name, - num_of_seats=num_of_seats, - service_run_id=service_run_id, - user_id=user_id, +) -> LicensedItemCheckoutRpcGet: + licensed_item_get = ( + await _licensed_items_checkouts_service.checkout_licensed_item_for_wallet( + app, + licensed_item_id=licensed_item_id, + wallet_id=wallet_id, + product_name=product_name, + num_of_seats=num_of_seats, + service_run_id=service_run_id, + user_id=user_id, + ) + ) + return LicensedItemCheckoutRpcGet.model_construct( + licensed_item_checkout_id=licensed_item_get.licensed_item_checkout_id, + licensed_item_id=licensed_item_get.licensed_item_id, + wallet_id=licensed_item_get.wallet_id, + user_id=licensed_item_get.user_id, + product_name=licensed_item_get.product_name, + started_at=licensed_item_get.started_at, + stopped_at=licensed_item_get.stopped_at, + num_of_seats=licensed_item_get.num_of_seats, ) @@ -87,12 +99,24 @@ async def release_licensed_item_for_wallet( product_name: ProductName, user_id: UserID, licensed_item_checkout_id: LicensedItemCheckoutID, -) -> LicensedItemCheckoutGet: - return await _licensed_checkouts_api.release_licensed_item_for_wallet( - app, - product_name=product_name, - user_id=user_id, - licensed_item_checkout_id=licensed_item_checkout_id, +) -> LicensedItemCheckoutRpcGet: + licensed_item_get = ( + await _licensed_items_checkouts_service.release_licensed_item_for_wallet( + app, + product_name=product_name, + user_id=user_id, + licensed_item_checkout_id=licensed_item_checkout_id, + ) + ) + return LicensedItemCheckoutRpcGet.model_construct( + licensed_item_checkout_id=licensed_item_get.licensed_item_checkout_id, + licensed_item_id=licensed_item_get.licensed_item_id, + wallet_id=licensed_item_get.wallet_id, + user_id=licensed_item_get.user_id, + product_name=licensed_item_get.product_name, + started_at=licensed_item_get.started_at, + stopped_at=licensed_item_get.stopped_at, + num_of_seats=licensed_item_get.num_of_seats, ) diff --git a/services/web/server/src/simcore_service_webserver/licenses/plugin.py b/services/web/server/src/simcore_service_webserver/licenses/plugin.py index 137c7b2d1dc..72af99badeb 100644 --- a/services/web/server/src/simcore_service_webserver/licenses/plugin.py +++ b/services/web/server/src/simcore_service_webserver/licenses/plugin.py @@ -8,7 +8,12 @@ from servicelib.aiohttp.application_setup import ModuleCategory, app_module_setup from ..rabbitmq import setup_rabbitmq -from . import _licensed_items_handlers, _licensed_items_purchases_handlers, _rpc +from . import ( + _licensed_items_checkouts_rest, + _licensed_items_purchases_rest, + _licensed_items_rest, + _rpc, +) _logger = logging.getLogger(__name__) @@ -24,8 +29,9 @@ def setup_licenses(app: web.Application): assert app[APP_SETTINGS_KEY].WEBSERVER_LICENSES # nosec # routes - app.router.add_routes(_licensed_items_handlers.routes) - app.router.add_routes(_licensed_items_purchases_handlers.routes) + app.router.add_routes(_licensed_items_rest.routes) + app.router.add_routes(_licensed_items_purchases_rest.routes) + app.router.add_routes(_licensed_items_checkouts_rest.routes) setup_rabbitmq(app) if app[APP_SETTINGS_KEY].WEBSERVER_RABBITMQ: diff --git a/services/web/server/src/simcore_service_webserver/licenses/api.py b/services/web/server/src/simcore_service_webserver/licenses/service.py similarity index 100% rename from services/web/server/src/simcore_service_webserver/licenses/api.py rename to services/web/server/src/simcore_service_webserver/licenses/service.py diff --git a/services/web/server/src/simcore_service_webserver/projects/_db_utils.py b/services/web/server/src/simcore_service_webserver/projects/_db_utils.py index e36e2d455b3..2b14c2d1566 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_db_utils.py +++ b/services/web/server/src/simcore_service_webserver/projects/_db_utils.py @@ -151,7 +151,7 @@ async def _get_user_primary_group_gid(conn: SAConnection, user_id: int) -> int: sa.select(users.c.primary_gid).where(users.c.id == str(user_id)) ) if not primary_gid: - raise UserNotFoundError(uid=user_id) + raise UserNotFoundError(user_id=user_id) assert isinstance(primary_gid, int) return primary_gid diff --git a/services/web/server/src/simcore_service_webserver/projects/_folders_db.py b/services/web/server/src/simcore_service_webserver/projects/_folders_db.py index d8c965be26b..d4fde1f5ce9 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_folders_db.py +++ b/services/web/server/src/simcore_service_webserver/projects/_folders_db.py @@ -8,7 +8,7 @@ from datetime import datetime from aiohttp import web -from common_library.unset import UnSet, as_dict_exclude_unset +from common_library.exclude import UnSet, as_dict_exclude_unset from models_library.folders import FolderID from models_library.projects import ProjectID from models_library.users import UserID diff --git a/services/web/server/src/simcore_service_webserver/security/_authz_access_roles.py b/services/web/server/src/simcore_service_webserver/security/_authz_access_roles.py index da342e1b996..0bd7e6a75eb 100644 --- a/services/web/server/src/simcore_service_webserver/security/_authz_access_roles.py +++ b/services/web/server/src/simcore_service_webserver/security/_authz_access_roles.py @@ -83,6 +83,7 @@ class PermissionDict(TypedDict, total=False): "user.notifications.write", "user.profile.delete", "user.profile.update", + "user.read", "user.tokens.*", "wallets.*", "workspaces.*", @@ -103,7 +104,7 @@ class PermissionDict(TypedDict, total=False): can=[ "product.details.*", "product.invitations.create", - "user.users.*", + "admin.users.read", ], inherits=[UserRole.TESTER], ), diff --git a/services/web/server/src/simcore_service_webserver/users/_common/schemas.py b/services/web/server/src/simcore_service_webserver/users/_common/schemas.py index b4455abfa07..04946e21fcc 100644 --- a/services/web/server/src/simcore_service_webserver/users/_common/schemas.py +++ b/services/web/server/src/simcore_service_webserver/users/_common/schemas.py @@ -12,7 +12,7 @@ import pycountry from models_library.api_schemas_webserver._base import InputSchema -from models_library.api_schemas_webserver.users import UserGet +from models_library.api_schemas_webserver.users import UserForAdminGet from models_library.emails import LowerCaseEmailStr from models_library.users import UserID from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator @@ -109,4 +109,6 @@ def _pre_check_and_normalize_country(cls, v): # asserts field names are in sync -assert set(PreRegisteredUserGet.model_fields).issubset(UserGet.model_fields) # nosec +assert set(PreRegisteredUserGet.model_fields).issubset( + UserForAdminGet.model_fields +) # nosec diff --git a/services/web/server/src/simcore_service_webserver/users/_users_repository.py b/services/web/server/src/simcore_service_webserver/users/_users_repository.py index 4c536536950..5fcc88af4a1 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_repository.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_repository.py @@ -30,6 +30,8 @@ from simcore_postgres_database.utils_users import ( UsersRepo, generate_alternative_username, + is_public, + visible_user_profile_cols, ) from sqlalchemy import delete from sqlalchemy.engine.row import Row @@ -49,7 +51,76 @@ def _parse_as_user(user_id: Any) -> UserID: try: return TypeAdapter(UserID).validate_python(user_id) except ValidationError as err: - raise UserNotFoundError(uid=user_id, user_id=user_id) from err + raise UserNotFoundError(user_id=user_id) from err + + +def _public_user_cols(caller_id: int): + return ( + # Fits PublicUser model + users.c.id.label("user_id"), + users.c.name.label("user_name"), + *visible_user_profile_cols(caller_id), + users.c.primary_gid.label("group_id"), + ) + + +# +# PUBLIC User +# + + +async def get_public_user( + engine: AsyncEngine, + connection: AsyncConnection | None = None, + *, + caller_id: UserID, + user_id: UserID, +): + query = sa.select(*_public_user_cols(caller_id=caller_id)).where( + users.c.id == user_id + ) + + async with pass_or_acquire_connection(engine, connection) as conn: + result = await conn.execute(query) + user = result.first() + if not user: + raise UserNotFoundError(user_id=user_id) + return user + + +async def search_public_user( + engine: AsyncEngine, + connection: AsyncConnection | None = None, + *, + caller_id: UserID, + search_pattern: str, + limit: int, +) -> list: + + _pattern = f"%{search_pattern}%" + + query = ( + sa.select(*_public_user_cols(caller_id=caller_id)) + .where( + users.c.name.ilike(_pattern) + | ( + is_public(users.c.privacy_hide_email, caller_id) + & users.c.email.ilike(_pattern) + ) + | ( + is_public(users.c.privacy_hide_fullname, caller_id) + & ( + users.c.first_name.ilike(_pattern) + | users.c.last_name.ilike(_pattern) + ) + ) + ) + .limit(limit) + ) + + async with pass_or_acquire_connection(engine, connection) as conn: + result = await conn.stream(query) + return [got async for got in result] async def get_user_or_raise( @@ -65,15 +136,16 @@ async def get_user_or_raise( assert return_column_names is not None # nosec assert set(return_column_names).issubset(users.columns.keys()) # nosec + query = sa.select(*(users.columns[name] for name in return_column_names)).where( + users.c.id == user_id + ) + async with pass_or_acquire_connection(engine, connection) as conn: - result = await conn.stream( - sa.select(*(users.columns[name] for name in return_column_names)).where( - users.c.id == user_id - ) - ) - row = await result.first() + result = await conn.execute(query) + row = result.first() if row is None: - raise UserNotFoundError(uid=user_id) + raise UserNotFoundError(user_id=user_id) + user: dict[str, Any] = row._asdict() return user @@ -88,7 +160,7 @@ async def get_user_primary_group_id( ).where(users.c.id == user_id) ) if primary_gid is None: - raise UserNotFoundError(uid=user_id) + raise UserNotFoundError(user_id=user_id) return primary_gid @@ -100,7 +172,9 @@ async def get_users_ids_in_group( ) -> set[UserID]: async with pass_or_acquire_connection(engine, connection) as conn: result = await conn.stream( - sa.select(user_to_groups.c.uid).where(user_to_groups.c.gid == group_id) + sa.select( + user_to_groups.c.uid, + ).where(user_to_groups.c.gid == group_id) ) return {row.uid async for row in result} @@ -108,7 +182,9 @@ async def get_users_ids_in_group( async def get_user_id_from_pgid(app: web.Application, primary_gid: int) -> UserID: async with pass_or_acquire_connection(engine=get_asyncpg_engine(app)) as conn: user_id: UserID = await conn.scalar( - sa.select(users.c.id).where(users.c.primary_gid == primary_gid) + sa.select( + users.c.id, + ).where(users.c.primary_gid == primary_gid) ) return user_id @@ -128,7 +204,7 @@ async def get_user_fullname(app: web.Application, *, user_id: UserID) -> FullNam ) user = await result.first() if not user: - raise UserNotFoundError(uid=user_id) + raise UserNotFoundError(user_id=user_id) return FullNameDict( first_name=user.first_name, @@ -141,7 +217,10 @@ async def get_guest_user_ids_and_names( ) -> list[tuple[UserID, UserNameID]]: async with pass_or_acquire_connection(engine=get_asyncpg_engine(app)) as conn: result = await conn.stream( - sa.select(users.c.id, users.c.name).where(users.c.role == UserRole.GUEST) + sa.select( + users.c.id, + users.c.name, + ).where(users.c.role == UserRole.GUEST) ) return TypeAdapter(list[tuple[UserID, UserNameID]]).validate_python( @@ -157,10 +236,12 @@ async def get_user_role(app: web.Application, *, user_id: UserID) -> UserRole: async with pass_or_acquire_connection(engine=get_asyncpg_engine(app)) as conn: user_role = await conn.scalar( - sa.select(users.c.role).where(users.c.id == user_id) + sa.select( + users.c.role, + ).where(users.c.id == user_id) ) if user_role is None: - raise UserNotFoundError(uid=user_id) + raise UserNotFoundError(user_id=user_id) assert isinstance(user_role, UserRole) # nosec return user_role @@ -198,7 +279,9 @@ async def do_update_expired_users( async with transaction_context(engine, connection) as conn: result = await conn.stream( users.update() - .values(status=UserStatus.EXPIRED) + .values( + status=UserStatus.EXPIRED, + ) .where( (users.c.expires_at.is_not(None)) & (users.c.status == UserStatus.ACTIVE) @@ -218,7 +301,11 @@ async def update_user_status( ): async with transaction_context(engine, connection) as conn: await conn.execute( - users.update().values(status=new_status).where(users.c.id == user_id) + users.update() + .values( + status=new_status, + ) + .where(users.c.id == user_id) ) @@ -232,7 +319,9 @@ async def search_users_and_get_profile( users_alias = sa.alias(users, name="users_alias") invited_by = ( - sa.select(users_alias.c.name) + sa.select( + users_alias.c.name, + ) .where(users_pre_registration_details.c.created_by == users_alias.c.id) .label("invited_by") ) @@ -291,11 +380,19 @@ async def get_user_products( ) -> list[Row]: async with pass_or_acquire_connection(engine, connection) as conn: product_name_subq = ( - sa.select(products.c.name) + sa.select( + products.c.name, + ) .where(products.c.group_id == groups.c.gid) .label("product_name") ) - products_gis_subq = sa.select(products.c.group_id).distinct().subquery() + products_gis_subq = ( + sa.select( + products.c.group_id, + ) + .distinct() + .subquery() + ) query = ( sa.select( groups.c.gid, @@ -326,7 +423,9 @@ async def create_user_details( async with transaction_context(engine, connection) as conn: await conn.execute( sa.insert(users_pre_registration_details).values( - created_by=created_by, pre_email=email, **other_values + created_by=created_by, + pre_email=email, + **other_values, ) ) @@ -341,7 +440,7 @@ async def get_user_billing_details( async with pass_or_acquire_connection(engine, connection) as conn: query = UsersRepo.get_billing_details_query(user_id=user_id) result = await conn.execute(query) - row = result.fetchone() + row = result.first() if not row: raise BillingDetailsNotFoundError(user_id=user_id) return UserBillingDetails.model_validate(row) @@ -356,7 +455,7 @@ async def delete_user_by_id( .where(users.c.id == user_id) .returning(users.c.id) # Return the ID of the deleted row otherwise None ) - deleted_user = result.fetchone() + deleted_user = result.first() # If no row was deleted, the user did not exist return bool(deleted_user) @@ -397,7 +496,7 @@ async def get_my_profile(app: web.Application, *, user_id: UserID) -> MyProfile: ) row = await result.first() if not row: - raise UserNotFoundError(uid=user_id) + raise UserNotFoundError(user_id=user_id) my_profile = MyProfile.model_validate(row, from_attributes=True) assert my_profile.id == user_id # nosec diff --git a/services/web/server/src/simcore_service_webserver/users/_users_rest.py b/services/web/server/src/simcore_service_webserver/users/_users_rest.py index 33540de2424..688b024b40a 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_rest.py @@ -5,7 +5,9 @@ from models_library.api_schemas_webserver.users import ( MyProfileGet, MyProfilePatch, - UsersSearchQueryParams, + UserGet, + UsersForAdminSearchQueryParams, + UsersSearch, ) from servicelib.aiohttp import status from servicelib.aiohttp.requests_validation import ( @@ -128,6 +130,32 @@ async def update_my_profile(request: web.Request) -> web.Response: return web.json_response(status=status.HTTP_204_NO_CONTENT) +# +# USERS (public) +# + + +@routes.post(f"/{API_VTAG}/users:search", name="search_users") +@login_required +@permission_required("user.read") +@_handle_users_exceptions +async def search_users(request: web.Request) -> web.Response: + req_ctx = UsersRequestContext.model_validate(request) + assert req_ctx.product_name # nosec + + # NOTE: Decided for body instead of query parameters because it is easier for the front-end + search_params = await parse_request_body_as(UsersSearch, request) + + found = await _users_service.search_public_users( + request.app, + caller_id=req_ctx.user_id, + match_=search_params.match_, + limit=search_params.limit, + ) + + return envelope_json_response([UserGet.from_model(user) for user in found]) + + # # USERS (only POs) # @@ -136,16 +164,16 @@ async def update_my_profile(request: web.Request) -> web.Response: _RESPONSE_MODEL_MINIMAL_POLICY["exclude_none"] = True -@routes.get(f"/{API_VTAG}/users:search", name="search_users") +@routes.get(f"/{API_VTAG}/admin/users:search", name="search_users_for_admin") @login_required -@permission_required("user.users.*") +@permission_required("admin.users.read") @_handle_users_exceptions -async def search_users(request: web.Request) -> web.Response: +async def search_users_for_admin(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) assert req_ctx.product_name # nosec - query_params: UsersSearchQueryParams = parse_request_query_parameters_as( - UsersSearchQueryParams, request + query_params: UsersForAdminSearchQueryParams = parse_request_query_parameters_as( + UsersForAdminSearchQueryParams, request ) found = await _users_service.search_users( @@ -157,11 +185,13 @@ async def search_users(request: web.Request) -> web.Response: ) -@routes.post(f"/{API_VTAG}/users:pre-register", name="pre_register_user") +@routes.post( + f"/{API_VTAG}/admin/users:pre-register", name="pre_register_user_for_admin" +) @login_required -@permission_required("user.users.*") +@permission_required("admin.users.read") @_handle_users_exceptions -async def pre_register_user(request: web.Request) -> web.Response: +async def pre_register_user_for_admin(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) pre_user_profile = await parse_request_body_as(PreRegisteredUserGet, request) diff --git a/services/web/server/src/simcore_service_webserver/users/_users_service.py b/services/web/server/src/simcore_service_webserver/users/_users_service.py index 289b4dd641e..2bb52b85d57 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_service.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_service.py @@ -3,7 +3,7 @@ import pycountry from aiohttp import web -from models_library.api_schemas_webserver.users import MyProfilePatch, UserGet +from models_library.api_schemas_webserver.users import MyProfilePatch, UserForAdminGet from models_library.basic_types import IDStr from models_library.emails import LowerCaseEmailStr from models_library.groups import GroupID @@ -43,7 +43,7 @@ async def pre_register_user( app: web.Application, profile: PreRegisteredUserGet, creator_user_id: UserID, -) -> UserGet: +) -> UserForAdminGet: found = await search_users(app, email_glob=profile.email, include_products=False) if found: @@ -87,6 +87,25 @@ async def pre_register_user( # +async def get_public_user(app: web.Application, *, caller_id: UserID, user_id: UserID): + return await _users_repository.get_public_user( + get_asyncpg_engine(app), + caller_id=caller_id, + user_id=user_id, + ) + + +async def search_public_users( + app: web.Application, *, caller_id: UserID, match_: str, limit: int +) -> list: + return await _users_repository.search_public_user( + get_asyncpg_engine(app), + caller_id=caller_id, + search_pattern=match_, + limit=limit, + ) + + async def get_user(app: web.Application, user_id: UserID) -> dict[str, Any]: """ :raises UserNotFoundError: if missing but NOT if marked for deletion! @@ -108,7 +127,7 @@ async def get_user_id_from_gid(app: web.Application, primary_gid: GroupID) -> Us async def search_users( app: web.Application, email_glob: str, *, include_products: bool = False -) -> list[UserGet]: +) -> list[UserForAdminGet]: # NOTE: this search is deploy-wide i.e. independent of the product! def _glob_to_sql_like(glob_pattern: str) -> str: @@ -130,7 +149,7 @@ async def _list_products_or_none(user_id): return None return [ - UserGet( + UserForAdminGet( first_name=r.first_name or r.pre_first_name, last_name=r.last_name or r.pre_last_name, email=r.email or r.pre_email, diff --git a/services/web/server/src/simcore_service_webserver/users/exceptions.py b/services/web/server/src/simcore_service_webserver/users/exceptions.py index edb552a2958..9f1bb48ef0a 100644 --- a/services/web/server/src/simcore_service_webserver/users/exceptions.py +++ b/services/web/server/src/simcore_service_webserver/users/exceptions.py @@ -8,16 +8,18 @@ class UsersBaseError(WebServerBaseError): class UserNotFoundError(UsersBaseError): - def __init__(self, *, uid: int | None = None, email: str | None = None, **ctx: Any): + def __init__( + self, *, user_id: int | None = None, email: str | None = None, **ctx: Any + ): super().__init__( msg_template=( - "User id {uid} not found" - if uid + "User id {user_id} not found" + if user_id else f"User with email {email} not found" ), **ctx, ) - self.uid = uid + self.user_id = user_id self.email = email diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_groups_handlers.py b/services/web/server/tests/unit/with_dbs/02/test_projects_groups_handlers.py index 5af112ba78f..fe3ecfa8c3a 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_groups_handlers.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_groups_handlers.py @@ -66,9 +66,9 @@ async def test_projects_groups_full_workflow( data, _ = await assert_status(resp, status.HTTP_200_OK) assert len(data) == 1 assert data[0]["gid"] == logged_user["primary_gid"] - assert data[0]["read"] == True - assert data[0]["write"] == True - assert data[0]["delete"] == True + assert data[0]["read"] is True + assert data[0]["write"] is True + assert data[0]["delete"] is True # Get project endpoint and check permissions url = client.app.router["get_project"].url_for( diff --git a/services/web/server/tests/unit/with_dbs/03/invitations/test_products__invitations_handlers.py b/services/web/server/tests/unit/with_dbs/03/invitations/test_products__invitations_handlers.py index 71da6536363..64aec0a93d9 100644 --- a/services/web/server/tests/unit/with_dbs/03/invitations/test_products__invitations_handlers.py +++ b/services/web/server/tests/unit/with_dbs/03/invitations/test_products__invitations_handlers.py @@ -153,7 +153,7 @@ async def test_pre_registration_and_invitation_workflow( ).model_dump() # Search user -> nothing - response = await client.get("/v0/users:search", params={"email": guest_email}) + response = await client.get("/v0/admin/users:search", params={"email": guest_email}) data, _ = await assert_status(response, expected_status) # i.e. no info of requester is found, i.e. needs pre-registration assert data == [] @@ -164,17 +164,21 @@ async def test_pre_registration_and_invitation_workflow( # assert response.status == status.HTTP_409_CONFLICT # Accept user for registration and create invitation for her - response = await client.post("/v0/users:pre-register", json=requester_info) + response = await client.post("/v0/admin/users:pre-register", json=requester_info) data, _ = await assert_status(response, expected_status) # Can only pre-register once for _ in range(MANY_TIMES): - response = await client.post("/v0/users:pre-register", json=requester_info) + response = await client.post( + "/v0/admin/users:pre-register", json=requester_info + ) await assert_status(response, status.HTTP_409_CONFLICT) # Search user again for _ in range(MANY_TIMES): - response = await client.get("/v0/users:search", params={"email": guest_email}) + response = await client.get( + "/v0/admin/users:search", params={"email": guest_email} + ) data, _ = await assert_status(response, expected_status) assert len(data) == 1 user_found = data[0] @@ -203,7 +207,7 @@ async def test_pre_registration_and_invitation_workflow( await assert_status(response, status.HTTP_200_OK) # find registered user - response = await client.get("/v0/users:search", params={"email": guest_email}) + response = await client.get("/v0/admin/users:search", params={"email": guest_email}) data, _ = await assert_status(response, expected_status) assert len(data) == 1 user_found = data[0] diff --git a/services/web/server/tests/unit/with_dbs/03/meta_modeling/test_meta_modeling_iterations.py b/services/web/server/tests/unit/with_dbs/03/meta_modeling/test_meta_modeling_iterations.py index a278e2e09e3..e00b67c0673 100644 --- a/services/web/server/tests/unit/with_dbs/03/meta_modeling/test_meta_modeling_iterations.py +++ b/services/web/server/tests/unit/with_dbs/03/meta_modeling/test_meta_modeling_iterations.py @@ -70,7 +70,9 @@ async def context_with_logged_user(client: TestClient, logged_user: UserInfoDict await conn.execute(projects.delete()) -@pytest.mark.skip(reason="TODO: temporary removed to check blocker") +@pytest.mark.skip( + reason="Blocking testing. Will follow up in https://github.com/ITISFoundation/osparc-simcore/issues/6976 " +) @pytest.mark.acceptance_test() async def test_iterators_workflow( client: TestClient, diff --git a/services/web/server/tests/unit/with_dbs/03/test_users.py b/services/web/server/tests/unit/with_dbs/03/test_users.py index cb45fc8d643..6b0ba408cc0 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users.py +++ b/services/web/server/tests/unit/with_dbs/03/test_users.py @@ -1,12 +1,14 @@ # pylint: disable=protected-access # pylint: disable=redefined-outer-name # pylint: disable=too-many-arguments +# pylint: disable=too-many-statements # pylint: disable=unused-argument # pylint: disable=unused-variable import functools import sys +from collections.abc import AsyncIterable from copy import deepcopy from http import HTTPStatus from typing import Any @@ -19,15 +21,21 @@ from common_library.users_enums import UserRole, UserStatus from faker import Faker from models_library.api_schemas_webserver.auth import AccountRequestInfo -from models_library.api_schemas_webserver.users import MyProfileGet, UserGet +from models_library.api_schemas_webserver.groups import GroupUserGet +from models_library.api_schemas_webserver.users import ( + MyProfileGet, + UserForAdminGet, + UserGet, +) from psycopg2 import OperationalError +from pydantic import TypeAdapter from pytest_simcore.helpers.assert_checks import assert_status from pytest_simcore.helpers.faker_factories import ( DEFAULT_TEST_PASSWORD, random_pre_registration_details, ) from pytest_simcore.helpers.monkeypatch_envs import EnvVarsDict, setenvs_from_dict -from pytest_simcore.helpers.webserver_login import UserInfoDict +from pytest_simcore.helpers.webserver_login import NewUser, UserInfoDict from servicelib.aiohttp import status from servicelib.rest_constants import RESPONSE_MODEL_POLICY from simcore_service_webserver.users._common.schemas import ( @@ -53,6 +61,188 @@ def app_environment( ) +@pytest.fixture +async def private_user(client: TestClient) -> AsyncIterable[UserInfoDict]: + assert client.app + async with NewUser( + app=client.app, + user_data={ + "name": "jamie01", + "first_name": "James", + "last_name": "Bond", + "email": "james@find.me", + "privacy_hide_email": True, + "privacy_hide_fullname": True, + }, + ) as usr: + yield usr + + +@pytest.fixture +async def semi_private_user(client: TestClient) -> AsyncIterable[UserInfoDict]: + assert client.app + async with NewUser( + app=client.app, + user_data={ + "name": "maxwell", + "first_name": "James", + "last_name": "Maxwell", + "email": "j@maxwell.me", + "privacy_hide_email": True, + "privacy_hide_fullname": False, # <-- + }, + ) as usr: + yield usr + + +@pytest.fixture +async def public_user(client: TestClient) -> AsyncIterable[UserInfoDict]: + assert client.app + async with NewUser( + app=client.app, + user_data={ + "name": "taylie01", + "first_name": "Taylor", + "last_name": "Swift", + "email": "taylor@find.me", + "privacy_hide_email": False, + "privacy_hide_fullname": False, + }, + ) as usr: + yield usr + + +@pytest.mark.acceptance_test( + "https://github.com/ITISFoundation/osparc-issues/issues/1779" +) +@pytest.mark.parametrize("user_role", [UserRole.USER]) +async def test_search_users( + logged_user: UserInfoDict, + client: TestClient, + user_role: UserRole, + public_user: UserInfoDict, + semi_private_user: UserInfoDict, + private_user: UserInfoDict, +): + assert client.app + assert user_role.value == logged_user["role"] + + assert private_user["id"] != logged_user["id"] + assert public_user["id"] != logged_user["id"] + + # SEARCH by partial first_name + partial_name = "james" + assert partial_name in private_user.get("first_name", "").lower() + assert partial_name in semi_private_user.get("first_name", "").lower() + + url = client.app.router["search_users"].url_for() + resp = await client.post(f"{url}", json={"match": partial_name}) + data, _ = await assert_status(resp, status.HTTP_200_OK) + + found = TypeAdapter(list[UserGet]).validate_python(data) + assert found + assert len(found) == 1 + assert semi_private_user["name"] == found[0].user_name + assert found[0].first_name == semi_private_user.get("first_name") + assert found[0].last_name == semi_private_user.get("last_name") + assert found[0].email is None + + # SEARCH by partial email + partial_email = "@find.m" + assert partial_email in private_user["email"] + assert partial_email in public_user["email"] + + url = client.app.router["search_users"].url_for() + resp = await client.post(f"{url}", json={"match": partial_email}) + data, _ = await assert_status(resp, status.HTTP_200_OK) + + found = TypeAdapter(list[UserGet]).validate_python(data) + assert found + assert len(found) == 1 + assert found[0].user_id == public_user["id"] + assert found[0].user_name == public_user["name"] + assert found[0].email == public_user["email"] + assert found[0].first_name == public_user.get("first_name") + assert found[0].last_name == public_user.get("last_name") + + # SEARCH by partial username + partial_username = "ie01" + assert partial_username in private_user["name"] + assert partial_username in public_user["name"] + + url = client.app.router["search_users"].url_for() + resp = await client.post(f"{url}", json={"match": partial_username}) + data, _ = await assert_status(resp, status.HTTP_200_OK) + + found = TypeAdapter(list[UserGet]).validate_python(data) + assert found + assert len(found) == 2 + + index = [u.user_id for u in found].index(public_user["id"]) + assert found[index].user_name == public_user["name"] + + # check privacy + index = (index + 1) % 2 + assert found[index].user_name == private_user["name"] + assert found[index].email is None + assert found[index].first_name is None + assert found[index].last_name is None + + # SEARCH user for admin (from a USER) + url = ( + client.app.router["search_users_for_admin"] + .url_for() + .with_query(email=partial_email) + ) + resp = await client.get(f"{url}") + await assert_status(resp, status.HTTP_403_FORBIDDEN) + + +@pytest.mark.acceptance_test( + "https://github.com/ITISFoundation/osparc-issues/issues/1779" +) +@pytest.mark.parametrize("user_role", [UserRole.USER]) +async def test_get_user_by_group_id( + logged_user: UserInfoDict, + client: TestClient, + user_role: UserRole, + public_user: UserInfoDict, + private_user: UserInfoDict, +): + assert client.app + assert user_role.value == logged_user["role"] + + assert private_user["id"] != logged_user["id"] + assert public_user["id"] != logged_user["id"] + + # GET user by primary GID + url = client.app.router["get_all_group_users"].url_for( + gid=f"{public_user['primary_gid']}" + ) + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + + users = TypeAdapter(list[GroupUserGet]).validate_python(data) + assert len(users) == 1 + assert users[0].id == public_user["id"] + assert users[0].user_name == public_user["name"] + assert users[0].first_name == public_user.get("first_name") + assert users[0].last_name == public_user.get("last_name") + + url = client.app.router["get_all_group_users"].url_for( + gid=f"{private_user['primary_gid']}" + ) + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + + users = TypeAdapter(list[GroupUserGet]).validate_python(data) + assert len(users) == 1 + assert users[0].id == private_user["id"] + assert users[0].user_name == private_user["name"] + assert users[0].first_name is None + assert users[0].last_name is None + + @pytest.mark.parametrize( "user_role,expected", [ @@ -345,8 +535,8 @@ async def test_access_rights_on_search_users_only_product_owners_can_access( ): assert client.app - url = client.app.router["search_users"].url_for() - assert url.path == "/v0/users:search" + url = client.app.router["search_users_for_admin"].url_for() + assert url.path == "/v0/admin/users:search" resp = await client.get(url.path, params={"email": "do-not-exists@foo.com"}) await assert_status(resp, expected) @@ -396,12 +586,14 @@ async def test_search_and_pre_registration( assert client.app # ONLY in `users` and NOT `users_pre_registration_details` - resp = await client.get("/v0/users:search", params={"email": logged_user["email"]}) + resp = await client.get( + "/v0/admin/users:search", params={"email": logged_user["email"]} + ) assert resp.status == status.HTTP_200_OK found, _ = await assert_status(resp, status.HTTP_200_OK) assert len(found) == 1 - got = UserGet( + got = UserForAdminGet( **found[0], institution=None, address=None, @@ -430,15 +622,15 @@ async def test_search_and_pre_registration( # NOT in `users` and ONLY `users_pre_registration_details` # create pre-registration - resp = await client.post("/v0/users:pre-register", json=account_request_form) + resp = await client.post("/v0/admin/users:pre-register", json=account_request_form) assert resp.status == status.HTTP_200_OK resp = await client.get( - "/v0/users:search", params={"email": account_request_form["email"]} + "/v0/admin/users:search", params={"email": account_request_form["email"]} ) found, _ = await assert_status(resp, status.HTTP_200_OK) assert len(found) == 1 - got = UserGet(**found[0], state=None, status=None) + got = UserForAdminGet(**found[0], state=None, status=None) assert got.model_dump(include={"registered", "status"}) == { "registered": False, @@ -457,11 +649,11 @@ async def test_search_and_pre_registration( ) resp = await client.get( - "/v0/users:search", params={"email": account_request_form["email"]} + "/v0/admin/users:search", params={"email": account_request_form["email"]} ) found, _ = await assert_status(resp, status.HTTP_200_OK) assert len(found) == 1 - got = UserGet(**found[0], state=None) + got = UserForAdminGet(**found[0], state=None) assert got.model_dump(include={"registered", "status"}) == { "registered": True, "status": new_user["status"].name, diff --git a/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_checkouts_rest.py b/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_checkouts_rest.py new file mode 100644 index 00000000000..1a6a81e76f4 --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_checkouts_rest.py @@ -0,0 +1,85 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments +# pylint: disable=too-many-statements +from http import HTTPStatus + +import pytest +from aiohttp.test_utils import TestClient +from models_library.api_schemas_resource_usage_tracker.licensed_items_checkouts import ( + LicensedItemCheckoutGet, + LicensedItemsCheckoutsPage, +) +from models_library.api_schemas_webserver.licensed_items_checkouts import ( + LicensedItemCheckoutRestGet, +) +from pytest_mock.plugin import MockerFixture +from pytest_simcore.helpers.assert_checks import assert_status +from pytest_simcore.helpers.webserver_login import UserInfoDict +from servicelib.aiohttp import status +from simcore_service_webserver.db.models import UserRole + +_LICENSED_ITEM_CHECKOUT_GET = LicensedItemCheckoutGet.model_validate( + LicensedItemCheckoutGet.model_config["json_schema_extra"]["examples"][0] +) + +_LICENSED_ITEM_CHECKOUT_PAGE = LicensedItemsCheckoutsPage( + items=[_LICENSED_ITEM_CHECKOUT_GET], + total=1, +) + + +@pytest.fixture +def mock_get_licensed_items_checkouts_page(mocker: MockerFixture) -> tuple: + return mocker.patch( + "simcore_service_webserver.licenses._licensed_items_checkouts_service.licensed_items_checkouts.get_licensed_items_checkouts_page", + spec=True, + return_value=_LICENSED_ITEM_CHECKOUT_PAGE, + ) + + +@pytest.fixture +def mock_get_licensed_item_checkout(mocker: MockerFixture) -> tuple: + return mocker.patch( + "simcore_service_webserver.licenses._licensed_items_checkouts_service.licensed_items_checkouts.get_licensed_item_checkout", + spec=True, + return_value=_LICENSED_ITEM_CHECKOUT_GET, + ) + + +@pytest.fixture +def mock_get_wallet_by_user(mocker: MockerFixture) -> tuple: + return mocker.patch( + "simcore_service_webserver.licenses._licensed_items_checkouts_service.get_wallet_by_user", + spec=True, + ) + + +@pytest.mark.parametrize("user_role,expected", [(UserRole.USER, status.HTTP_200_OK)]) +async def test_licensed_items_checkouts_handlers( + client: TestClient, + logged_user: UserInfoDict, + expected: HTTPStatus, + mock_get_licensed_items_checkouts_page: MockerFixture, + mock_get_licensed_item_checkout: MockerFixture, + mock_get_wallet_by_user: MockerFixture, +): + assert client.app + + # list + url = client.app.router["list_licensed_item_checkouts_for_wallet"].url_for( + wallet_id="1" + ) + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(data) == 1 + assert LicensedItemCheckoutRestGet(**data[0]) + + # get + url = client.app.router["get_licensed_item_checkout"].url_for( + licensed_item_checkout_id=f"{_LICENSED_ITEM_CHECKOUT_PAGE.items[0].licensed_item_checkout_id}" + ) + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + assert LicensedItemCheckoutRestGet(**data) diff --git a/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_purchases_handlers.py b/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_purchases_rest.py similarity index 93% rename from services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_purchases_handlers.py rename to services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_purchases_rest.py index ce0fddeca19..ee3656d2c1c 100644 --- a/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_purchases_handlers.py +++ b/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_purchases_rest.py @@ -49,7 +49,7 @@ @pytest.fixture def mock_get_licensed_items_purchases_page(mocker: MockerFixture) -> tuple: return mocker.patch( - "simcore_service_webserver.licenses._licensed_items_purchases_api.licensed_items_purchases.get_licensed_items_purchases_page", + "simcore_service_webserver.licenses._licensed_items_purchases_service.licensed_items_purchases.get_licensed_items_purchases_page", spec=True, return_value=_LICENSED_ITEM_PURCHASE_PAGE, ) @@ -58,7 +58,7 @@ def mock_get_licensed_items_purchases_page(mocker: MockerFixture) -> tuple: @pytest.fixture def mock_get_licensed_item_purchase(mocker: MockerFixture) -> tuple: return mocker.patch( - "simcore_service_webserver.licenses._licensed_items_purchases_api.licensed_items_purchases.get_licensed_item_purchase", + "simcore_service_webserver.licenses._licensed_items_purchases_service.licensed_items_purchases.get_licensed_item_purchase", spec=True, return_value=_LICENSED_ITEM_PURCHASE_GET, ) @@ -67,13 +67,13 @@ def mock_get_licensed_item_purchase(mocker: MockerFixture) -> tuple: @pytest.fixture def mock_get_wallet_by_user(mocker: MockerFixture) -> tuple: return mocker.patch( - "simcore_service_webserver.licenses._licensed_items_purchases_api.get_wallet_by_user", + "simcore_service_webserver.licenses._licensed_items_purchases_service.get_wallet_by_user", spec=True, ) @pytest.mark.parametrize("user_role,expected", [(UserRole.USER, status.HTTP_200_OK)]) -async def test_licensed_items_db_crud( +async def test_licensed_items_purchaches_handlers( client: TestClient, logged_user: UserInfoDict, expected: HTTPStatus, diff --git a/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_db.py b/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_repository.py similarity index 84% rename from services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_db.py rename to services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_repository.py index 910e1bdf3f4..dfe04e2e0d3 100644 --- a/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_db.py +++ b/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_repository.py @@ -16,7 +16,7 @@ from pytest_simcore.helpers.webserver_login import UserInfoDict from servicelib.aiohttp import status from simcore_service_webserver.db.models import UserRole -from simcore_service_webserver.licenses import _licensed_items_db +from simcore_service_webserver.licenses import _licensed_items_repository from simcore_service_webserver.licenses.errors import LicensedItemNotFoundError from simcore_service_webserver.projects.models import ProjectDict @@ -32,7 +32,7 @@ async def test_licensed_items_db_crud( ): assert client.app - output: tuple[int, list[LicensedItemDB]] = await _licensed_items_db.list_( + output: tuple[int, list[LicensedItemDB]] = await _licensed_items_repository.list_( client.app, product_name=osparc_product_name, offset=0, @@ -41,7 +41,7 @@ async def test_licensed_items_db_crud( ) assert output[0] == 0 - licensed_item_db = await _licensed_items_db.create( + licensed_item_db = await _licensed_items_repository.create( client.app, product_name=osparc_product_name, name="Model A", @@ -50,7 +50,7 @@ async def test_licensed_items_db_crud( ) _licensed_item_id = licensed_item_db.licensed_item_id - output: tuple[int, list[LicensedItemDB]] = await _licensed_items_db.list_( + output: tuple[int, list[LicensedItemDB]] = await _licensed_items_repository.list_( client.app, product_name=osparc_product_name, offset=0, @@ -59,35 +59,35 @@ async def test_licensed_items_db_crud( ) assert output[0] == 1 - licensed_item_db = await _licensed_items_db.get( + licensed_item_db = await _licensed_items_repository.get( client.app, licensed_item_id=_licensed_item_id, product_name=osparc_product_name, ) assert licensed_item_db.name == "Model A" - await _licensed_items_db.update( + await _licensed_items_repository.update( client.app, licensed_item_id=_licensed_item_id, product_name=osparc_product_name, updates=LicensedItemUpdateDB(name="Model B"), ) - licensed_item_db = await _licensed_items_db.get( + licensed_item_db = await _licensed_items_repository.get( client.app, licensed_item_id=_licensed_item_id, product_name=osparc_product_name, ) assert licensed_item_db.name == "Model B" - licensed_item_db = await _licensed_items_db.delete( + licensed_item_db = await _licensed_items_repository.delete( client.app, licensed_item_id=_licensed_item_id, product_name=osparc_product_name, ) with pytest.raises(LicensedItemNotFoundError): - await _licensed_items_db.get( + await _licensed_items_repository.get( client.app, licensed_item_id=_licensed_item_id, product_name=osparc_product_name, diff --git a/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_handlers.py b/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_rest.py similarity index 88% rename from services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_handlers.py rename to services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_rest.py index b1fee67dafa..67c36f2581b 100644 --- a/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_handlers.py +++ b/services/web/server/tests/unit/with_dbs/04/licenses/test_licensed_items_rest.py @@ -18,7 +18,7 @@ from pytest_simcore.helpers.webserver_login import UserInfoDict from servicelib.aiohttp import status from simcore_service_webserver.db.models import UserRole -from simcore_service_webserver.licenses import _licensed_items_db +from simcore_service_webserver.licenses import _licensed_items_repository from simcore_service_webserver.projects.models import ProjectDict @@ -39,7 +39,7 @@ async def test_licensed_items_listing( data, _ = await assert_status(resp, status.HTTP_200_OK) assert data == [] - licensed_item_db = await _licensed_items_db.create( + licensed_item_db = await _licensed_items_repository.create( client.app, product_name=osparc_product_name, name="Model A", @@ -67,7 +67,7 @@ async def test_licensed_items_listing( @pytest.fixture def mock_licensed_items_purchase_functions(mocker: MockerFixture) -> tuple: mock_wallet_credits = mocker.patch( - "simcore_service_webserver.licenses._licensed_items_api.get_wallet_with_available_credits_by_user_and_wallet", + "simcore_service_webserver.licenses._licensed_items_service.get_wallet_with_available_credits_by_user_and_wallet", spec=True, return_value=WalletGetWithAvailableCredits.model_validate( WalletGetWithAvailableCredits.model_config["json_schema_extra"]["examples"][ @@ -76,14 +76,14 @@ def mock_licensed_items_purchase_functions(mocker: MockerFixture) -> tuple: ), ) mock_get_pricing_unit = mocker.patch( - "simcore_service_webserver.licenses._licensed_items_api.get_pricing_plan_unit", + "simcore_service_webserver.licenses._licensed_items_service.get_pricing_plan_unit", spec=True, return_value=PricingUnitGet.model_validate( PricingUnitGet.model_config["json_schema_extra"]["examples"][0] ), ) mock_create_licensed_item_purchase = mocker.patch( - "simcore_service_webserver.licenses._licensed_items_api.licensed_items_purchases.create_licensed_item_purchase", + "simcore_service_webserver.licenses._licensed_items_service.licensed_items_purchases.create_licensed_item_purchase", spec=True, ) @@ -106,7 +106,7 @@ async def test_licensed_items_purchase( ): assert client.app - licensed_item_db = await _licensed_items_db.create( + licensed_item_db = await _licensed_items_repository.create( client.app, product_name=osparc_product_name, name="Model A", diff --git a/services/web/server/tests/unit/with_dbs/04/licenses/test_licenses_rpc.py b/services/web/server/tests/unit/with_dbs/04/licenses/test_licenses_rpc.py index 6888711b2da..836a7fe05e6 100644 --- a/services/web/server/tests/unit/with_dbs/04/licenses/test_licenses_rpc.py +++ b/services/web/server/tests/unit/with_dbs/04/licenses/test_licenses_rpc.py @@ -26,7 +26,7 @@ from settings_library.rabbit import RabbitSettings from simcore_postgres_database.models.users import UserRole from simcore_service_webserver.application_settings import ApplicationSettings -from simcore_service_webserver.licenses import _licensed_items_db +from simcore_service_webserver.licenses import _licensed_items_repository pytest_simcore_core_services_selection = [ "rabbit", @@ -73,7 +73,7 @@ async def rpc_client( @pytest.fixture def mock_get_wallet_by_user(mocker: MockerFixture) -> tuple: return mocker.patch( - "simcore_service_webserver.licenses._licensed_checkouts_api.get_wallet_by_user", + "simcore_service_webserver.licenses._licensed_items_checkouts_service.get_wallet_by_user", spec=True, ) @@ -86,7 +86,7 @@ def mock_get_wallet_by_user(mocker: MockerFixture) -> tuple: @pytest.fixture def mock_checkout_licensed_item(mocker: MockerFixture) -> tuple: return mocker.patch( - "simcore_service_webserver.licenses._licensed_checkouts_api.licensed_items_checkouts.checkout_licensed_item", + "simcore_service_webserver.licenses._licensed_items_checkouts_service.licensed_items_checkouts.checkout_licensed_item", spec=True, return_value=_LICENSED_ITEM_CHECKOUT_GET, ) @@ -95,7 +95,7 @@ def mock_checkout_licensed_item(mocker: MockerFixture) -> tuple: @pytest.fixture def mock_get_licensed_item_checkout(mocker: MockerFixture) -> tuple: return mocker.patch( - "simcore_service_webserver.licenses._licensed_checkouts_api.licensed_items_checkouts.get_licensed_item_checkout", + "simcore_service_webserver.licenses._licensed_items_checkouts_service.licensed_items_checkouts.get_licensed_item_checkout", spec=True, return_value=_LICENSED_ITEM_CHECKOUT_GET, ) @@ -104,7 +104,7 @@ def mock_get_licensed_item_checkout(mocker: MockerFixture) -> tuple: @pytest.fixture def mock_release_licensed_item(mocker: MockerFixture) -> tuple: return mocker.patch( - "simcore_service_webserver.licenses._licensed_checkouts_api.licensed_items_checkouts.release_licensed_item", + "simcore_service_webserver.licenses._licensed_items_checkouts_service.licensed_items_checkouts.release_licensed_item", spec=True, return_value=_LICENSED_ITEM_CHECKOUT_GET, ) @@ -132,7 +132,7 @@ async def test_license_checkout_workflow( assert len(result.items) == 0 assert result.total == 0 - license_item_db = await _licensed_items_db.create( + license_item_db = await _licensed_items_repository.create( client.app, product_name=osparc_product_name, name="Model A",