From 3e09bb1b8386b2e95f04d3a578df7a1a1e0489be Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Fri, 6 Dec 2024 15:21:48 +0100 Subject: [PATCH 01/84] @sanderegg review: rename patch instead of update --- api/specs/web-server/_users.py | 8 ++--- .../api_schemas_webserver/users.py | 12 ++++---- .../services/webserver.py | 6 ++-- .../tests/unit/_with_db/test_api_user.py | 2 +- .../users/_handlers.py | 6 ++-- .../simcore_service_webserver/users/api.py | 14 ++++----- .../tests/unit/isolated/test_users_models.py | 30 +++++++++---------- .../03/login/test_login_registration.py | 4 +-- .../tests/unit/with_dbs/03/test_users.py | 8 ++--- 9 files changed, 46 insertions(+), 44 deletions(-) diff --git a/api/specs/web-server/_users.py b/api/specs/web-server/_users.py index c161a7aa69a..cb1904f3bb7 100644 --- a/api/specs/web-server/_users.py +++ b/api/specs/web-server/_users.py @@ -7,7 +7,7 @@ from typing import Annotated from fastapi import APIRouter, Depends, status -from models_library.api_schemas_webserver.users import ProfileGet, ProfileUpdate +from models_library.api_schemas_webserver.users import MyProfileGet, MyProfilePatch from models_library.api_schemas_webserver.users_preferences import PatchRequestBody from models_library.generics import Envelope from models_library.user_preferences import PreferenceIdentifier @@ -34,7 +34,7 @@ @router.get( "/me", - response_model=Envelope[ProfileGet], + response_model=Envelope[MyProfileGet], ) async def get_my_profile(): ... @@ -44,7 +44,7 @@ async def get_my_profile(): "/me", status_code=status.HTTP_204_NO_CONTENT, ) -async def update_my_profile(_profile: ProfileUpdate): +async def update_my_profile(_profile: MyProfilePatch): ... @@ -54,7 +54,7 @@ async def update_my_profile(_profile: ProfileUpdate): deprecated=True, description="Use PATCH instead", ) -async def replace_my_profile(_profile: ProfileUpdate): +async def replace_my_profile(_profile: MyProfilePatch): ... 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 ae7b9f89504..f0dd3d8bcfb 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 @@ -13,17 +13,17 @@ from ._base import InputSchema, OutputSchema -class ProfilePrivacyGet(OutputSchema): +class MyProfilePrivacyGet(OutputSchema): hide_fullname: bool hide_email: bool -class ProfilePrivacyUpdate(InputSchema): +class MyProfilePrivacyPatch(InputSchema): hide_fullname: bool | None = None hide_email: bool | None = None -class ProfileGet(BaseModel): +class MyProfileGet(BaseModel): # WARNING: do not use InputSchema until front-end is updated! id: UserID user_name: Annotated[ @@ -45,7 +45,7 @@ class ProfileGet(BaseModel): ), ] = None - privacy: ProfilePrivacyGet + privacy: MyProfilePrivacyGet preferences: AggregatedPreferences model_config = ConfigDict( @@ -77,13 +77,13 @@ def _to_upper_string(cls, v): return v -class ProfileUpdate(BaseModel): +class MyProfilePatch(BaseModel): # WARNING: do not use InputSchema until front-end is updated! first_name: FirstNameStr | None = None last_name: LastNameStr | None = None user_name: Annotated[IDStr | None, Field(alias="userName")] = None - privacy: ProfilePrivacyUpdate | None = None + privacy: MyProfilePrivacyPatch | None = None model_config = ConfigDict( json_schema_extra={ diff --git a/services/api-server/src/simcore_service_api_server/services/webserver.py b/services/api-server/src/simcore_service_api_server/services/webserver.py index 9301b5ce42c..255ed711019 100644 --- a/services/api-server/src/simcore_service_api_server/services/webserver.py +++ b/services/api-server/src/simcore_service_api_server/services/webserver.py @@ -29,8 +29,10 @@ ProjectInputUpdate, ) from models_library.api_schemas_webserver.resource_usage import PricingPlanGet -from models_library.api_schemas_webserver.users import ProfileGet as WebProfileGet -from models_library.api_schemas_webserver.users import ProfileUpdate as WebProfileUpdate +from models_library.api_schemas_webserver.users import MyProfileGet as WebProfileGet +from models_library.api_schemas_webserver.users import ( + MyProfilePatch as WebProfileUpdate, +) from models_library.api_schemas_webserver.wallets import WalletGet from models_library.generics import Envelope from models_library.projects import ProjectID diff --git a/services/api-server/tests/unit/_with_db/test_api_user.py b/services/api-server/tests/unit/_with_db/test_api_user.py index 24836d1b3cd..93a3bdf8f68 100644 --- a/services/api-server/tests/unit/_with_db/test_api_user.py +++ b/services/api-server/tests/unit/_with_db/test_api_user.py @@ -9,7 +9,7 @@ import pytest import respx from fastapi import FastAPI -from models_library.api_schemas_webserver.users import ProfileGet as WebProfileGet +from models_library.api_schemas_webserver.users import MyProfileGet as WebProfileGet from respx import MockRouter from simcore_service_api_server._meta import API_VTAG from simcore_service_api_server.core.settings import ApplicationSettings diff --git a/services/web/server/src/simcore_service_webserver/users/_handlers.py b/services/web/server/src/simcore_service_webserver/users/_handlers.py index d67d772e0ee..25785673a03 100644 --- a/services/web/server/src/simcore_service_webserver/users/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/users/_handlers.py @@ -2,7 +2,7 @@ import logging from aiohttp import web -from models_library.api_schemas_webserver.users import ProfileGet, ProfileUpdate +from models_library.api_schemas_webserver.users import MyProfileGet, MyProfilePatch from models_library.users import UserID from pydantic import BaseModel, Field from servicelib.aiohttp import status @@ -74,7 +74,7 @@ async def wrapper(request: web.Request) -> web.StreamResponse: @_handle_users_exceptions async def get_my_profile(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) - profile: ProfileGet = await api.get_user_profile( + profile: MyProfileGet = await api.get_user_profile( request.app, req_ctx.user_id, req_ctx.product_name ) return envelope_json_response(profile) @@ -89,7 +89,7 @@ async def get_my_profile(request: web.Request) -> web.Response: @_handle_users_exceptions async def update_my_profile(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) - profile_update = await parse_request_body_as(ProfileUpdate, request) + profile_update = await parse_request_body_as(MyProfilePatch, request) await api.update_user_profile( request.app, user_id=req_ctx.user_id, update=profile_update diff --git a/services/web/server/src/simcore_service_webserver/users/api.py b/services/web/server/src/simcore_service_webserver/users/api.py index 7fc2c138204..750b9921763 100644 --- a/services/web/server/src/simcore_service_webserver/users/api.py +++ b/services/web/server/src/simcore_service_webserver/users/api.py @@ -15,9 +15,9 @@ from aiopg.sa.engine import Engine from aiopg.sa.result import RowProxy from models_library.api_schemas_webserver.users import ( - ProfileGet, - ProfilePrivacyGet, - ProfileUpdate, + MyProfileGet, + MyProfilePatch, + MyProfilePrivacyGet, ) from models_library.basic_types import IDStr from models_library.products import ProductName @@ -55,7 +55,7 @@ def _parse_as_user(user_id: Any) -> UserID: async def get_user_profile( app: web.Application, user_id: UserID, product_name: ProductName -) -> ProfileGet: +) -> MyProfileGet: """ :raises UserNotFoundError: :raises MissingGroupExtraPropertiesForProductError: when product is not properly configured @@ -136,7 +136,7 @@ async def get_user_profile( if user_profile.get("expiration_date"): optional["expiration_date"] = user_profile["expiration_date"] - return ProfileGet( + return MyProfileGet( id=user_profile["id"], user_name=user_profile["user_name"], first_name=user_profile["first_name"], @@ -148,7 +148,7 @@ async def get_user_profile( "organizations": user_standard_groups, "all": all_group, }, - privacy=ProfilePrivacyGet( + privacy=MyProfilePrivacyGet( hide_fullname=user_profile["privacy_hide_fullname"], hide_email=user_profile["privacy_hide_email"], ), @@ -161,7 +161,7 @@ async def update_user_profile( app: web.Application, *, user_id: UserID, - update: ProfileUpdate, + update: MyProfilePatch, ) -> None: """ Raises: diff --git a/services/web/server/tests/unit/isolated/test_users_models.py b/services/web/server/tests/unit/isolated/test_users_models.py index 8ff676476ee..db129b68550 100644 --- a/services/web/server/tests/unit/isolated/test_users_models.py +++ b/services/web/server/tests/unit/isolated/test_users_models.py @@ -11,9 +11,9 @@ import pytest from faker import Faker from models_library.api_schemas_webserver.users import ( - ProfileGet, - ProfilePrivacyGet, - ProfileUpdate, + MyProfileGet, + MyProfilePatch, + MyProfilePrivacyGet, ) from models_library.generics import Envelope from models_library.utils.fastapi_encoders import jsonable_encoder @@ -26,7 +26,7 @@ @pytest.mark.parametrize( "model_cls", - [ProfileGet, ThirdPartyToken], + [MyProfileGet, ThirdPartyToken], ) def test_user_models_examples( model_cls: type[BaseModel], model_cls_examples: dict[str, Any] @@ -51,23 +51,23 @@ def test_user_models_examples( @pytest.fixture -def fake_profile_get(faker: Faker) -> ProfileGet: +def fake_profile_get(faker: Faker) -> MyProfileGet: fake_profile: dict[str, Any] = faker.simple_profile() first, last = fake_profile["name"].rsplit(maxsplit=1) - return ProfileGet( + return MyProfileGet( id=faker.pyint(), first_name=first, last_name=last, user_name=fake_profile["username"], login=fake_profile["mail"], role="USER", - privacy=ProfilePrivacyGet(hide_fullname=True, hide_email=True), + privacy=MyProfilePrivacyGet(hide_fullname=True, hide_email=True), preferences={}, ) -def test_profile_get_expiration_date(fake_profile_get: ProfileGet): +def test_profile_get_expiration_date(fake_profile_get: MyProfileGet): fake_expiration = datetime.now(UTC) profile = fake_profile_get.model_copy( @@ -80,7 +80,7 @@ def test_profile_get_expiration_date(fake_profile_get: ProfileGet): assert body["expirationDate"] == fake_expiration.date().isoformat() -def test_auto_compute_gravatar__deprecated(fake_profile_get: ProfileGet): +def test_auto_compute_gravatar__deprecated(fake_profile_get: MyProfileGet): profile = fake_profile_get.model_copy() @@ -89,7 +89,7 @@ def test_auto_compute_gravatar__deprecated(fake_profile_get: ProfileGet): assert ( "gravatar_id" not in data - ), f"{ProfileGet.model_fields['gravatar_id'].deprecated=}" + ), f"{MyProfileGet.model_fields['gravatar_id'].deprecated=}" assert data["id"] == profile.id assert data["first_name"] == profile.first_name assert data["last_name"] == profile.last_name @@ -100,13 +100,13 @@ def test_auto_compute_gravatar__deprecated(fake_profile_get: ProfileGet): @pytest.mark.parametrize("user_role", [u.name for u in UserRole]) def test_profile_get_role(user_role: str): - for example in ProfileGet.model_json_schema()["examples"]: + for example in MyProfileGet.model_json_schema()["examples"]: data = deepcopy(example) data["role"] = user_role - m1 = ProfileGet(**data) + m1 = MyProfileGet(**data) data["role"] = UserRole(user_role) - m2 = ProfileGet(**data) + m2 = MyProfileGet(**data) assert m1 == m2 @@ -155,13 +155,13 @@ def test_parsing_output_of_get_user_profile(): }, } - profile = ProfileGet.model_validate(result_from_db_query_and_composition) + profile = MyProfileGet.model_validate(result_from_db_query_and_composition) assert "password" not in profile.model_dump(exclude_unset=True) def test_mapping_update_models_from_rest_to_db(): - profile_update = ProfileUpdate.model_validate( + profile_update = MyProfilePatch.model_validate( # request payload { "first_name": "foo", diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_registration.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_registration.py index 762642dfb5c..0ece8630d0f 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_registration.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_registration.py @@ -9,7 +9,7 @@ import pytest from aiohttp.test_utils import TestClient from faker import Faker -from models_library.api_schemas_webserver.users import ProfileGet +from models_library.api_schemas_webserver.users import MyProfileGet from models_library.products import ProductName from pytest_mock import MockerFixture from pytest_simcore.helpers.assert_checks import assert_error, assert_status @@ -494,7 +494,7 @@ async def test_registraton_with_invitation_for_trial_account( url = client.app.router["get_my_profile"].url_for() response = await client.get(url.path) data, _ = await assert_status(response, status.HTTP_200_OK) - profile = ProfileGet.model_validate(data) + profile = MyProfileGet.model_validate(data) expected = invitation.user["created_at"] + timedelta(days=TRIAL_DAYS) assert profile.expiration_date 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 4e2829c6fce..a872b98858c 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 @@ -18,7 +18,7 @@ from aiopg.sa.connection import SAConnection from faker import Faker from models_library.api_schemas_webserver.auth import AccountRequestInfo -from models_library.api_schemas_webserver.users import ProfileGet +from models_library.api_schemas_webserver.users import MyProfileGet from models_library.generics import Envelope from psycopg2 import OperationalError from pytest_simcore.helpers.assert_checks import assert_status @@ -117,7 +117,7 @@ async def test_get_profile( resp = await client.get(f"{url}") data, error = await assert_status(resp, status.HTTP_200_OK) - resp_model = Envelope[ProfileGet].model_validate(await resp.json()) + resp_model = Envelope[MyProfileGet].model_validate(await resp.json()) assert resp_model.data.model_dump(**RESPONSE_MODEL_POLICY, mode="json") == data assert resp_model.error is None @@ -202,7 +202,7 @@ async def test_profile_workflow( url = client.app.router["get_my_profile"].url_for() resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) - my_profile = ProfileGet.model_validate(data) + my_profile = MyProfileGet.model_validate(data) url = client.app.router["update_my_profile"].url_for() resp = await client.patch( @@ -218,7 +218,7 @@ async def test_profile_workflow( url = client.app.router["get_my_profile"].url_for() resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) - updated_profile = ProfileGet.model_validate(data) + updated_profile = MyProfileGet.model_validate(data) assert updated_profile.first_name != my_profile.first_name assert updated_profile.last_name == my_profile.last_name From 73c73f9f213063ca708f88054d6aafd2fe13e4d1 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Fri, 6 Dec 2024 15:22:47 +0100 Subject: [PATCH 02/84] rename ids --- .../api/v0/openapi.yaml | 292 +++++++++--------- 1 file changed, 146 insertions(+), 146 deletions(-) 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 bc73e5441d2..9da262c366e 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 @@ -1141,7 +1141,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Envelope_ProfileGet_' + $ref: '#/components/schemas/Envelope_MyProfileGet_' put: tags: - user @@ -1152,7 +1152,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ProfileUpdate' + $ref: '#/components/schemas/MyProfilePatch' required: true responses: '204': @@ -1167,7 +1167,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ProfileUpdate' + $ref: '#/components/schemas/MyProfilePatch' required: true responses: '204': @@ -7989,6 +7989,19 @@ components: title: Error type: object title: Envelope[MyGroupsGet] + Envelope_MyProfileGet_: + properties: + data: + anyOf: + - $ref: '#/components/schemas/MyProfileGet' + - type: 'null' + error: + anyOf: + - {} + - type: 'null' + title: Error + type: object + title: Envelope[MyProfileGet] Envelope_NodeCreated_: properties: data: @@ -8106,19 +8119,6 @@ components: title: Error type: object title: Envelope[PricingUnitGet] - Envelope_ProfileGet_: - properties: - data: - anyOf: - - $ref: '#/components/schemas/ProfileGet' - - type: 'null' - error: - anyOf: - - {} - - type: 'null' - title: Error - type: object - title: Envelope[ProfileGet] Envelope_ProjectGet_: properties: data: @@ -10379,6 +10379,136 @@ components: description: Some foundation gid: '16' label: Blue Fundation + MyProfileGet: + properties: + id: + type: integer + exclusiveMinimum: true + title: Id + minimum: 0 + userName: + type: string + maxLength: 100 + minLength: 1 + title: Username + description: Unique username identifier + first_name: + anyOf: + - type: string + maxLength: 255 + - type: 'null' + title: First Name + last_name: + anyOf: + - type: string + maxLength: 255 + - type: 'null' + title: Last Name + login: + type: string + format: email + title: Login + role: + type: string + enum: + - ANONYMOUS + - GUEST + - USER + - TESTER + - PRODUCT_OWNER + - ADMIN + title: Role + groups: + anyOf: + - $ref: '#/components/schemas/MyGroupsGet' + - type: 'null' + gravatar_id: + anyOf: + - type: string + - type: 'null' + title: Gravatar Id + deprecated: true + expirationDate: + anyOf: + - type: string + format: date + - type: 'null' + title: Expirationdate + description: If user has a trial account, it sets the expiration date, otherwise + None + privacy: + $ref: '#/components/schemas/MyProfilePrivacyGet' + preferences: + additionalProperties: + $ref: '#/components/schemas/Preference' + type: object + title: Preferences + type: object + required: + - id + - userName + - login + - role + - privacy + - preferences + title: MyProfileGet + MyProfilePatch: + properties: + first_name: + anyOf: + - type: string + maxLength: 255 + - type: 'null' + title: First Name + last_name: + anyOf: + - type: string + maxLength: 255 + - type: 'null' + title: Last Name + userName: + anyOf: + - type: string + maxLength: 100 + minLength: 1 + - type: 'null' + title: Username + privacy: + anyOf: + - $ref: '#/components/schemas/MyProfilePrivacyPatch' + - type: 'null' + type: object + title: MyProfilePatch + example: + first_name: Pedro + last_name: Crespo + MyProfilePrivacyGet: + properties: + hideFullname: + type: boolean + title: Hidefullname + hideEmail: + type: boolean + title: Hideemail + type: object + required: + - hideFullname + - hideEmail + title: MyProfilePrivacyGet + MyProfilePrivacyPatch: + properties: + hideFullname: + anyOf: + - type: boolean + - type: 'null' + title: Hidefullname + hideEmail: + anyOf: + - type: boolean + - type: 'null' + title: Hideemail + type: object + title: MyProfilePrivacyPatch Node-Input: properties: key: @@ -11661,136 +11791,6 @@ components: - currentCostPerUnit - default title: PricingUnitGet - ProfileGet: - properties: - id: - type: integer - exclusiveMinimum: true - title: Id - minimum: 0 - userName: - type: string - maxLength: 100 - minLength: 1 - title: Username - description: Unique username identifier - first_name: - anyOf: - - type: string - maxLength: 255 - - type: 'null' - title: First Name - last_name: - anyOf: - - type: string - maxLength: 255 - - type: 'null' - title: Last Name - login: - type: string - format: email - title: Login - role: - type: string - enum: - - ANONYMOUS - - GUEST - - USER - - TESTER - - PRODUCT_OWNER - - ADMIN - title: Role - groups: - anyOf: - - $ref: '#/components/schemas/MyGroupsGet' - - type: 'null' - gravatar_id: - anyOf: - - type: string - - type: 'null' - title: Gravatar Id - deprecated: true - expirationDate: - anyOf: - - type: string - format: date - - type: 'null' - title: Expirationdate - description: If user has a trial account, it sets the expiration date, otherwise - None - privacy: - $ref: '#/components/schemas/ProfilePrivacyGet' - preferences: - additionalProperties: - $ref: '#/components/schemas/Preference' - type: object - title: Preferences - type: object - required: - - id - - userName - - login - - role - - privacy - - preferences - title: ProfileGet - ProfilePrivacyGet: - properties: - hideFullname: - type: boolean - title: Hidefullname - hideEmail: - type: boolean - title: Hideemail - type: object - required: - - hideFullname - - hideEmail - title: ProfilePrivacyGet - ProfilePrivacyUpdate: - properties: - hideFullname: - anyOf: - - type: boolean - - type: 'null' - title: Hidefullname - hideEmail: - anyOf: - - type: boolean - - type: 'null' - title: Hideemail - type: object - title: ProfilePrivacyUpdate - ProfileUpdate: - properties: - first_name: - anyOf: - - type: string - maxLength: 255 - - type: 'null' - title: First Name - last_name: - anyOf: - - type: string - maxLength: 255 - - type: 'null' - title: Last Name - userName: - anyOf: - - type: string - maxLength: 100 - minLength: 1 - - type: 'null' - title: Username - privacy: - anyOf: - - $ref: '#/components/schemas/ProfilePrivacyUpdate' - - type: 'null' - type: object - title: ProfileUpdate - example: - first_name: Pedro - last_name: Crespo ProjectCopyOverride: properties: name: From 2a7af4e61b51608cd5e8f4f69f4af022aaca0c5c Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Fri, 6 Dec 2024 15:37:21 +0100 Subject: [PATCH 03/84] exception handlers groups --- .../groups/_exceptions_handlers.py | 49 ++++++++++++++++ .../groups/_handlers.py | 58 ++++--------------- 2 files changed, 61 insertions(+), 46 deletions(-) create mode 100644 services/web/server/src/simcore_service_webserver/groups/_exceptions_handlers.py diff --git a/services/web/server/src/simcore_service_webserver/groups/_exceptions_handlers.py b/services/web/server/src/simcore_service_webserver/groups/_exceptions_handlers.py new file mode 100644 index 00000000000..cc5ead8f726 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/groups/_exceptions_handlers.py @@ -0,0 +1,49 @@ +import logging + +from servicelib.aiohttp import status + +from ..exception_handling import ( + ExceptionToHttpErrorMap, + HttpErrorInfo, + exception_handling_decorator, + to_exceptions_handlers_map, +) +from ..users.exceptions import UserNotFoundError +from .exceptions import ( + GroupNotFoundError, + UserAlreadyInGroupError, + UserInGroupNotFoundError, + UserInsufficientRightsError, +) + +_logger = logging.getLogger(__name__) + + +_TO_HTTP_ERROR_MAP: ExceptionToHttpErrorMap = { + UserNotFoundError: HttpErrorInfo( + status.HTTP_404_NOT_FOUND, + "User {uid} or {email} not found", + ), + GroupNotFoundError: HttpErrorInfo( + status.HTTP_404_NOT_FOUND, + "Group {gid} not found", + ), + UserInGroupNotFoundError: HttpErrorInfo( + status.HTTP_404_NOT_FOUND, + "User not found in group {gid}", + ), + UserAlreadyInGroupError: HttpErrorInfo( + status.HTTP_409_CONFLICT, + "User is already in group {gid}", + ), + UserInsufficientRightsError: HttpErrorInfo( + status.HTTP_403_FORBIDDEN, + "Insufficient rights for {permission} access to group {gid}", + ), +} + + +handle_plugin_requests_exceptions = exception_handling_decorator( + to_exceptions_handlers_map(_TO_HTTP_ERROR_MAP) +) +# this is one decorator with a single exception handler diff --git a/services/web/server/src/simcore_service_webserver/groups/_handlers.py b/services/web/server/src/simcore_service_webserver/groups/_handlers.py index fac761aaf25..2b7ad27b602 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/groups/_handlers.py @@ -32,16 +32,11 @@ from ..scicrunch.models import ResearchResource, ResourceHit from ..scicrunch.service_client import SciCrunch from ..security.decorators import permission_required -from ..users.exceptions import UserNotFoundError from ..utils_aiohttp import envelope_json_response from . import api from ._classifiers import GroupClassifierRepository, build_rrids_tree_view -from .exceptions import ( - GroupNotFoundError, - UserAlreadyInGroupError, - UserInGroupNotFoundError, - UserInsufficientRightsError, -) +from ._exceptions_handlers import handle_plugin_requests_exceptions +from .exceptions import GroupNotFoundError _logger = logging.getLogger(__name__) @@ -51,42 +46,13 @@ class _GroupsRequestContext(BaseModel): product_name: str = Field(..., alias=RQ_PRODUCT_KEY) # type: ignore[literal-required] -def _handle_groups_exceptions(handler: Handler): - @functools.wraps(handler) - async def wrapper(request: web.Request) -> web.StreamResponse: - try: - return await handler(request) - - except UserNotFoundError as exc: - raise web.HTTPNotFound( - reason=f"User {exc.uid or exc.email} not found" - ) from exc - - except GroupNotFoundError as exc: - gid = getattr(exc, "gid", "") - raise web.HTTPNotFound(reason=f"Group {gid} not found") from exc - - except UserInGroupNotFoundError as exc: - gid = getattr(exc, "gid", "") - raise web.HTTPNotFound(reason=f"User not found in group {gid}") from exc - - except UserAlreadyInGroupError as exc: - gid = getattr(exc, "gid", "") - raise web.HTTPConflict(reason=f"User is already in group {gid}") from exc - - except UserInsufficientRightsError as exc: - raise web.HTTPForbidden from exc - - return wrapper - - routes = web.RouteTableDef() @routes.get(f"/{API_VTAG}/groups", name="list_groups") @login_required @permission_required("groups.read") -@_handle_groups_exceptions +@handle_plugin_requests_exceptions async def list_groups(request: web.Request): """ List all groups (organizations, primary, everyone and products) I belong to @@ -131,7 +97,7 @@ class _GroupPathParams(BaseModel): @routes.get(f"/{API_VTAG}/groups/{{gid}}", name="get_group") @login_required @permission_required("groups.read") -@_handle_groups_exceptions +@handle_plugin_requests_exceptions async def get_group(request: web.Request): """Get one group details""" req_ctx = _GroupsRequestContext.model_validate(request) @@ -145,7 +111,7 @@ async def get_group(request: web.Request): @routes.post(f"/{API_VTAG}/groups", name="create_group") @login_required @permission_required("groups.*") -@_handle_groups_exceptions +@handle_plugin_requests_exceptions async def create_group(request: web.Request): """Creates organization groups""" req_ctx = _GroupsRequestContext.model_validate(request) @@ -160,7 +126,7 @@ async def create_group(request: web.Request): @routes.patch(f"/{API_VTAG}/groups/{{gid}}", name="update_group") @login_required @permission_required("groups.*") -@_handle_groups_exceptions +@handle_plugin_requests_exceptions async def update_group(request: web.Request): """Updates organization groups""" req_ctx = _GroupsRequestContext.model_validate(request) @@ -178,7 +144,7 @@ async def update_group(request: web.Request): @routes.delete(f"/{API_VTAG}/groups/{{gid}}", name="delete_group") @login_required @permission_required("groups.*") -@_handle_groups_exceptions +@handle_plugin_requests_exceptions async def delete_group(request: web.Request): """Deletes organization groups""" req_ctx = _GroupsRequestContext.model_validate(request) @@ -196,7 +162,7 @@ async def delete_group(request: web.Request): @routes.get(f"/{API_VTAG}/groups/{{gid}}/users", name="get_all_group_users") @login_required @permission_required("groups.*") -@_handle_groups_exceptions +@handle_plugin_requests_exceptions async def get_group_users(request: web.Request): """Gets users in organization groups""" req_ctx = _GroupsRequestContext.model_validate(request) @@ -214,7 +180,7 @@ async def get_group_users(request: web.Request): @routes.post(f"/{API_VTAG}/groups/{{gid}}/users", name="add_group_user") @login_required @permission_required("groups.*") -@_handle_groups_exceptions +@handle_plugin_requests_exceptions async def add_group_user(request: web.Request): """ Adds a user in an organization group @@ -242,7 +208,7 @@ class _GroupUserPathParams(BaseModel): @routes.get(f"/{API_VTAG}/groups/{{gid}}/users/{{uid}}", name="get_group_user") @login_required @permission_required("groups.*") -@_handle_groups_exceptions +@handle_plugin_requests_exceptions async def get_group_user(request: web.Request): """ Gets specific user in an organization group @@ -259,7 +225,7 @@ async def get_group_user(request: web.Request): @routes.patch(f"/{API_VTAG}/groups/{{gid}}/users/{{uid}}", name="update_group_user") @login_required @permission_required("groups.*") -@_handle_groups_exceptions +@handle_plugin_requests_exceptions async def update_group_user(request: web.Request): req_ctx = _GroupsRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(_GroupUserPathParams, request) @@ -279,7 +245,7 @@ async def update_group_user(request: web.Request): @routes.delete(f"/{API_VTAG}/groups/{{gid}}/users/{{uid}}", name="delete_group_user") @login_required @permission_required("groups.*") -@_handle_groups_exceptions +@handle_plugin_requests_exceptions async def delete_group_user(request: web.Request): req_ctx = _GroupsRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(_GroupUserPathParams, request) From e370faf4ffcf6b6e16497d9c7522d632a28eade1 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Fri, 6 Dec 2024 15:56:26 +0100 Subject: [PATCH 04/84] models --- api/specs/web-server/_groups.py | 32 +-- .../groups/_exceptions_handlers.py | 10 + .../groups/_handlers.py | 182 ++---------------- .../groups/_models.py | 28 +++ .../groups/_scicrunch_handlers.py | 112 +++++++++++ 5 files changed, 186 insertions(+), 178 deletions(-) create mode 100644 services/web/server/src/simcore_service_webserver/groups/_models.py create mode 100644 services/web/server/src/simcore_service_webserver/groups/_scicrunch_handlers.py diff --git a/api/specs/web-server/_groups.py b/api/specs/web-server/_groups.py index 9fa015bd7b5..a48e823690c 100644 --- a/api/specs/web-server/_groups.py +++ b/api/specs/web-server/_groups.py @@ -11,17 +11,17 @@ GroupCreate, GroupGet, GroupUpdate, + GroupUserAdd, GroupUserGet, + GroupUserUpdate, MyGroupsGet, ) from models_library.generics import Envelope from simcore_service_webserver._meta import API_VTAG -from simcore_service_webserver.groups._handlers import ( - GroupUserAdd, - GroupUserUpdate, - _ClassifiersQuery, - _GroupPathParams, - _GroupUserPathParams, +from simcore_service_webserver.groups._models import ( + GroupsClassifiersQuery, + GroupsPathParams, + GroupsUsersPathParams, ) from simcore_service_webserver.scicrunch.models import ResearchResource, ResourceHit @@ -58,7 +58,7 @@ async def create_group(_body: GroupCreate): "/groups/{gid}", response_model=Envelope[GroupGet], ) -async def get_group(_path: Annotated[_GroupPathParams, Depends()]): +async def get_group(_path: Annotated[GroupsPathParams, Depends()]): """ Get an organization group """ @@ -69,7 +69,7 @@ async def get_group(_path: Annotated[_GroupPathParams, Depends()]): response_model=Envelope[GroupGet], ) async def update_group( - _path: Annotated[_GroupPathParams, Depends()], + _path: Annotated[GroupsPathParams, Depends()], _body: GroupUpdate, ): """ @@ -81,7 +81,7 @@ async def update_group( "/groups/{gid}", status_code=status.HTTP_204_NO_CONTENT, ) -async def delete_group(_path: Annotated[_GroupPathParams, Depends()]): +async def delete_group(_path: Annotated[GroupsPathParams, Depends()]): """ Deletes organization groups """ @@ -91,7 +91,7 @@ async def delete_group(_path: Annotated[_GroupPathParams, Depends()]): "/groups/{gid}/users", response_model=Envelope[list[GroupUserGet]], ) -async def get_all_group_users(_path: Annotated[_GroupPathParams, Depends()]): +async def get_all_group_users(_path: Annotated[GroupsPathParams, Depends()]): """ Gets users in organization groups """ @@ -102,7 +102,7 @@ async def get_all_group_users(_path: Annotated[_GroupPathParams, Depends()]): status_code=status.HTTP_204_NO_CONTENT, ) async def add_group_user( - _path: Annotated[_GroupPathParams, Depends()], + _path: Annotated[GroupsPathParams, Depends()], _body: GroupUserAdd, ): """ @@ -115,7 +115,7 @@ async def add_group_user( response_model=Envelope[GroupUserGet], ) async def get_group_user( - _path: Annotated[_GroupUserPathParams, Depends()], + _path: Annotated[GroupsUsersPathParams, Depends()], ): """ Gets specific user in an organization group @@ -127,7 +127,7 @@ async def get_group_user( response_model=Envelope[GroupUserGet], ) async def update_group_user( - _path: Annotated[_GroupUserPathParams, Depends()], + _path: Annotated[GroupsUsersPathParams, Depends()], _body: GroupUserUpdate, ): """ @@ -140,7 +140,7 @@ async def update_group_user( status_code=status.HTTP_204_NO_CONTENT, ) async def delete_group_user( - _path: Annotated[_GroupUserPathParams, Depends()], + _path: Annotated[GroupsUsersPathParams, Depends()], ): """ Removes a user from an organization group @@ -157,8 +157,8 @@ async def delete_group_user( response_model=Envelope[dict[str, Any]], ) async def get_group_classifiers( - _path: Annotated[_GroupPathParams, Depends()], - _query: Annotated[_ClassifiersQuery, Depends()], + _path: Annotated[GroupsPathParams, Depends()], + _query: Annotated[GroupsClassifiersQuery, Depends()], ): ... diff --git a/services/web/server/src/simcore_service_webserver/groups/_exceptions_handlers.py b/services/web/server/src/simcore_service_webserver/groups/_exceptions_handlers.py index cc5ead8f726..4d0f44d6d36 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_exceptions_handlers.py +++ b/services/web/server/src/simcore_service_webserver/groups/_exceptions_handlers.py @@ -8,6 +8,7 @@ exception_handling_decorator, to_exceptions_handlers_map, ) +from ..scicrunch.errors import InvalidRRIDError, ScicrunchError from ..users.exceptions import UserNotFoundError from .exceptions import ( GroupNotFoundError, @@ -40,6 +41,15 @@ status.HTTP_403_FORBIDDEN, "Insufficient rights for {permission} access to group {gid}", ), + # scicrunch + InvalidRRIDError: HttpErrorInfo( + status.HTTP_409_CONFLICT, + "Invalid RRID {rrid}", + ), + ScicrunchError: HttpErrorInfo( + status.HTTP_409_CONFLICT, + "Cannot get RRID since scicrunch.org service is not reachable.", + ), } diff --git a/services/web/server/src/simcore_service_webserver/groups/_handlers.py b/services/web/server/src/simcore_service_webserver/groups/_handlers.py index 2b7ad27b602..920785de53a 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/groups/_handlers.py @@ -1,7 +1,5 @@ -import functools import logging from contextlib import suppress -from typing import Literal from aiohttp import web from models_library.api_schemas_webserver.groups import ( @@ -13,39 +11,26 @@ GroupUserUpdate, MyGroupsGet, ) -from models_library.users import GroupID, UserID -from pydantic import BaseModel, ConfigDict, Field, TypeAdapter +from pydantic import TypeAdapter from servicelib.aiohttp import status from servicelib.aiohttp.requests_validation import ( parse_request_body_as, parse_request_path_parameters_as, - parse_request_query_parameters_as, ) -from servicelib.aiohttp.typing_extension import Handler -from .._constants import RQ_PRODUCT_KEY, RQT_USERID_KEY from .._meta import API_VTAG from ..login.decorators import login_required from ..products.api import Product, get_current_product -from ..scicrunch.db import ResearchResourceRepository -from ..scicrunch.errors import InvalidRRIDError, ScicrunchError -from ..scicrunch.models import ResearchResource, ResourceHit -from ..scicrunch.service_client import SciCrunch from ..security.decorators import permission_required from ..utils_aiohttp import envelope_json_response from . import api -from ._classifiers import GroupClassifierRepository, build_rrids_tree_view from ._exceptions_handlers import handle_plugin_requests_exceptions +from ._models import GroupsPathParams, GroupsRequestContext, GroupsUsersPathParams from .exceptions import GroupNotFoundError _logger = logging.getLogger(__name__) -class _GroupsRequestContext(BaseModel): - user_id: UserID = Field(..., alias=RQT_USERID_KEY) # type: ignore[literal-required] - product_name: str = Field(..., alias=RQ_PRODUCT_KEY) # type: ignore[literal-required] - - routes = web.RouteTableDef() @@ -58,7 +43,7 @@ async def list_groups(request: web.Request): List all groups (organizations, primary, everyone and products) I belong to """ product: Product = get_current_product(request) - req_ctx = _GroupsRequestContext.model_validate(request) + req_ctx = GroupsRequestContext.model_validate(request) primary_group, user_groups, all_group = await api.list_user_groups_with_read_access( request.app, req_ctx.user_id @@ -89,19 +74,14 @@ async def list_groups(request: web.Request): # -class _GroupPathParams(BaseModel): - gid: GroupID - model_config = ConfigDict(extra="forbid") - - @routes.get(f"/{API_VTAG}/groups/{{gid}}", name="get_group") @login_required @permission_required("groups.read") @handle_plugin_requests_exceptions async def get_group(request: web.Request): """Get one group details""" - req_ctx = _GroupsRequestContext.model_validate(request) - path_params = parse_request_path_parameters_as(_GroupPathParams, request) + req_ctx = GroupsRequestContext.model_validate(request) + path_params = parse_request_path_parameters_as(GroupsPathParams, request) group = await api.get_user_group(request.app, req_ctx.user_id, path_params.gid) assert GroupGet.model_validate(group) is not None # nosec @@ -114,7 +94,7 @@ async def get_group(request: web.Request): @handle_plugin_requests_exceptions async def create_group(request: web.Request): """Creates organization groups""" - req_ctx = _GroupsRequestContext.model_validate(request) + req_ctx = GroupsRequestContext.model_validate(request) create = await parse_request_body_as(GroupCreate, request) new_group = create.model_dump(mode="json", exclude_unset=True) @@ -129,8 +109,8 @@ async def create_group(request: web.Request): @handle_plugin_requests_exceptions async def update_group(request: web.Request): """Updates organization groups""" - req_ctx = _GroupsRequestContext.model_validate(request) - path_params = parse_request_path_parameters_as(_GroupPathParams, request) + req_ctx = GroupsRequestContext.model_validate(request) + path_params = parse_request_path_parameters_as(GroupsPathParams, request) update: GroupUpdate = await parse_request_body_as(GroupUpdate, request) new_group_values = update.model_dump(exclude_unset=True) @@ -147,8 +127,8 @@ async def update_group(request: web.Request): @handle_plugin_requests_exceptions async def delete_group(request: web.Request): """Deletes organization groups""" - req_ctx = _GroupsRequestContext.model_validate(request) - path_params = parse_request_path_parameters_as(_GroupPathParams, request) + req_ctx = GroupsRequestContext.model_validate(request) + path_params = parse_request_path_parameters_as(GroupsPathParams, request) await api.delete_user_group(request.app, req_ctx.user_id, path_params.gid) return web.json_response(status=status.HTTP_204_NO_CONTENT) @@ -165,8 +145,8 @@ async def delete_group(request: web.Request): @handle_plugin_requests_exceptions async def get_group_users(request: web.Request): """Gets users in organization groups""" - req_ctx = _GroupsRequestContext.model_validate(request) - path_params = parse_request_path_parameters_as(_GroupPathParams, request) + req_ctx = GroupsRequestContext.model_validate(request) + path_params = parse_request_path_parameters_as(GroupsPathParams, request) group_user = await api.list_users_in_group( request.app, req_ctx.user_id, path_params.gid @@ -185,8 +165,8 @@ async def add_group_user(request: web.Request): """ Adds a user in an organization group """ - req_ctx = _GroupsRequestContext.model_validate(request) - path_params = parse_request_path_parameters_as(_GroupPathParams, request) + req_ctx = GroupsRequestContext.model_validate(request) + path_params = parse_request_path_parameters_as(GroupsPathParams, request) added: GroupUserAdd = await parse_request_body_as(GroupUserAdd, request) await api.add_user_in_group( @@ -199,12 +179,6 @@ async def add_group_user(request: web.Request): return web.json_response(status=status.HTTP_204_NO_CONTENT) -class _GroupUserPathParams(BaseModel): - gid: GroupID - uid: UserID - model_config = ConfigDict(extra="forbid") - - @routes.get(f"/{API_VTAG}/groups/{{gid}}/users/{{uid}}", name="get_group_user") @login_required @permission_required("groups.*") @@ -213,8 +187,8 @@ async def get_group_user(request: web.Request): """ Gets specific user in an organization group """ - req_ctx = _GroupsRequestContext.model_validate(request) - path_params = parse_request_path_parameters_as(_GroupUserPathParams, request) + req_ctx = GroupsRequestContext.model_validate(request) + path_params = parse_request_path_parameters_as(GroupsUsersPathParams, request) user = await api.get_user_in_group( request.app, req_ctx.user_id, path_params.gid, path_params.uid ) @@ -227,8 +201,8 @@ async def get_group_user(request: web.Request): @permission_required("groups.*") @handle_plugin_requests_exceptions async def update_group_user(request: web.Request): - req_ctx = _GroupsRequestContext.model_validate(request) - path_params = parse_request_path_parameters_as(_GroupUserPathParams, request) + req_ctx = GroupsRequestContext.model_validate(request) + path_params = parse_request_path_parameters_as(GroupsUsersPathParams, request) update: GroupUserUpdate = await parse_request_body_as(GroupUserUpdate, request) user = await api.update_user_in_group( @@ -247,125 +221,9 @@ async def update_group_user(request: web.Request): @permission_required("groups.*") @handle_plugin_requests_exceptions async def delete_group_user(request: web.Request): - req_ctx = _GroupsRequestContext.model_validate(request) - path_params = parse_request_path_parameters_as(_GroupUserPathParams, request) + req_ctx = GroupsRequestContext.model_validate(request) + path_params = parse_request_path_parameters_as(GroupsUsersPathParams, request) await api.delete_user_in_group( request.app, req_ctx.user_id, path_params.gid, path_params.uid ) return web.json_response(status=status.HTTP_204_NO_CONTENT) - - -# -# Classifiers -# - - -class _GroupsParams(BaseModel): - gid: GroupID - - -class _ClassifiersQuery(BaseModel): - tree_view: Literal["std"] = "std" - - -@routes.get(f"/{API_VTAG}/groups/{{gid}}/classifiers", name="get_group_classifiers") -@login_required -@permission_required("groups.*") -async def get_group_classifiers(request: web.Request): - try: - path_params = parse_request_path_parameters_as(_GroupsParams, request) - query_params: _ClassifiersQuery = parse_request_query_parameters_as( - _ClassifiersQuery, request - ) - - repo = GroupClassifierRepository(request.app) - if not await repo.group_uses_scicrunch(path_params.gid): - return await repo.get_classifiers_from_bundle(path_params.gid) - - # otherwise, build dynamic tree with RRIDs - view = await build_rrids_tree_view( - request.app, tree_view_mode=query_params.tree_view - ) - except ScicrunchError: - view = {} - - return envelope_json_response(view) - - -def _handle_scicrunch_exceptions(handler: Handler): - @functools.wraps(handler) - async def wrapper(request: web.Request) -> web.StreamResponse: - try: - return await handler(request) - - except InvalidRRIDError as err: - raise web.HTTPBadRequest(reason=f"{err}") from err - - except ScicrunchError as err: - user_msg = "Cannot get RRID since scicrunch.org service is not reachable." - _logger.exception("%s", user_msg) - raise web.HTTPServiceUnavailable(reason=user_msg) from err - - return wrapper - - -@routes.get( - f"/{API_VTAG}/groups/sparc/classifiers/scicrunch-resources/{{rrid}}", - name="get_scicrunch_resource", -) -@login_required -@permission_required("groups.*") -@_handle_scicrunch_exceptions -async def get_scicrunch_resource(request: web.Request): - rrid = request.match_info["rrid"] - rrid = SciCrunch.validate_identifier(rrid) - - # check if in database first - repo = ResearchResourceRepository(request.app) - resource: ResearchResource | None = await repo.get_resource(rrid) - if not resource: - # otherwise, request to scicrunch service - scicrunch = SciCrunch.get_instance(request.app) - resource = await scicrunch.get_resource_fields(rrid) - - return envelope_json_response(resource.model_dump()) - - -@routes.post( - f"/{API_VTAG}/groups/sparc/classifiers/scicrunch-resources/{{rrid}}", - name="add_scicrunch_resource", -) -@login_required -@permission_required("groups.*") -@_handle_scicrunch_exceptions -async def add_scicrunch_resource(request: web.Request): - rrid = request.match_info["rrid"] - - # check if exists - repo = ResearchResourceRepository(request.app) - resource: ResearchResource | None = await repo.get_resource(rrid) - if not resource: - # then request scicrunch service - scicrunch = SciCrunch.get_instance(request.app) - resource = await scicrunch.get_resource_fields(rrid) - - # insert new or if exists, then update - await repo.upsert(resource) - - return envelope_json_response(resource.model_dump()) - - -@routes.get( - f"/{API_VTAG}/groups/sparc/classifiers/scicrunch-resources:search", - name="search_scicrunch_resources", -) -@login_required -@permission_required("groups.*") -@_handle_scicrunch_exceptions -async def search_scicrunch_resources(request: web.Request): - guess_name = str(request.query["guess_name"]).strip() - - scicrunch = SciCrunch.get_instance(request.app) - hits: list[ResourceHit] = await scicrunch.search_resource(guess_name) - - return envelope_json_response([hit.model_dump() for hit in hits]) diff --git a/services/web/server/src/simcore_service_webserver/groups/_models.py b/services/web/server/src/simcore_service_webserver/groups/_models.py new file mode 100644 index 00000000000..55f756f3f4e --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/groups/_models.py @@ -0,0 +1,28 @@ +import logging +from typing import Literal + +from models_library.rest_base import RequestParameters, StrictRequestParameters +from models_library.users import GroupID, UserID +from pydantic import Field + +from .._constants import RQ_PRODUCT_KEY, RQT_USERID_KEY + +_logger = logging.getLogger(__name__) + + +class GroupsRequestContext(RequestParameters): + user_id: UserID = Field(..., alias=RQT_USERID_KEY) # type: ignore[literal-required] + product_name: str = Field(..., alias=RQ_PRODUCT_KEY) # type: ignore[literal-required] + + +class GroupsPathParams(StrictRequestParameters): + gid: GroupID + + +class GroupsUsersPathParams(StrictRequestParameters): + gid: GroupID + uid: UserID + + +class GroupsClassifiersQuery(RequestParameters): + tree_view: Literal["std"] = "std" diff --git a/services/web/server/src/simcore_service_webserver/groups/_scicrunch_handlers.py b/services/web/server/src/simcore_service_webserver/groups/_scicrunch_handlers.py new file mode 100644 index 00000000000..d683989b09f --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/groups/_scicrunch_handlers.py @@ -0,0 +1,112 @@ +import logging + +from aiohttp import web +from servicelib.aiohttp.requests_validation import ( + parse_request_path_parameters_as, + parse_request_query_parameters_as, +) + +from .._meta import API_VTAG +from ..login.decorators import login_required +from ..scicrunch.db import ResearchResourceRepository +from ..scicrunch.errors import ScicrunchError +from ..scicrunch.models import ResearchResource, ResourceHit +from ..scicrunch.service_client import SciCrunch +from ..security.decorators import permission_required +from ..utils_aiohttp import envelope_json_response +from ._classifiers import GroupClassifierRepository, build_rrids_tree_view +from ._exceptions_handlers import handle_plugin_requests_exceptions +from ._models import GroupsClassifiersQuery, GroupsPathParams + +_logger = logging.getLogger(__name__) + + +routes = web.RouteTableDef() + + +@routes.get(f"/{API_VTAG}/groups/{{gid}}/classifiers", name="get_group_classifiers") +@login_required +@permission_required("groups.*") +@handle_plugin_requests_exceptions +async def get_group_classifiers(request: web.Request): + try: + path_params = parse_request_path_parameters_as(GroupsPathParams, request) + query_params: GroupsClassifiersQuery = parse_request_query_parameters_as( + GroupsClassifiersQuery, request + ) + + repo = GroupClassifierRepository(request.app) + if not await repo.group_uses_scicrunch(path_params.gid): + bundle = await repo.get_classifiers_from_bundle(path_params.gid) + return envelope_json_response(bundle) + + # otherwise, build dynamic tree with RRIDs + view = await build_rrids_tree_view( + request.app, tree_view_mode=query_params.tree_view + ) + except ScicrunchError: + view = {} + + return envelope_json_response(view) + + +@routes.get( + f"/{API_VTAG}/groups/sparc/classifiers/scicrunch-resources/{{rrid}}", + name="get_scicrunch_resource", +) +@login_required +@permission_required("groups.*") +@handle_plugin_requests_exceptions +async def get_scicrunch_resource(request: web.Request): + rrid = request.match_info["rrid"] + rrid = SciCrunch.validate_identifier(rrid) + + # check if in database first + repo = ResearchResourceRepository(request.app) + resource: ResearchResource | None = await repo.get_resource(rrid) + if not resource: + # otherwise, request to scicrunch service + scicrunch = SciCrunch.get_instance(request.app) + resource = await scicrunch.get_resource_fields(rrid) + + return envelope_json_response(resource.model_dump()) + + +@routes.post( + f"/{API_VTAG}/groups/sparc/classifiers/scicrunch-resources/{{rrid}}", + name="add_scicrunch_resource", +) +@login_required +@permission_required("groups.*") +@handle_plugin_requests_exceptions +async def add_scicrunch_resource(request: web.Request): + rrid = request.match_info["rrid"] + + # check if exists + repo = ResearchResourceRepository(request.app) + resource: ResearchResource | None = await repo.get_resource(rrid) + if not resource: + # then request scicrunch service + scicrunch = SciCrunch.get_instance(request.app) + resource = await scicrunch.get_resource_fields(rrid) + + # insert new or if exists, then update + await repo.upsert(resource) + + return envelope_json_response(resource.model_dump()) + + +@routes.get( + f"/{API_VTAG}/groups/sparc/classifiers/scicrunch-resources:search", + name="search_scicrunch_resources", +) +@login_required +@permission_required("groups.*") +@handle_plugin_requests_exceptions +async def search_scicrunch_resources(request: web.Request): + guess_name = str(request.query["guess_name"]).strip() + + scicrunch = SciCrunch.get_instance(request.app) + hits: list[ResourceHit] = await scicrunch.search_resource(guess_name) + + return envelope_json_response([hit.model_dump() for hit in hits]) From 22f22dbddde7ca7d9270dcecf42757045dfd1cd6 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Fri, 6 Dec 2024 15:58:12 +0100 Subject: [PATCH 05/84] mv to common --- .../src/simcore_service_webserver/groups/_common/__init__.py | 0 .../groups/{_models.py => _common/models.py} | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 services/web/server/src/simcore_service_webserver/groups/_common/__init__.py rename services/web/server/src/simcore_service_webserver/groups/{_models.py => _common/models.py} (100%) diff --git a/services/web/server/src/simcore_service_webserver/groups/_common/__init__.py b/services/web/server/src/simcore_service_webserver/groups/_common/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/services/web/server/src/simcore_service_webserver/groups/_models.py b/services/web/server/src/simcore_service_webserver/groups/_common/models.py similarity index 100% rename from services/web/server/src/simcore_service_webserver/groups/_models.py rename to services/web/server/src/simcore_service_webserver/groups/_common/models.py From 17675c9015b296c5c1a8f907a422b292307026e5 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Fri, 6 Dec 2024 15:58:39 +0100 Subject: [PATCH 06/84] exception handlers --- .../{_exceptions_handlers.py => _common/exceptions_handlers.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename services/web/server/src/simcore_service_webserver/groups/{_exceptions_handlers.py => _common/exceptions_handlers.py} (100%) diff --git a/services/web/server/src/simcore_service_webserver/groups/_exceptions_handlers.py b/services/web/server/src/simcore_service_webserver/groups/_common/exceptions_handlers.py similarity index 100% rename from services/web/server/src/simcore_service_webserver/groups/_exceptions_handlers.py rename to services/web/server/src/simcore_service_webserver/groups/_common/exceptions_handlers.py From 2ba30f981ca63c3b876bccd1b53cbd2a9119808b Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Fri, 6 Dec 2024 16:00:43 +0100 Subject: [PATCH 07/84] moving classifiers and users" --- api/specs/web-server/_groups.py | 2 +- .../groups/{_classifiers.py => _classifiers_api.py} | 0 .../{_scicrunch_handlers.py => _classifiers_handlers.py} | 6 +++--- .../groups/_common/exceptions_handlers.py | 8 ++++---- .../simcore_service_webserver/groups/_common/models.py | 2 +- .../server/src/simcore_service_webserver/groups/_db.py | 2 +- .../src/simcore_service_webserver/groups/_handlers.py | 8 ++++++-- .../groups/{_users.py => _users_api.py} | 0 .../server/src/simcore_service_webserver/groups/plugin.py | 3 ++- .../tests/unit/with_dbs/01/test_groups_classifiers.py | 2 +- 10 files changed, 19 insertions(+), 14 deletions(-) rename services/web/server/src/simcore_service_webserver/groups/{_classifiers.py => _classifiers_api.py} (100%) rename services/web/server/src/simcore_service_webserver/groups/{_scicrunch_handlers.py => _classifiers_handlers.py} (94%) rename services/web/server/src/simcore_service_webserver/groups/{_users.py => _users_api.py} (100%) diff --git a/api/specs/web-server/_groups.py b/api/specs/web-server/_groups.py index a48e823690c..7d30714df37 100644 --- a/api/specs/web-server/_groups.py +++ b/api/specs/web-server/_groups.py @@ -18,7 +18,7 @@ ) from models_library.generics import Envelope from simcore_service_webserver._meta import API_VTAG -from simcore_service_webserver.groups._models import ( +from simcore_service_webserver.groups._common.models import ( GroupsClassifiersQuery, GroupsPathParams, GroupsUsersPathParams, diff --git a/services/web/server/src/simcore_service_webserver/groups/_classifiers.py b/services/web/server/src/simcore_service_webserver/groups/_classifiers_api.py similarity index 100% rename from services/web/server/src/simcore_service_webserver/groups/_classifiers.py rename to services/web/server/src/simcore_service_webserver/groups/_classifiers_api.py diff --git a/services/web/server/src/simcore_service_webserver/groups/_scicrunch_handlers.py b/services/web/server/src/simcore_service_webserver/groups/_classifiers_handlers.py similarity index 94% rename from services/web/server/src/simcore_service_webserver/groups/_scicrunch_handlers.py rename to services/web/server/src/simcore_service_webserver/groups/_classifiers_handlers.py index d683989b09f..910ebf6a176 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_scicrunch_handlers.py +++ b/services/web/server/src/simcore_service_webserver/groups/_classifiers_handlers.py @@ -14,9 +14,9 @@ from ..scicrunch.service_client import SciCrunch from ..security.decorators import permission_required from ..utils_aiohttp import envelope_json_response -from ._classifiers import GroupClassifierRepository, build_rrids_tree_view -from ._exceptions_handlers import handle_plugin_requests_exceptions -from ._models import GroupsClassifiersQuery, GroupsPathParams +from ._classifiers_api import GroupClassifierRepository, build_rrids_tree_view +from ._common.exceptions_handlers import handle_plugin_requests_exceptions +from ._common.models import GroupsClassifiersQuery, GroupsPathParams _logger = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/groups/_common/exceptions_handlers.py b/services/web/server/src/simcore_service_webserver/groups/_common/exceptions_handlers.py index 4d0f44d6d36..f0b9242fb70 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_common/exceptions_handlers.py +++ b/services/web/server/src/simcore_service_webserver/groups/_common/exceptions_handlers.py @@ -2,15 +2,15 @@ from servicelib.aiohttp import status -from ..exception_handling import ( +from ...exception_handling import ( ExceptionToHttpErrorMap, HttpErrorInfo, exception_handling_decorator, to_exceptions_handlers_map, ) -from ..scicrunch.errors import InvalidRRIDError, ScicrunchError -from ..users.exceptions import UserNotFoundError -from .exceptions import ( +from ...scicrunch.errors import InvalidRRIDError, ScicrunchError +from ...users.exceptions import UserNotFoundError +from ..exceptions import ( GroupNotFoundError, UserAlreadyInGroupError, UserInGroupNotFoundError, diff --git a/services/web/server/src/simcore_service_webserver/groups/_common/models.py b/services/web/server/src/simcore_service_webserver/groups/_common/models.py index 55f756f3f4e..debbab18b97 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_common/models.py +++ b/services/web/server/src/simcore_service_webserver/groups/_common/models.py @@ -5,7 +5,7 @@ from models_library.users import GroupID, UserID from pydantic import Field -from .._constants import RQ_PRODUCT_KEY, RQT_USERID_KEY +from ..._constants import RQ_PRODUCT_KEY, RQT_USERID_KEY _logger = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/groups/_db.py b/services/web/server/src/simcore_service_webserver/groups/_db.py index 3bcee2c6591..2afb684f72e 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_db.py +++ b/services/web/server/src/simcore_service_webserver/groups/_db.py @@ -13,7 +13,7 @@ from ..db.models import GroupType, groups, user_to_groups, users from ..users.exceptions import UserNotFoundError -from ._users import convert_user_in_group_to_schema +from ._users_api import convert_user_in_group_to_schema from ._utils import ( AccessRightsDict, check_group_permissions, diff --git a/services/web/server/src/simcore_service_webserver/groups/_handlers.py b/services/web/server/src/simcore_service_webserver/groups/_handlers.py index 920785de53a..1c899c0aad9 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/groups/_handlers.py @@ -24,8 +24,12 @@ from ..security.decorators import permission_required from ..utils_aiohttp import envelope_json_response from . import api -from ._exceptions_handlers import handle_plugin_requests_exceptions -from ._models import GroupsPathParams, GroupsRequestContext, GroupsUsersPathParams +from ._common.exceptions_handlers import handle_plugin_requests_exceptions +from ._common.models import ( + GroupsPathParams, + GroupsRequestContext, + GroupsUsersPathParams, +) from .exceptions import GroupNotFoundError _logger = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/groups/_users.py b/services/web/server/src/simcore_service_webserver/groups/_users_api.py similarity index 100% rename from services/web/server/src/simcore_service_webserver/groups/_users.py rename to services/web/server/src/simcore_service_webserver/groups/_users_api.py diff --git a/services/web/server/src/simcore_service_webserver/groups/plugin.py b/services/web/server/src/simcore_service_webserver/groups/plugin.py index 70b2f4eeb25..663671848c0 100644 --- a/services/web/server/src/simcore_service_webserver/groups/plugin.py +++ b/services/web/server/src/simcore_service_webserver/groups/plugin.py @@ -5,7 +5,7 @@ from .._constants import APP_SETTINGS_KEY from ..products.plugin import setup_products -from . import _handlers +from . import _classifiers_handlers, _handlers _logger = logging.getLogger(__name__) @@ -24,3 +24,4 @@ def setup_groups(app: web.Application): setup_products(app) app.router.add_routes(_handlers.routes) + app.router.add_route(_classifiers_handlers.routes) diff --git a/services/web/server/tests/unit/with_dbs/01/test_groups_classifiers.py b/services/web/server/tests/unit/with_dbs/01/test_groups_classifiers.py index b2fc82f44e6..354a30ef1d9 100644 --- a/services/web/server/tests/unit/with_dbs/01/test_groups_classifiers.py +++ b/services/web/server/tests/unit/with_dbs/01/test_groups_classifiers.py @@ -8,7 +8,7 @@ import sqlalchemy as sa from servicelib.common_aiopg_utils import DataSourceName, create_pg_engine from simcore_service_webserver._constants import APP_AIOPG_ENGINE_KEY -from simcore_service_webserver.groups._classifiers import GroupClassifierRepository +from simcore_service_webserver.groups._classifiers_api import GroupClassifierRepository from sqlalchemy.sql import text From 252f033049b59d913eda1a1d16098e74b4456b72 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Fri, 6 Dec 2024 16:02:18 +0100 Subject: [PATCH 08/84] one place for groups --- .../groups/{_db.py => _groups_db.py} | 0 .../{_handlers.py => _groups_handlers.py} | 0 .../simcore_service_webserver/groups/api.py | 40 ++++++++++--------- .../groups/plugin.py | 4 +- .../tests/unit/with_dbs/01/test_groups.py | 2 +- 5 files changed, 25 insertions(+), 21 deletions(-) rename services/web/server/src/simcore_service_webserver/groups/{_db.py => _groups_db.py} (100%) rename services/web/server/src/simcore_service_webserver/groups/{_handlers.py => _groups_handlers.py} (100%) diff --git a/services/web/server/src/simcore_service_webserver/groups/_db.py b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py similarity index 100% rename from services/web/server/src/simcore_service_webserver/groups/_db.py rename to services/web/server/src/simcore_service_webserver/groups/_groups_db.py diff --git a/services/web/server/src/simcore_service_webserver/groups/_handlers.py b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py similarity index 100% rename from services/web/server/src/simcore_service_webserver/groups/_handlers.py rename to services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py diff --git a/services/web/server/src/simcore_service_webserver/groups/api.py b/services/web/server/src/simcore_service_webserver/groups/api.py index 503eee73839..ae758926a35 100644 --- a/services/web/server/src/simcore_service_webserver/groups/api.py +++ b/services/web/server/src/simcore_service_webserver/groups/api.py @@ -8,7 +8,7 @@ from ..db.plugin import get_database_engine from ..users.api import get_user -from . import _db +from . import _groups_db from ._utils import AccessRightsDict from .exceptions import GroupsError @@ -22,7 +22,9 @@ async def list_user_groups_with_read_access( # NOTE: Careful! It seems we are filtering out groups, such as Product Groups, # because they do not have read access. I believe this was done because the frontend did not want to display them. async with get_database_engine(app).acquire() as conn: - return await _db.get_all_user_groups_with_read_access(conn, user_id=user_id) + return await _groups_db.get_all_user_groups_with_read_access( + conn, user_id=user_id + ) async def list_all_user_groups(app: web.Application, user_id: UserID) -> list[Group]: @@ -30,7 +32,7 @@ async def list_all_user_groups(app: web.Application, user_id: UserID) -> list[Gr Return all user groups """ async with get_database_engine(app).acquire() as conn: - groups_db = await _db.get_all_user_groups(conn, user_id=user_id) + groups_db = await _groups_db.get_all_user_groups(conn, user_id=user_id) return [Group.model_construct(**group.model_dump()) for group in groups_db] @@ -45,7 +47,7 @@ async def get_user_group( raises UserInsufficientRightsError """ async with get_database_engine(app).acquire() as conn: - return await _db.get_user_group(conn, user_id=user_id, gid=gid) + return await _groups_db.get_user_group(conn, user_id=user_id, gid=gid) async def get_product_group_for_user( @@ -56,7 +58,7 @@ async def get_product_group_for_user( raises GroupNotFoundError """ async with get_database_engine(app).acquire() as conn: - return await _db.get_product_group_for_user( + return await _groups_db.get_product_group_for_user( conn, user_id=user_id, product_gid=product_gid ) @@ -65,7 +67,9 @@ async def create_user_group( app: web.Application, user_id: UserID, new_group: dict ) -> dict[str, Any]: async with get_database_engine(app).acquire() as conn: - return await _db.create_user_group(conn, user_id=user_id, new_group=new_group) + return await _groups_db.create_user_group( + conn, user_id=user_id, new_group=new_group + ) async def update_user_group( @@ -75,7 +79,7 @@ async def update_user_group( new_group_values: dict[str, str], ) -> dict[str, str]: async with get_database_engine(app).acquire() as conn: - return await _db.update_user_group( + return await _groups_db.update_user_group( conn, user_id=user_id, gid=gid, new_group_values=new_group_values ) @@ -84,28 +88,28 @@ async def delete_user_group( app: web.Application, user_id: UserID, gid: GroupID ) -> None: async with get_database_engine(app).acquire() as conn: - return await _db.delete_user_group(conn, user_id=user_id, gid=gid) + return await _groups_db.delete_user_group(conn, user_id=user_id, gid=gid) async def list_users_in_group( app: web.Application, user_id: UserID, gid: GroupID ) -> list[dict[str, str]]: async with get_database_engine(app).acquire() as conn: - return await _db.list_users_in_group(conn, user_id=user_id, gid=gid) + return await _groups_db.list_users_in_group(conn, user_id=user_id, gid=gid) async def auto_add_user_to_groups(app: web.Application, user_id: UserID) -> None: user: dict = await get_user(app, user_id) async with get_database_engine(app).acquire() as conn: - return await _db.auto_add_user_to_groups(conn, user=user) + return await _groups_db.auto_add_user_to_groups(conn, user=user) async def auto_add_user_to_product_group( app: web.Application, user_id: UserID, product_name: str ) -> GroupID: async with get_database_engine(app).acquire() as conn: - return await _db.auto_add_user_to_product_group( + return await _groups_db.auto_add_user_to_product_group( conn, user_id=user_id, product_name=product_name ) @@ -114,7 +118,7 @@ async def is_user_by_email_in_group( app: web.Application, user_email: LowerCaseEmailStr, group_id: GroupID ) -> bool: async with get_database_engine(app).acquire() as conn: - return await _db.is_user_by_email_in_group( + return await _groups_db.is_user_by_email_in_group( conn, email=user_email, group_id=group_id, @@ -143,14 +147,14 @@ async def add_user_in_group( async with get_database_engine(app).acquire() as conn: if new_user_email: - user: RowProxy = await _db.get_user_from_email(conn, new_user_email) + user: RowProxy = await _groups_db.get_user_from_email(conn, new_user_email) new_user_id = user["id"] if not new_user_id: msg = "Missing new user in arguments" raise GroupsError(msg=msg) - return await _db.add_new_user_in_group( + return await _groups_db.add_new_user_in_group( conn, user_id=user_id, gid=gid, @@ -163,7 +167,7 @@ async def get_user_in_group( app: web.Application, user_id: UserID, gid: GroupID, the_user_id_in_group: int ) -> dict[str, str]: async with get_database_engine(app).acquire() as conn: - return await _db.get_user_in_group( + return await _groups_db.get_user_in_group( conn, user_id=user_id, gid=gid, the_user_id_in_group=the_user_id_in_group ) @@ -176,7 +180,7 @@ async def update_user_in_group( access_rights: dict, ) -> dict[str, str]: async with get_database_engine(app).acquire() as conn: - return await _db.update_user_in_group( + return await _groups_db.update_user_in_group( conn, user_id=user_id, gid=gid, @@ -189,14 +193,14 @@ async def delete_user_in_group( app: web.Application, user_id: UserID, gid: GroupID, the_user_id_in_group: int ) -> None: async with get_database_engine(app).acquire() as conn: - return await _db.delete_user_in_group( + return await _groups_db.delete_user_in_group( conn, user_id=user_id, gid=gid, the_user_id_in_group=the_user_id_in_group ) async def get_group_from_gid(app: web.Application, gid: GroupID) -> Group | None: async with get_database_engine(app).acquire() as conn: - group_db = await _db.get_group_from_gid(conn, gid=gid) + group_db = await _groups_db.get_group_from_gid(conn, gid=gid) if group_db: return Group.model_construct(**group_db.model_dump()) diff --git a/services/web/server/src/simcore_service_webserver/groups/plugin.py b/services/web/server/src/simcore_service_webserver/groups/plugin.py index 663671848c0..39e80c9f96d 100644 --- a/services/web/server/src/simcore_service_webserver/groups/plugin.py +++ b/services/web/server/src/simcore_service_webserver/groups/plugin.py @@ -5,7 +5,7 @@ from .._constants import APP_SETTINGS_KEY from ..products.plugin import setup_products -from . import _classifiers_handlers, _handlers +from . import _classifiers_handlers, _groups_handlers _logger = logging.getLogger(__name__) @@ -23,5 +23,5 @@ def setup_groups(app: web.Application): # plugin dependencies setup_products(app) - app.router.add_routes(_handlers.routes) + app.router.add_routes(_groups_handlers.routes) app.router.add_route(_classifiers_handlers.routes) diff --git a/services/web/server/tests/unit/with_dbs/01/test_groups.py b/services/web/server/tests/unit/with_dbs/01/test_groups.py index 51f2f746a80..197fca37c99 100644 --- a/services/web/server/tests/unit/with_dbs/01/test_groups.py +++ b/services/web/server/tests/unit/with_dbs/01/test_groups.py @@ -24,7 +24,7 @@ from simcore_service_webserver._meta import API_VTAG from simcore_service_webserver.application_settings import setup_settings from simcore_service_webserver.db.plugin import setup_db -from simcore_service_webserver.groups._db import ( +from simcore_service_webserver.groups._groups_db import ( _DEFAULT_GROUP_OWNER_ACCESS_RIGHTS, _DEFAULT_GROUP_READ_ACCESS_RIGHTS, ) From aaffcae3a6415618a0039f8975fadceb8d01e6bc Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Fri, 6 Dec 2024 16:43:51 +0100 Subject: [PATCH 09/84] refactors db --- .../groups/_groups_db.py | 574 +++++++++++------- .../groups/_users_api.py | 2 +- .../groups/_utils.py | 11 - 3 files changed, 343 insertions(+), 244 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py index 2afb684f72e..24fb7ae5ccd 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py @@ -2,21 +2,26 @@ from typing import Any import sqlalchemy as sa -from aiopg.sa import SAConnection -from aiopg.sa.result import ResultProxy, RowProxy +from aiohttp import web +from aiopg.sa.result import RowProxy from models_library.groups import GroupAtDB from models_library.users import GroupID, UserID from simcore_postgres_database.errors import UniqueViolation from simcore_postgres_database.utils_products import get_or_create_product_group +from simcore_postgres_database.utils_repos import ( + pass_or_acquire_connection, + transaction_context, +) from sqlalchemy import and_, literal_column from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.ext.asyncio import AsyncConnection from ..db.models import GroupType, groups, user_to_groups, users +from ..db.plugin import get_asyncpg_engine from ..users.exceptions import UserNotFoundError from ._users_api import convert_user_in_group_to_schema from ._utils import ( AccessRightsDict, - check_group_permissions, convert_groups_db_to_schema, convert_groups_schema_to_db, ) @@ -24,6 +29,7 @@ GroupNotFoundError, UserAlreadyInGroupError, UserInGroupNotFoundError, + UserInsufficientRightsError, ) _DEFAULT_PRODUCT_GROUP_ACCESS_RIGHTS = AccessRightsDict( @@ -44,10 +50,20 @@ ) +def _check_group_permissions(group, user_id: int, gid: int, permission: str) -> None: + if not group.access_rights[permission]: + raise UserInsufficientRightsError( + user_id=user_id, gid=gid, permission=permission + ) + + async def _get_user_group( - conn: SAConnection, user_id: UserID, gid: GroupID -) -> RowProxy: - result = await conn.execute( + conn: AsyncConnection, + *, + user_id: UserID, + gid: GroupID, +): + result = await conn.stream( sa.select(groups, user_to_groups.c.access_rights) .select_from(user_to_groups.join(groups, user_to_groups.c.gid == groups.c.gid)) .where(and_(user_to_groups.c.uid == user_id, user_to_groups.c.gid == gid)) @@ -55,17 +71,18 @@ async def _get_user_group( group = await result.fetchone() if not group: raise GroupNotFoundError(gid=gid) - assert isinstance(group, RowProxy) # nosec return group -async def get_user_from_email(conn: SAConnection, email: str) -> RowProxy: - result = await conn.execute(sa.select(users).where(users.c.email == email)) - user = await result.fetchone() - if not user: - raise UserNotFoundError(email=email) - assert isinstance(user, RowProxy) # nosec - return user +async def get_user_from_email( + app: web.Application, connection: AsyncConnection | None = None, *, email: str +): + async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: + result = await conn.stream(sa.select(users).where(users.c.email == email)) + user = await result.fetchone() + if not user: + raise UserNotFoundError(email=email) + return user # @@ -74,7 +91,10 @@ async def get_user_from_email(conn: SAConnection, email: str) -> RowProxy: async def get_all_user_groups_with_read_access( - conn: SAConnection, user_id: UserID + app: web.Application, + connection: AsyncConnection | None = None, + *, + user_id: UserID, ) -> tuple[dict[str, Any], list[dict[str, Any]], dict[str, Any]]: """ Returns the user primary group, standard groups and the all group @@ -90,42 +110,53 @@ async def get_all_user_groups_with_read_access( ) .where(user_to_groups.c.uid == user_id) ) - row: RowProxy - async for row in conn.execute(query): - if row.type == GroupType.EVERYONE: - assert row.access_rights["read"] # nosec - all_group = convert_groups_db_to_schema(row) - elif row.type == GroupType.PRIMARY: - assert row.access_rights["read"] # nosec - primary_group = convert_groups_db_to_schema(row) + async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: + result = await conn.stream(query) + async for row in result: + if row.type == GroupType.EVERYONE: + assert row.access_rights["read"] # nosec + all_group = convert_groups_db_to_schema(row) - else: - assert row.type == GroupType.STANDARD # nosec - # only add if user has read access - if row.access_rights["read"]: - user_groups.append(convert_groups_db_to_schema(row)) + elif row.type == GroupType.PRIMARY: + assert row.access_rights["read"] # nosec + primary_group = convert_groups_db_to_schema(row) - return (primary_group, user_groups, all_group) + else: + assert row.type == GroupType.STANDARD # nosec + # only add if user has read access + if row.access_rights["read"]: + user_groups.append(convert_groups_db_to_schema(row)) + return (primary_group, user_groups, all_group) -async def get_all_user_groups(conn: SAConnection, user_id: UserID) -> list[GroupAtDB]: + +async def get_all_user_groups( + app: web.Application, + connection: AsyncConnection | None = None, + *, + user_id: UserID, +) -> list[GroupAtDB]: """ Returns all user groups """ - result = await conn.execute( - sa.select(groups) - .select_from( - user_to_groups.join(groups, user_to_groups.c.gid == groups.c.gid), + async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: + result = await conn.stream( + sa.select(groups) + .select_from( + user_to_groups.join(groups, user_to_groups.c.gid == groups.c.gid), + ) + .where(user_to_groups.c.uid == user_id) ) - .where(user_to_groups.c.uid == user_id) - ) - rows = await result.fetchall() or [] - return [GroupAtDB.model_validate(row) for row in rows] + return [GroupAtDB.model_validate(row) async for row in result] async def get_user_group( - conn: SAConnection, user_id: UserID, gid: GroupID + app: web.Application, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + gid: GroupID, ) -> dict[str, str]: """ Gets group gid if user associated to it and has read access @@ -133,84 +164,112 @@ async def get_user_group( raises GroupNotFoundError raises UserInsufficientRightsError """ - group: RowProxy = await _get_user_group(conn, user_id, gid) - check_group_permissions(group, user_id, gid, "read") - return convert_groups_db_to_schema(group) + async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: + group = await _get_user_group(conn, user_id=user_id, gid=gid) + _check_group_permissions(group, user_id, gid, "read") + return convert_groups_db_to_schema(group) async def get_product_group_for_user( - conn: SAConnection, user_id: UserID, product_gid: GroupID + app: web.Application, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + product_gid: GroupID, ) -> dict[str, str]: """ Returns product's group if user belongs to it, otherwise it raises GroupNotFoundError """ - group: RowProxy = await _get_user_group(conn, user_id, product_gid) - return convert_groups_db_to_schema(group) + async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: + group = await _get_user_group(conn, user_id=user_id, gid=product_gid) + return convert_groups_db_to_schema(group) async def create_user_group( - conn: SAConnection, user_id: UserID, new_group: dict + app: web.Application, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + new_group: dict, ) -> dict[str, Any]: - result = await conn.execute( - sa.select(users.c.primary_gid).where(users.c.id == user_id) - ) - user: RowProxy | None = await result.fetchone() - if not user: - raise UserNotFoundError(uid=user_id) - result = await conn.execute( - # pylint: disable=no-value-for-parameter - groups.insert() - .values(**convert_groups_schema_to_db(new_group)) - .returning(literal_column("*")) - ) - group: RowProxy | None = await result.fetchone() - assert group # nosec - - await conn.execute( - # pylint: disable=no-value-for-parameter - user_to_groups.insert().values( - uid=user_id, - gid=group.gid, - access_rights=_DEFAULT_GROUP_OWNER_ACCESS_RIGHTS, + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + result = await conn.stream( + sa.select(users.c.primary_gid).where(users.c.id == user_id) + ) + user = await result.fetchone() + if not user: + raise UserNotFoundError(uid=user_id) + + result = await conn.stream( + # pylint: disable=no-value-for-parameter + groups.insert() + .values(**convert_groups_schema_to_db(new_group)) + .returning(literal_column("*")) + ) + group = await result.fetchone() + assert group # nosec + + await conn.execute( + # pylint: disable=no-value-for-parameter + user_to_groups.insert().values( + uid=user_id, + gid=group.gid, + access_rights=_DEFAULT_GROUP_OWNER_ACCESS_RIGHTS, + ) + ) + return convert_groups_db_to_schema( + group, accessRights=_DEFAULT_GROUP_OWNER_ACCESS_RIGHTS ) - ) - return convert_groups_db_to_schema( - group, accessRights=_DEFAULT_GROUP_OWNER_ACCESS_RIGHTS - ) async def update_user_group( - conn: SAConnection, user_id: UserID, gid: GroupID, new_group_values: dict[str, str] + app: web.Application, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + gid: GroupID, + new_group_values: dict[str, str], ) -> dict[str, str]: - new_values = { - k: v for k, v in convert_groups_schema_to_db(new_group_values).items() if v - } - - group = await _get_user_group(conn, user_id, gid) - check_group_permissions(group, user_id, gid, "write") - - result = await conn.execute( - # pylint: disable=no-value-for-parameter - groups.update() - .values(**new_values) - .where(groups.c.gid == group.gid) - .returning(literal_column("*")) - ) - updated_group = await result.fetchone() - assert updated_group # nosec - return convert_groups_db_to_schema(updated_group, accessRights=group.access_rights) + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + new_values = { + k: v for k, v in convert_groups_schema_to_db(new_group_values).items() if v + } + group = await _get_user_group(conn, user_id=user_id, gid=gid) + _check_group_permissions(group, user_id, gid, "write") -async def delete_user_group(conn: SAConnection, user_id: UserID, gid: GroupID) -> None: - group = await _get_user_group(conn, user_id, gid) - check_group_permissions(group, user_id, gid, "delete") + result = await conn.stream( + # pylint: disable=no-value-for-parameter + groups.update() + .values(**new_values) + .where(groups.c.gid == group.gid) + .returning(literal_column("*")) + ) + updated_group = await result.fetchone() + assert updated_group # nosec + + return convert_groups_db_to_schema( + updated_group, accessRights=group.access_rights + ) - await conn.execute( - # pylint: disable=no-value-for-parameter - groups.delete().where(groups.c.gid == group.gid) - ) + +async def delete_user_group( + app: web.Application, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + gid: GroupID, +) -> None: + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + group = await _get_user_group(conn, user_id=user_id, gid=gid) + _check_group_permissions(group, user_id, gid, "delete") + + await conn.execute( + # pylint: disable=no-value-for-parameter + groups.delete().where(groups.c.gid == group.gid) + ) # @@ -219,153 +278,187 @@ async def delete_user_group(conn: SAConnection, user_id: UserID, gid: GroupID) - async def list_users_in_group( - conn: SAConnection, user_id: UserID, gid: GroupID + app: web.Application, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + gid: GroupID, ) -> list[dict[str, str]]: - # first check if the group exists - group: RowProxy = await _get_user_group(conn, user_id, gid) - check_group_permissions(group, user_id, gid, "read") - # now get the list - query = ( - sa.select(users, user_to_groups.c.access_rights) - .select_from(users.join(user_to_groups)) - .where(user_to_groups.c.gid == gid) - ) - users_list = [ - convert_user_in_group_to_schema(row) async for row in conn.execute(query) - ] - return users_list + async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: + # first check if the group exists + group = await _get_user_group(conn, user_id=user_id, gid=gid) + _check_group_permissions(group, user_id, gid, "read") + # now get the list + query = ( + sa.select(users, user_to_groups.c.access_rights) + .select_from(users.join(user_to_groups)) + .where(user_to_groups.c.gid == gid) + ) + + result = await conn.stream(query) + return [convert_user_in_group_to_schema(row) async for row in result] -async def auto_add_user_to_groups(conn: SAConnection, user: dict) -> None: +async def auto_add_user_to_groups( + app: web.Application, connection: AsyncConnection | None = None, *, user: dict +) -> None: + user_id: UserID = user["id"] # auto add user to the groups with the right rules # get the groups where there are inclusion rules and see if they apply query = sa.select(groups).where(groups.c.inclusion_rules != {}) possible_group_ids = set() - async for row in conn.execute(query): - inclusion_rules = row[groups.c.inclusion_rules] - for prop, rule_pattern in inclusion_rules.items(): - if prop not in user: - continue - if re.search(rule_pattern, user[prop]): - possible_group_ids.add(row[groups.c.gid]) - - # now add the user to these groups if possible - for gid in possible_group_ids: + + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + result = await conn.stream(query) + async for row in result: + inclusion_rules = row[groups.c.inclusion_rules] + for prop, rule_pattern in inclusion_rules.items(): + if prop not in user: + continue + if re.search(rule_pattern, user[prop]): + possible_group_ids.add(row[groups.c.gid]) + + # now add the user to these groups if possible + for gid in possible_group_ids: + await conn.execute( + # pylint: disable=no-value-for-parameter + insert(user_to_groups) + .values( + uid=user_id, + gid=gid, + access_rights=_DEFAULT_GROUP_READ_ACCESS_RIGHTS, + ) + .on_conflict_do_nothing() # in case the user was already added + ) + + +async def auto_add_user_to_product_group( + app: web.Application, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + product_name: str, +) -> GroupID: + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + product_group_id: GroupID = await get_or_create_product_group( + conn, product_name + ) + await conn.execute( # pylint: disable=no-value-for-parameter insert(user_to_groups) .values( uid=user_id, - gid=gid, - access_rights=_DEFAULT_GROUP_READ_ACCESS_RIGHTS, + gid=product_group_id, + access_rights=_DEFAULT_PRODUCT_GROUP_ACCESS_RIGHTS, ) .on_conflict_do_nothing() # in case the user was already added ) - - -async def auto_add_user_to_product_group( - conn: SAConnection, user_id: UserID, product_name: str -) -> GroupID: - product_group_id: GroupID = await get_or_create_product_group(conn, product_name) - - await conn.execute( - # pylint: disable=no-value-for-parameter - insert(user_to_groups) - .values( - uid=user_id, - gid=product_group_id, - access_rights=_DEFAULT_PRODUCT_GROUP_ACCESS_RIGHTS, - ) - .on_conflict_do_nothing() # in case the user was already added - ) - return product_group_id + return product_group_id async def is_user_by_email_in_group( - conn: SAConnection, email: str, group_id: GroupID + app: web.Application, + connection: AsyncConnection | None = None, + *, + email: str, + group_id: GroupID, ) -> bool: - user_id = await conn.scalar( - sa.select(users.c.id) - .select_from(sa.join(user_to_groups, users, user_to_groups.c.uid == users.c.id)) - .where((users.c.email == email) & (user_to_groups.c.gid == group_id)) - ) - return user_id is not None + async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: + user_id = await conn.scalar( + sa.select(users.c.id) + .select_from( + sa.join(user_to_groups, users, user_to_groups.c.uid == users.c.id) + ) + .where((users.c.email == email) & (user_to_groups.c.gid == group_id)) + ) + return user_id is not None async def add_new_user_in_group( - conn: SAConnection, + app: web.Application, + connection: AsyncConnection | None = None, + *, user_id: UserID, gid: GroupID, - *, new_user_id: UserID, access_rights: AccessRightsDict | None = None, ) -> None: """ adds new_user (either by id or email) in group (with gid) owned by user_id """ - - # first check if the group exists - group: RowProxy = await _get_user_group(conn, user_id, gid) - check_group_permissions(group, user_id, gid, "write") - - # now check the new user exists - users_count = await conn.scalar( - sa.select(sa.func.count()).where(users.c.id == new_user_id) - ) - if not users_count: - assert new_user_id is not None # nosec - raise UserInGroupNotFoundError(uid=new_user_id, gid=gid) - - # add the new user to the group now - user_access_rights = _DEFAULT_GROUP_READ_ACCESS_RIGHTS - if access_rights: - user_access_rights.update(access_rights) - - try: - 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 - ) + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + # first check if the group exists + group = await _get_user_group(conn, user_id=user_id, gid=gid) + _check_group_permissions(group, user_id, gid, "write") + + # now check the new user exists + users_count = await conn.scalar( + sa.select(sa.func.count()).where(users.c.id == new_user_id) ) - except UniqueViolation as exc: - raise UserAlreadyInGroupError( - uid=new_user_id, gid=gid, user_id=user_id, access_rights=access_rights - ) from exc + if not users_count: + assert new_user_id is not None # nosec + raise UserInGroupNotFoundError(uid=new_user_id, gid=gid) + + # add the new user to the group now + user_access_rights = _DEFAULT_GROUP_READ_ACCESS_RIGHTS + if access_rights: + user_access_rights.update(access_rights) + + try: + 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 + ) + ) + except UniqueViolation as exc: + raise UserAlreadyInGroupError( + uid=new_user_id, gid=gid, user_id=user_id, access_rights=access_rights + ) from exc async def _get_user_in_group_permissions( - conn: SAConnection, gid: GroupID, the_user_id_in_group: int + conn: AsyncConnection, *, gid: GroupID, the_user_id_in_group: int ) -> RowProxy: # now get the user - result = await conn.execute( + result = await conn.stream( sa.select(users, 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 == gid, users.c.id == the_user_id_in_group)) ) - the_user: RowProxy | None = await result.fetchone() + the_user = await result.fetchone() if not the_user: raise UserInGroupNotFoundError(uid=the_user_id_in_group, gid=gid) return the_user async def get_user_in_group( - conn: SAConnection, user_id: UserID, gid: GroupID, the_user_id_in_group: int + app: web.Application, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + gid: GroupID, + the_user_id_in_group: int, ) -> dict[str, str]: - # first check if the group exists - group: RowProxy = await _get_user_group(conn, user_id, gid) - check_group_permissions(group, user_id, gid, "read") - # get the user with its permissions - the_user: RowProxy = await _get_user_in_group_permissions( - conn, gid, the_user_id_in_group - ) - return convert_user_in_group_to_schema(the_user) + async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: + # first check if the group exists + group = await _get_user_group(conn, user_id=user_id, gid=gid) + _check_group_permissions(group, user_id, gid, "read") + + # get the user with its permissions + the_user = await _get_user_in_group_permissions( + conn, gid=gid, the_user_id_in_group=the_user_id_in_group + ) + return convert_user_in_group_to_schema(the_user) async def update_user_in_group( - conn: SAConnection, + app: web.Application, + connection: AsyncConnection | None = None, + *, user_id: UserID, gid: GroupID, the_user_id_in_group: int, @@ -375,54 +468,71 @@ async def update_user_in_group( msg = f"Cannot update empty {access_rights}" raise ValueError(msg) - # first check if the group exists - group: RowProxy = await _get_user_group(conn, user_id, gid) - check_group_permissions(group, user_id, gid, "write") - # now check the user exists - the_user: RowProxy = await _get_user_in_group_permissions( - conn, gid, the_user_id_in_group - ) - # modify the user access rights - new_db_values = {"access_rights": access_rights} - await conn.execute( - # pylint: disable=no-value-for-parameter - user_to_groups.update() - .values(**new_db_values) - .where( - and_( - user_to_groups.c.uid == the_user_id_in_group, - user_to_groups.c.gid == gid, + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + + # first check if the group exists + group = await _get_user_group(conn, user_id=user_id, gid=gid) + _check_group_permissions(group, user_id, gid, "write") + + # now check the user exists + the_user = await _get_user_in_group_permissions( + conn, gid=gid, the_user_id_in_group=the_user_id_in_group + ) + + # modify the user access rights + new_db_values = {"access_rights": access_rights} + await conn.execute( + # pylint: disable=no-value-for-parameter + user_to_groups.update() + .values(**new_db_values) + .where( + and_( + user_to_groups.c.uid == the_user_id_in_group, + user_to_groups.c.gid == gid, + ) ) ) - ) - user = dict(the_user) - user.update(**new_db_values) - return convert_user_in_group_to_schema(user) + user = dict(the_user) + user.update(**new_db_values) + return convert_user_in_group_to_schema(user) async def delete_user_in_group( - conn: SAConnection, user_id: UserID, gid: GroupID, the_user_id_in_group: int + app: web.Application, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + gid: GroupID, + the_user_id_in_group: int, ) -> None: - # first check if the group exists - group: RowProxy = await _get_user_group(conn, user_id, gid) - check_group_permissions(group, user_id, gid, "write") - # check the user exists - await _get_user_in_group_permissions(conn, gid, the_user_id_in_group) - # delete him/her - await conn.execute( - # pylint: disable=no-value-for-parameter - user_to_groups.delete().where( - and_( - user_to_groups.c.uid == the_user_id_in_group, - user_to_groups.c.gid == gid, + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + # first check if the group exists + group = await _get_user_group(conn, user_id=user_id, gid=gid) + _check_group_permissions(group, user_id, gid, "write") + + # check the user exists + await _get_user_in_group_permissions( + conn, gid=gid, the_user_id_in_group=the_user_id_in_group + ) + + # delete him/her + await conn.execute( + # pylint: disable=no-value-for-parameter + user_to_groups.delete().where( + and_( + user_to_groups.c.uid == the_user_id_in_group, + user_to_groups.c.gid == gid, + ) ) ) - ) -async def get_group_from_gid(conn: SAConnection, gid: GroupID) -> GroupAtDB | None: - row: ResultProxy = await conn.execute(groups.select().where(groups.c.gid == gid)) - result = await row.first() - if result: - return GroupAtDB.model_validate(result) - return None +async def get_group_from_gid( + app: web.Application, connection: AsyncConnection | None = None, *, gid: GroupID +) -> GroupAtDB | None: + async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: + row = await conn.stream(groups.select().where(groups.c.gid == gid)) + result = await row.first() + if result: + return GroupAtDB.model_validate(result) + return None diff --git a/services/web/server/src/simcore_service_webserver/groups/_users_api.py b/services/web/server/src/simcore_service_webserver/groups/_users_api.py index 37b8d3453aa..f640cdd07d8 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_users_api.py +++ b/services/web/server/src/simcore_service_webserver/groups/_users_api.py @@ -15,7 +15,7 @@ def convert_user_in_group_to_schema(user: Mapping[str, Any]) -> dict[str, str]: "first_name": user["first_name"], "last_name": user["last_name"], "login": user["email"], - "gravatar_id": gravatar_hash(user["email"]), + "gravatar_id": gravatar_hash(user["email"]), # deprecated } group_user["accessRights"] = user["access_rights"] group_user["gid"] = user["primary_gid"] diff --git a/services/web/server/src/simcore_service_webserver/groups/_utils.py b/services/web/server/src/simcore_service_webserver/groups/_utils.py index 4f0f3ad759f..79a16995638 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_utils.py +++ b/services/web/server/src/simcore_service_webserver/groups/_utils.py @@ -2,8 +2,6 @@ from aiopg.sa.result import RowProxy -from .exceptions import UserInsufficientRightsError - _GROUPS_SCHEMA_TO_DB = { "gid": "gid", "label": "name", @@ -20,15 +18,6 @@ class AccessRightsDict(TypedDict): delete: bool -def check_group_permissions( - group: RowProxy, user_id: int, gid: int, permission: str -) -> None: - if not group.access_rights[permission]: - raise UserInsufficientRightsError( - user_id=user_id, gid=gid, permission=permission - ) - - def convert_groups_db_to_schema( db_row: RowProxy, *, prefix: str | None = "", **kwargs ) -> dict: From c80bb38e38c11779aeacf63c2da2e5f81ea132ed Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Fri, 6 Dec 2024 16:55:43 +0100 Subject: [PATCH 10/84] service layer --- .../groups/_groups_db.py | 4 +- .../simcore_service_webserver/groups/api.py | 121 ++++++++---------- 2 files changed, 52 insertions(+), 73 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py index 24fb7ae5ccd..aec4df4345c 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py @@ -3,7 +3,6 @@ import sqlalchemy as sa from aiohttp import web -from aiopg.sa.result import RowProxy from models_library.groups import GroupAtDB from models_library.users import GroupID, UserID from simcore_postgres_database.errors import UniqueViolation @@ -288,6 +287,7 @@ async def list_users_in_group( # first check if the group exists group = await _get_user_group(conn, user_id=user_id, gid=gid) _check_group_permissions(group, user_id, gid, "read") + # now get the list query = ( sa.select(users, user_to_groups.c.access_rights) @@ -422,7 +422,7 @@ async def add_new_user_in_group( async def _get_user_in_group_permissions( conn: AsyncConnection, *, gid: GroupID, the_user_id_in_group: int -) -> RowProxy: +): # now get the user result = await conn.stream( sa.select(users, user_to_groups.c.access_rights) diff --git a/services/web/server/src/simcore_service_webserver/groups/api.py b/services/web/server/src/simcore_service_webserver/groups/api.py index ae758926a35..01ed47a144b 100644 --- a/services/web/server/src/simcore_service_webserver/groups/api.py +++ b/services/web/server/src/simcore_service_webserver/groups/api.py @@ -1,12 +1,10 @@ from typing import Any from aiohttp import web -from aiopg.sa.result import RowProxy from models_library.emails import LowerCaseEmailStr from models_library.groups import Group from models_library.users import GroupID, UserID -from ..db.plugin import get_database_engine from ..users.api import get_user from . import _groups_db from ._utils import AccessRightsDict @@ -21,18 +19,14 @@ async def list_user_groups_with_read_access( """ # NOTE: Careful! It seems we are filtering out groups, such as Product Groups, # because they do not have read access. I believe this was done because the frontend did not want to display them. - async with get_database_engine(app).acquire() as conn: - return await _groups_db.get_all_user_groups_with_read_access( - conn, user_id=user_id - ) + return await _groups_db.get_all_user_groups_with_read_access(app, user_id=user_id) async def list_all_user_groups(app: web.Application, user_id: UserID) -> list[Group]: """ Return all user groups """ - async with get_database_engine(app).acquire() as conn: - groups_db = await _groups_db.get_all_user_groups(conn, user_id=user_id) + groups_db = await _groups_db.get_all_user_groups(app, user_id=user_id) return [Group.model_construct(**group.model_dump()) for group in groups_db] @@ -46,8 +40,7 @@ async def get_user_group( raises GroupNotFoundError raises UserInsufficientRightsError """ - async with get_database_engine(app).acquire() as conn: - return await _groups_db.get_user_group(conn, user_id=user_id, gid=gid) + return await _groups_db.get_user_group(app, user_id=user_id, gid=gid) async def get_product_group_for_user( @@ -57,19 +50,15 @@ async def get_product_group_for_user( Returns product's group if user belongs to it, otherwise it raises GroupNotFoundError """ - async with get_database_engine(app).acquire() as conn: - return await _groups_db.get_product_group_for_user( - conn, user_id=user_id, product_gid=product_gid - ) + return await _groups_db.get_product_group_for_user( + app, user_id=user_id, product_gid=product_gid + ) async def create_user_group( app: web.Application, user_id: UserID, new_group: dict ) -> dict[str, Any]: - async with get_database_engine(app).acquire() as conn: - return await _groups_db.create_user_group( - conn, user_id=user_id, new_group=new_group - ) + return await _groups_db.create_user_group(app, user_id=user_id, new_group=new_group) async def update_user_group( @@ -78,51 +67,45 @@ async def update_user_group( gid: GroupID, new_group_values: dict[str, str], ) -> dict[str, str]: - async with get_database_engine(app).acquire() as conn: - return await _groups_db.update_user_group( - conn, user_id=user_id, gid=gid, new_group_values=new_group_values - ) + return await _groups_db.update_user_group( + app, user_id=user_id, gid=gid, new_group_values=new_group_values + ) async def delete_user_group( app: web.Application, user_id: UserID, gid: GroupID ) -> None: - async with get_database_engine(app).acquire() as conn: - return await _groups_db.delete_user_group(conn, user_id=user_id, gid=gid) + return await _groups_db.delete_user_group(app, user_id=user_id, gid=gid) async def list_users_in_group( app: web.Application, user_id: UserID, gid: GroupID ) -> list[dict[str, str]]: - async with get_database_engine(app).acquire() as conn: - return await _groups_db.list_users_in_group(conn, user_id=user_id, gid=gid) + return await _groups_db.list_users_in_group(app, user_id=user_id, gid=gid) async def auto_add_user_to_groups(app: web.Application, user_id: UserID) -> None: user: dict = await get_user(app, user_id) - - async with get_database_engine(app).acquire() as conn: - return await _groups_db.auto_add_user_to_groups(conn, user=user) + return await _groups_db.auto_add_user_to_groups(app, user=user) async def auto_add_user_to_product_group( app: web.Application, user_id: UserID, product_name: str ) -> GroupID: - async with get_database_engine(app).acquire() as conn: - return await _groups_db.auto_add_user_to_product_group( - conn, user_id=user_id, product_name=product_name - ) + return await _groups_db.auto_add_user_to_product_group( + app, user_id=user_id, product_name=product_name + ) async def is_user_by_email_in_group( app: web.Application, user_email: LowerCaseEmailStr, group_id: GroupID ) -> bool: - async with get_database_engine(app).acquire() as conn: - return await _groups_db.is_user_by_email_in_group( - conn, - email=user_email, - group_id=group_id, - ) + + return await _groups_db.is_user_by_email_in_group( + app, + email=user_email, + group_id=group_id, + ) async def add_user_in_group( @@ -145,31 +128,30 @@ async def add_user_in_group( msg = "Invalid method call, missing user id or user email" raise GroupsError(msg=msg) - async with get_database_engine(app).acquire() as conn: - if new_user_email: - user: RowProxy = await _groups_db.get_user_from_email(conn, new_user_email) - new_user_id = user["id"] + if new_user_email: + user = await _groups_db.get_user_from_email(app, email=new_user_email) + new_user_id = user.id - if not new_user_id: - msg = "Missing new user in arguments" - raise GroupsError(msg=msg) + if not new_user_id: + msg = "Missing new user in arguments" + raise GroupsError(msg=msg) - return await _groups_db.add_new_user_in_group( - conn, - user_id=user_id, - gid=gid, - new_user_id=new_user_id, - access_rights=access_rights, - ) + return await _groups_db.add_new_user_in_group( + app, + user_id=user_id, + gid=gid, + new_user_id=new_user_id, + access_rights=access_rights, + ) async def get_user_in_group( app: web.Application, user_id: UserID, gid: GroupID, the_user_id_in_group: int ) -> dict[str, str]: - async with get_database_engine(app).acquire() as conn: - return await _groups_db.get_user_in_group( - conn, user_id=user_id, gid=gid, the_user_id_in_group=the_user_id_in_group - ) + + return await _groups_db.get_user_in_group( + app, user_id=user_id, gid=gid, the_user_id_in_group=the_user_id_in_group + ) async def update_user_in_group( @@ -179,28 +161,25 @@ async def update_user_in_group( the_user_id_in_group: int, access_rights: dict, ) -> dict[str, str]: - async with get_database_engine(app).acquire() as conn: - return await _groups_db.update_user_in_group( - conn, - user_id=user_id, - gid=gid, - the_user_id_in_group=the_user_id_in_group, - access_rights=access_rights, - ) + return await _groups_db.update_user_in_group( + app, + user_id=user_id, + gid=gid, + the_user_id_in_group=the_user_id_in_group, + access_rights=access_rights, + ) async def delete_user_in_group( app: web.Application, user_id: UserID, gid: GroupID, the_user_id_in_group: int ) -> None: - async with get_database_engine(app).acquire() as conn: - return await _groups_db.delete_user_in_group( - conn, user_id=user_id, gid=gid, the_user_id_in_group=the_user_id_in_group - ) + return await _groups_db.delete_user_in_group( + app, user_id=user_id, gid=gid, the_user_id_in_group=the_user_id_in_group + ) async def get_group_from_gid(app: web.Application, gid: GroupID) -> Group | None: - async with get_database_engine(app).acquire() as conn: - group_db = await _groups_db.get_group_from_gid(conn, gid=gid) + group_db = await _groups_db.get_group_from_gid(app, gid=gid) if group_db: return Group.model_construct(**group_db.model_dump()) From 8ba6d46a688779a69c27ce0d47974f04f2c05a86 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Fri, 6 Dec 2024 17:10:56 +0100 Subject: [PATCH 11/84] fixing tests --- .../utils_products.py | 59 +++++++++++-------- .../groups/_groups_db.py | 4 +- .../groups/plugin.py | 2 +- 3 files changed, 38 insertions(+), 27 deletions(-) diff --git a/packages/postgres-database/src/simcore_postgres_database/utils_products.py b/packages/postgres-database/src/simcore_postgres_database/utils_products.py index ff87ac1ad00..56eb5005161 100644 --- a/packages/postgres-database/src/simcore_postgres_database/utils_products.py +++ b/packages/postgres-database/src/simcore_postgres_database/utils_products.py @@ -37,6 +37,39 @@ async def get_product_group_id( return None if group_id is None else _GroupID(group_id) +async def execute_get_or_create_product_group(conn, product_name: str) -> int: + # + # NOTE: Separated so it can be used in asyncpg and aiopg environs while both + # coexist + # + group_id: int | None = await conn.scalar( + sa.select(products.c.group_id) + .where(products.c.name == product_name) + .with_for_update(read=True) + # a `FOR SHARE` lock: locks changes in the product until transaction is done. + # Read might return in None, but it is OK + ) + if group_id is None: + group_id = await conn.scalar( + groups.insert() + .values( + name=product_name, + description=f"{product_name} product group", + type=GroupType.STANDARD, + ) + .returning(groups.c.gid) + ) + assert group_id # nosec + + await conn.execute( + products.update() + .where(products.c.name == product_name) + .values(group_id=group_id) + ) + + return group_id + + async def get_or_create_product_group( connection: AiopgConnection, product_name: str ) -> _GroupID: @@ -44,29 +77,7 @@ async def get_or_create_product_group( Returns group_id of a product. Creates it if undefined """ async with connection.begin(): - group_id = await connection.scalar( - sa.select(products.c.group_id) - .where(products.c.name == product_name) - .with_for_update(read=True) - # a `FOR SHARE` lock: locks changes in the product until transaction is done. - # Read might return in None, but it is OK + group_id = await execute_get_or_create_product_group( + connection, product_name=product_name ) - if group_id is None: - group_id = await connection.scalar( - groups.insert() - .values( - name=product_name, - description=f"{product_name} product group", - type=GroupType.STANDARD, - ) - .returning(groups.c.gid) - ) - assert group_id # nosec - - await connection.execute( - products.update() - .where(products.c.name == product_name) - .values(group_id=group_id) - ) - return _GroupID(group_id) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py index aec4df4345c..5dd3617f374 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py @@ -6,7 +6,7 @@ from models_library.groups import GroupAtDB from models_library.users import GroupID, UserID from simcore_postgres_database.errors import UniqueViolation -from simcore_postgres_database.utils_products import get_or_create_product_group +from simcore_postgres_database.utils_products import execute_get_or_create_product_group from simcore_postgres_database.utils_repos import ( pass_or_acquire_connection, transaction_context, @@ -342,7 +342,7 @@ async def auto_add_user_to_product_group( product_name: str, ) -> GroupID: async with transaction_context(get_asyncpg_engine(app), connection) as conn: - product_group_id: GroupID = await get_or_create_product_group( + product_group_id: GroupID = await execute_get_or_create_product_group( conn, product_name ) diff --git a/services/web/server/src/simcore_service_webserver/groups/plugin.py b/services/web/server/src/simcore_service_webserver/groups/plugin.py index 39e80c9f96d..7000926383c 100644 --- a/services/web/server/src/simcore_service_webserver/groups/plugin.py +++ b/services/web/server/src/simcore_service_webserver/groups/plugin.py @@ -24,4 +24,4 @@ def setup_groups(app: web.Application): setup_products(app) app.router.add_routes(_groups_handlers.routes) - app.router.add_route(_classifiers_handlers.routes) + app.router.add_routes(_classifiers_handlers.routes) From d15657a49b3b0501761d9091ef0cb9be0fdcb96e Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Fri, 6 Dec 2024 19:29:07 +0100 Subject: [PATCH 12/84] updates models --- .../api_schemas_webserver/groups.py | 17 ++++++++++++----- .../models-library/src/models_library/groups.py | 12 ++++++++++-- 2 files changed, 22 insertions(+), 7 deletions(-) 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 71bbc5ae068..43f86f80e22 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 @@ -1,5 +1,7 @@ from contextlib import suppress +from typing import Annotated +from common_library.basic_types import DEFAULT_FACTORY from pydantic import ( AnyHttpUrl, AnyUrl, @@ -26,6 +28,7 @@ class GroupAccessRights(BaseModel): read: bool write: bool delete: bool + model_config = ConfigDict( json_schema_extra={ "examples": [ @@ -45,11 +48,15 @@ class GroupGet(OutputSchema): default=None, description="url to the group thumbnail" ) access_rights: GroupAccessRights = Field(..., alias="accessRights") - inclusion_rules: dict[str, str] = Field( - default_factory=dict, - description="Maps user's column and regular expression", - alias="inclusionRules", - ) + + inclusion_rules: Annotated[ + dict[str, str], + Field( + default_factory=dict, + description="Maps user's column and regular expression", + alias="inclusionRules", + ), + ] = DEFAULT_FACTORY model_config = ConfigDict( json_schema_extra={ diff --git a/packages/models-library/src/models_library/groups.py b/packages/models-library/src/models_library/groups.py index 488776b6d8e..c4790adca81 100644 --- a/packages/models-library/src/models_library/groups.py +++ b/packages/models-library/src/models_library/groups.py @@ -1,6 +1,7 @@ import enum -from typing import Final +from typing import Annotated, Final +from common_library.basic_types import DEFAULT_FACTORY from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic.types import PositiveInt @@ -25,9 +26,16 @@ class Group(BaseModel): gid: PositiveInt name: str description: str - group_type: GroupTypeInModel = Field(..., alias="type") + group_type: Annotated[GroupTypeInModel, Field(alias="type")] thumbnail: str | None + inclusion_rules: Annotated[ + dict[str, str], + Field( + default_factory=dict, + ), + ] = DEFAULT_FACTORY + _from_equivalent_enums = field_validator("group_type", mode="before")( create_enums_pre_validator(GroupTypeInModel) ) From 8727e2abd3c6970626d1da005f322971706511a1 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Fri, 6 Dec 2024 19:31:22 +0100 Subject: [PATCH 13/84] common and db --- .../groups/_common/models.py | 3 - .../groups/_common/types.py | 18 ++ .../groups/_groups_db.py | 173 ++++++++++++------ .../groups/_utils.py | 8 - 4 files changed, 136 insertions(+), 66 deletions(-) create mode 100644 services/web/server/src/simcore_service_webserver/groups/_common/types.py diff --git a/services/web/server/src/simcore_service_webserver/groups/_common/models.py b/services/web/server/src/simcore_service_webserver/groups/_common/models.py index debbab18b97..872193aaffe 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_common/models.py +++ b/services/web/server/src/simcore_service_webserver/groups/_common/models.py @@ -1,4 +1,3 @@ -import logging from typing import Literal from models_library.rest_base import RequestParameters, StrictRequestParameters @@ -7,8 +6,6 @@ from ..._constants import RQ_PRODUCT_KEY, RQT_USERID_KEY -_logger = logging.getLogger(__name__) - class GroupsRequestContext(RequestParameters): user_id: UserID = Field(..., alias=RQT_USERID_KEY) # type: ignore[literal-required] diff --git a/services/web/server/src/simcore_service_webserver/groups/_common/types.py b/services/web/server/src/simcore_service_webserver/groups/_common/types.py new file mode 100644 index 00000000000..2bafa5950c9 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/groups/_common/types.py @@ -0,0 +1,18 @@ +from typing import NamedTuple, TypeAlias, TypedDict + +from models_library.groups import Group + + +class AccessRightsDict(TypedDict): + read: bool + write: bool + delete: bool + + +GroupInfoTuple: TypeAlias = tuple[Group, AccessRightsDict] + + +class GroupsByTypeTuple(NamedTuple): + primary: GroupInfoTuple | None + standard: list[GroupInfoTuple] + everyone: GroupInfoTuple | None diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py index 5dd3617f374..deccc8b6589 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py @@ -1,9 +1,9 @@ import re -from typing import Any +from copy import deepcopy import sqlalchemy as sa from aiohttp import web -from models_library.groups import GroupAtDB +from models_library.groups import Group, GroupAtDB from models_library.users import GroupID, UserID from simcore_postgres_database.errors import UniqueViolation from simcore_postgres_database.utils_products import execute_get_or_create_product_group @@ -13,17 +13,15 @@ ) from sqlalchemy import and_, literal_column from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.engine.row import Row from sqlalchemy.ext.asyncio import AsyncConnection from ..db.models import GroupType, groups, user_to_groups, users from ..db.plugin import get_asyncpg_engine from ..users.exceptions import UserNotFoundError +from ._common.types import AccessRightsDict, GroupInfoTuple, GroupsByTypeTuple from ._users_api import convert_user_in_group_to_schema -from ._utils import ( - AccessRightsDict, - convert_groups_db_to_schema, - convert_groups_schema_to_db, -) +from ._utils import convert_groups_schema_to_db from .exceptions import ( GroupNotFoundError, UserAlreadyInGroupError, @@ -48,34 +46,76 @@ delete=True, ) +_GROUP_COLUMNS = ( + groups.c.gid, + groups.c.name, + groups.c.description, + groups.c.thumbnail, + groups.c.type, + groups.c.inclusion_rules, + # NOTE: drops timestamps +) + + +def _row_to_model(group: Row) -> Group: + return Group( + gid=group.gid, + name=group.name, + description=group.description, + thumbnail=group.thumbnail, + group_type=group.type, + inclusion_rules=group.inclusion_rules, + ) + + +def _to_group_info_tuple(group: Row) -> GroupInfoTuple: + return ( + _row_to_model(group), + AccessRightsDict( + read=group.access_rights.read, + write=group.access_rights.read, + delete=group.access_rights.delete, + ), + ) + -def _check_group_permissions(group, user_id: int, gid: int, permission: str) -> None: +def _check_group_permissions( + group: Row, user_id: int, gid: int, permission: str +) -> None: if not group.access_rights[permission]: raise UserInsufficientRightsError( user_id=user_id, gid=gid, permission=permission ) -async def _get_user_group( +async def _get_group_and_access_rights_or_raise( conn: AsyncConnection, *, user_id: UserID, gid: GroupID, ): result = await conn.stream( - sa.select(groups, user_to_groups.c.access_rights) + sa.select( + groups, + user_to_groups.c.access_rights, + ) .select_from(user_to_groups.join(groups, user_to_groups.c.gid == groups.c.gid)) - .where(and_(user_to_groups.c.uid == user_id, user_to_groups.c.gid == gid)) + .where((user_to_groups.c.uid == user_id) & (user_to_groups.c.gid == gid)) ) - group = await result.fetchone() - if not group: + row = await result.fetchone() + if not row: raise GroupNotFoundError(gid=gid) - return group + return row async def get_user_from_email( app: web.Application, connection: AsyncConnection | None = None, *, email: str -): +) -> Row: + """ + Raises: + UserNotFoundError + + """ async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: result = await conn.stream(sa.select(users).where(users.c.email == email)) user = await result.fetchone() @@ -94,13 +134,14 @@ async def get_all_user_groups_with_read_access( connection: AsyncConnection | None = None, *, user_id: UserID, -) -> tuple[dict[str, Any], list[dict[str, Any]], dict[str, Any]]: +) -> GroupsByTypeTuple: + """ Returns the user primary group, standard groups and the all group """ - primary_group = {} - user_groups = [] - all_group = {} + primary_group: GroupInfoTuple | None = None + standard_groups: list[GroupInfoTuple] = [] + everyone_group: GroupInfoTuple | None = None query = ( sa.select(groups, user_to_groups.c.access_rights) @@ -115,19 +156,21 @@ async def get_all_user_groups_with_read_access( async for row in result: if row.type == GroupType.EVERYONE: assert row.access_rights["read"] # nosec - all_group = convert_groups_db_to_schema(row) + everyone_group = _to_group_info_tuple(row) elif row.type == GroupType.PRIMARY: assert row.access_rights["read"] # nosec - primary_group = convert_groups_db_to_schema(row) + primary_group = _to_group_info_tuple(row) else: assert row.type == GroupType.STANDARD # nosec # only add if user has read access if row.access_rights["read"]: - user_groups.append(convert_groups_db_to_schema(row)) + standard_groups.append(_to_group_info_tuple(row)) - return (primary_group, user_groups, all_group) + return GroupsByTypeTuple( + primary=primary_group, standard=standard_groups, everyone=everyone_group + ) async def get_all_user_groups( @@ -135,19 +178,19 @@ async def get_all_user_groups( connection: AsyncConnection | None = None, *, user_id: UserID, -) -> list[GroupAtDB]: +) -> list[Group]: """ - Returns all user groups + Returns all user's groups """ async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: result = await conn.stream( - sa.select(groups) + sa.select(_GROUP_COLUMNS) .select_from( user_to_groups.join(groups, user_to_groups.c.gid == groups.c.gid), ) .where(user_to_groups.c.uid == user_id) ) - return [GroupAtDB.model_validate(row) async for row in result] + return [Group.model_validate(row) async for row in result] async def get_user_group( @@ -156,7 +199,7 @@ async def get_user_group( *, user_id: UserID, gid: GroupID, -) -> dict[str, str]: +) -> tuple[Group, AccessRightsDict]: """ Gets group gid if user associated to it and has read access @@ -164,9 +207,13 @@ async def get_user_group( raises UserInsufficientRightsError """ async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: - group = await _get_user_group(conn, user_id=user_id, gid=gid) - _check_group_permissions(group, user_id, gid, "read") - return convert_groups_db_to_schema(group) + row = await _get_group_and_access_rights_or_raise( + conn, user_id=user_id, gid=gid + ) + _check_group_permissions(row, user_id, gid, "read") + + group, access_rights = _to_group_info_tuple(row) + return group, access_rights async def get_product_group_for_user( @@ -175,14 +222,17 @@ async def get_product_group_for_user( *, user_id: UserID, product_gid: GroupID, -) -> dict[str, str]: +) -> tuple[Group, AccessRightsDict]: """ Returns product's group if user belongs to it, otherwise it raises GroupNotFoundError """ async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: - group = await _get_user_group(conn, user_id=user_id, gid=product_gid) - return convert_groups_db_to_schema(group) + row = await _get_group_and_access_rights_or_raise( + conn, user_id=user_id, gid=product_gid + ) + group, access_rights = _to_group_info_tuple(row) + return group, access_rights async def create_user_group( @@ -191,7 +241,7 @@ async def create_user_group( *, user_id: UserID, new_group: dict, -) -> dict[str, Any]: +) -> tuple[Group, AccessRightsDict]: async with transaction_context(get_asyncpg_engine(app), connection) as conn: result = await conn.stream( sa.select(users.c.primary_gid).where(users.c.id == user_id) @@ -204,22 +254,22 @@ async def create_user_group( # pylint: disable=no-value-for-parameter groups.insert() .values(**convert_groups_schema_to_db(new_group)) - .returning(literal_column("*")) + .returning(*_GROUP_COLUMNS) ) - group = await result.fetchone() - assert group # nosec + row = await result.fetchone() + assert row # nosec await conn.execute( # pylint: disable=no-value-for-parameter user_to_groups.insert().values( uid=user_id, - gid=group.gid, + gid=row.gid, access_rights=_DEFAULT_GROUP_OWNER_ACCESS_RIGHTS, ) ) - return convert_groups_db_to_schema( - group, accessRights=_DEFAULT_GROUP_OWNER_ACCESS_RIGHTS - ) + + group = _row_to_model(row) + return group, deepcopy(_DEFAULT_GROUP_OWNER_ACCESS_RIGHTS) async def update_user_group( @@ -229,14 +279,16 @@ async def update_user_group( user_id: UserID, gid: GroupID, new_group_values: dict[str, str], -) -> dict[str, str]: +) -> tuple[Group, AccessRightsDict]: async with transaction_context(get_asyncpg_engine(app), connection) as conn: new_values = { k: v for k, v in convert_groups_schema_to_db(new_group_values).items() if v } - group = await _get_user_group(conn, user_id=user_id, gid=gid) + group = await _get_group_and_access_rights_or_raise( + conn, user_id=user_id, gid=gid + ) _check_group_permissions(group, user_id, gid, "write") result = await conn.stream( @@ -246,12 +298,11 @@ async def update_user_group( .where(groups.c.gid == group.gid) .returning(literal_column("*")) ) - updated_group = await result.fetchone() - assert updated_group # nosec + row = await result.fetchone() + assert row # nosec - return convert_groups_db_to_schema( - updated_group, accessRights=group.access_rights - ) + group, access_rights = _to_group_info_tuple(row) + return group, access_rights async def delete_user_group( @@ -262,7 +313,9 @@ async def delete_user_group( gid: GroupID, ) -> None: async with transaction_context(get_asyncpg_engine(app), connection) as conn: - group = await _get_user_group(conn, user_id=user_id, gid=gid) + group = await _get_group_and_access_rights_or_raise( + conn, user_id=user_id, gid=gid + ) _check_group_permissions(group, user_id, gid, "delete") await conn.execute( @@ -285,7 +338,9 @@ async def list_users_in_group( ) -> list[dict[str, str]]: async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: # first check if the group exists - group = await _get_user_group(conn, user_id=user_id, gid=gid) + group = await _get_group_and_access_rights_or_raise( + conn, user_id=user_id, gid=gid + ) _check_group_permissions(group, user_id, gid, "read") # now get the list @@ -391,7 +446,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_user_group(conn, user_id=user_id, gid=gid) + group = await _get_group_and_access_rights_or_raise( + conn, user_id=user_id, gid=gid + ) _check_group_permissions(group, user_id, gid, "write") # now check the new user exists @@ -445,7 +502,9 @@ async def get_user_in_group( ) -> dict[str, str]: async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: # first check if the group exists - group = await _get_user_group(conn, user_id=user_id, gid=gid) + group = await _get_group_and_access_rights_or_raise( + conn, user_id=user_id, gid=gid + ) _check_group_permissions(group, user_id, gid, "read") # get the user with its permissions @@ -471,7 +530,9 @@ 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_user_group(conn, user_id=user_id, gid=gid) + group = await _get_group_and_access_rights_or_raise( + conn, user_id=user_id, gid=gid + ) _check_group_permissions(group, user_id, gid, "write") # now check the user exists @@ -507,7 +568,9 @@ async def delete_user_in_group( ) -> None: async with transaction_context(get_asyncpg_engine(app), connection) as conn: # first check if the group exists - group = await _get_user_group(conn, user_id=user_id, gid=gid) + group = await _get_group_and_access_rights_or_raise( + conn, user_id=user_id, gid=gid + ) _check_group_permissions(group, user_id, gid, "write") # check the user exists diff --git a/services/web/server/src/simcore_service_webserver/groups/_utils.py b/services/web/server/src/simcore_service_webserver/groups/_utils.py index 79a16995638..c380b8f0fbf 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_utils.py +++ b/services/web/server/src/simcore_service_webserver/groups/_utils.py @@ -1,5 +1,3 @@ -from typing import TypedDict - from aiopg.sa.result import RowProxy _GROUPS_SCHEMA_TO_DB = { @@ -12,12 +10,6 @@ } -class AccessRightsDict(TypedDict): - read: bool - write: bool - delete: bool - - def convert_groups_db_to_schema( db_row: RowProxy, *, prefix: str | None = "", **kwargs ) -> dict: From ab2c5160fe6574e99904a0d4bb1991b4d91f1138 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Fri, 6 Dec 2024 19:32:09 +0100 Subject: [PATCH 14/84] sio --- .../simcore_service_webserver/socketio/_handlers.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/socketio/_handlers.py b/services/web/server/src/simcore_service_webserver/socketio/_handlers.py index 356e2cc1ba7..078c22e8cf7 100644 --- a/services/web/server/src/simcore_service_webserver/socketio/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/socketio/_handlers.py @@ -17,7 +17,7 @@ from servicelib.logging_utils import get_log_record_extra, log_context from servicelib.request_keys import RQT_USERID_KEY -from ..groups.api import list_user_groups_with_read_access +from ..groups.api import list_user_groups_ids_with_read_access from ..login.decorators import login_required from ..products.api import Product, get_current_product from ..resource_manager.user_sessions import managed_resource @@ -89,15 +89,13 @@ async def _set_user_in_group_rooms( app: web.Application, user_id: UserID, socket_id: SocketID ) -> None: """Adds user in rooms associated to its groups""" - primary_group, user_groups, all_group = await list_user_groups_with_read_access( - app, user_id - ) - groups = [primary_group] + user_groups + ([all_group] if bool(all_group) else []) + + group_ids = await list_user_groups_ids_with_read_access(app, user_id=user_id) sio = get_socket_server(app) - for group in groups: + for gid in group_ids: # NOTE socketio need to be upgraded that's why enter_room is not an awaitable - sio.enter_room(socket_id, SocketIORoomStr.from_group_id(group["gid"])) + sio.enter_room(socket_id, SocketIORoomStr.from_group_id(gid)) sio.enter_room(socket_id, SocketIORoomStr.from_user_id(user_id)) From 90ea82da6a0ebab131d7178f6a43dcf76e046c8b Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Fri, 6 Dec 2024 19:35:09 +0100 Subject: [PATCH 15/84] cleanup --- .../tests/{test_groups.py => test_models_groups.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/postgres-database/tests/{test_groups.py => test_models_groups.py} (100%) diff --git a/packages/postgres-database/tests/test_groups.py b/packages/postgres-database/tests/test_models_groups.py similarity index 100% rename from packages/postgres-database/tests/test_groups.py rename to packages/postgres-database/tests/test_models_groups.py From 60f1d928a7f9cef013496a11c8738207145b6ef6 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Fri, 6 Dec 2024 20:02:49 +0100 Subject: [PATCH 16/84] move user groups fixture to pytest_simcore and drop from integration and unit tests --- .../simcore_webserver_groups_fixtures.py | 141 ++++++++++++++++++ services/web/server/tests/conftest.py | 3 +- .../web/server/tests/integration/conftest.py | 74 --------- .../server/tests/unit/with_dbs/conftest.py | 100 +------------ 4 files changed, 144 insertions(+), 174 deletions(-) create mode 100644 packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py diff --git a/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py b/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py new file mode 100644 index 00000000000..ae12be73531 --- /dev/null +++ b/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py @@ -0,0 +1,141 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +""" + + Fixtures for user groups + + NOTE: These fixtures are used in integration and unit tests +""" + + +from collections.abc import AsyncIterator +from typing import Any + +import pytest +from aiohttp.test_utils import TestClient +from models_library.api_schemas_webserver.groups import GroupGet +from pytest_simcore.helpers.webserver_login import NewUser, UserInfoDict +from simcore_service_webserver.groups._common.types import GroupsByTypeTuple +from simcore_service_webserver.groups._groups_api import ( + list_user_groups_with_read_access, +) +from simcore_service_webserver.groups.api import ( + add_user_in_group, + create_user_group, + delete_user_group, +) + + +def _to_group_get_json(group, access_rights) -> dict[str, Any]: + return GroupGet.model_validate( + { + **group.model_dump(), + "access_rights": access_rights, + } + ).model_dump(mode="json") + + +@pytest.fixture +async def logged_user_groups_by_type( + client: TestClient, logged_user: UserInfoDict +) -> GroupsByTypeTuple: + assert client.app + + groups_by_type = await list_user_groups_with_read_access( + client.app, logged_user["id"] + ) + assert groups_by_type.primary + assert groups_by_type.everyone + return groups_by_type + + +@pytest.fixture +async def primary_group( + client: TestClient, + logged_user_groups_by_type: GroupsByTypeTuple, +) -> dict[str, Any]: + assert client.app + assert logged_user_groups_by_type.primary + return _to_group_get_json(*logged_user_groups_by_type.primary) + + +@pytest.fixture +async def standard_groups( + client: TestClient, + logged_user: UserInfoDict, + logged_user_groups_by_type: GroupsByTypeTuple, +) -> AsyncIterator[list[dict[str, Any]]]: + + assert client.app + sparc_group = { + "gid": "5", # this will be replaced + "label": "SPARC", + "description": "Stimulating Peripheral Activity to Relieve Conditions", + "thumbnail": "https://commonfund.nih.gov/sites/default/files/sparc-image-homepage500px.png", + "inclusionRules": {"email": r"@(sparc)+\.(io|com)$"}, + } + team_black_group = { + "gid": "5", # this will be replaced + "label": "team Black", + "description": "THE incredible black team", + "thumbnail": None, + "inclusionRules": {"email": r"@(black)+\.(io|com)$"}, + } + + # create a separate account to own standard groups + async with NewUser( + { + "name": f"{logged_user['name']}_groups_owner", + "role": "USER", + }, + client.app, + ) as owner_user: + # creates two groups + sparc_group = await create_user_group( + app=client.app, + user_id=owner_user["id"], + new_group=sparc_group, + ) + team_black_group = await create_user_group( + app=client.app, + user_id=owner_user["id"], + new_group=team_black_group, + ) + + # adds logged_user to sparc group + await add_user_in_group( + app=client.app, + user_id=owner_user["id"], + gid=sparc_group["gid"], + new_user_id=logged_user["id"], + ) + + # adds logged_user to team-black group + await add_user_in_group( + app=client.app, + user_id=owner_user["id"], + gid=team_black_group["gid"], + new_user_email=logged_user["email"], + ) + + standard_groups = [ + _to_group_get_json(*sg) for sg in logged_user_groups_by_type.standard + ] + + yield standard_groups + + # clean groups + await delete_user_group(client.app, owner_user["id"], sparc_group["gid"]) + await delete_user_group(client.app, owner_user["id"], team_black_group["gid"]) + + +@pytest.fixture +async def all_group( + client: TestClient, + logged_user_groups_by_type: GroupsByTypeTuple, +) -> dict[str, Any]: + assert client.app + assert logged_user_groups_by_type.everyone + + return _to_group_get_json(*logged_user_groups_by_type.everyone) diff --git a/services/web/server/tests/conftest.py b/services/web/server/tests/conftest.py index f215368ad1d..0e1de456b78 100644 --- a/services/web/server/tests/conftest.py +++ b/services/web/server/tests/conftest.py @@ -64,6 +64,7 @@ "pytest_simcore.environment_configs", "pytest_simcore.faker_users_data", "pytest_simcore.hypothesis_type_strategies", + "pytest_simcore.openapi_specs", "pytest_simcore.postgres_service", "pytest_simcore.pydantic_models", "pytest_simcore.pytest_global_environs", @@ -74,8 +75,8 @@ "pytest_simcore.services_api_mocks_for_aiohttp_clients", "pytest_simcore.simcore_service_library_fixtures", "pytest_simcore.simcore_services", + "pytest_simcore.simcore_webserver_groups_fixtures", "pytest_simcore.socketio_client", - "pytest_simcore.openapi_specs", ] diff --git a/services/web/server/tests/integration/conftest.py b/services/web/server/tests/integration/conftest.py index 0dee770f2f2..2f8cda8aa5e 100644 --- a/services/web/server/tests/integration/conftest.py +++ b/services/web/server/tests/integration/conftest.py @@ -15,7 +15,6 @@ import json import logging import sys -from collections.abc import AsyncIterable from copy import deepcopy from pathlib import Path from string import Template @@ -27,13 +26,6 @@ from pytest_simcore.helpers import FIXTURE_CONFIG_CORE_SERVICES_SELECTION from pytest_simcore.helpers.dict_tools import ConfigDict from pytest_simcore.helpers.docker import get_service_published_port -from pytest_simcore.helpers.webserver_login import NewUser, UserInfoDict -from simcore_service_webserver.groups.api import ( - add_user_in_group, - create_user_group, - delete_user_group, - list_user_groups_with_read_access, -) CURRENT_DIR = Path(sys.argv[0] if __name__ == "__main__" else __file__).resolve().parent @@ -177,72 +169,6 @@ def mock_orphaned_services(mocker: MockerFixture) -> mock.Mock: ) -@pytest.fixture -async def primary_group(client, logged_user: UserInfoDict) -> dict[str, str]: - primary_group, _, _ = await list_user_groups_with_read_access( - client.app, logged_user["id"] - ) - return primary_group - - -@pytest.fixture -async def standard_groups( - client, logged_user: UserInfoDict -) -> AsyncIterable[list[dict[str, str]]]: - # create a separate admin account to create some standard groups for the logged user - sparc_group = { - "gid": "5", # this will be replaced - "label": "SPARC", - "description": "Stimulating Peripheral Activity to Relieve Conditions", - "thumbnail": "https://commonfund.nih.gov/sites/default/files/sparc-image-homepage500px.png", - } - team_black_group = { - "gid": "5", # this will be replaced - "label": "team Black", - "description": "THE incredible black team", - "thumbnail": None, - } - async with NewUser( - {"name": f"{logged_user['name']}_admin", "role": "USER"}, client.app - ) as admin_user: - sparc_group = await create_user_group(client.app, admin_user["id"], sparc_group) - team_black_group = await create_user_group( - client.app, admin_user["id"], team_black_group - ) - await add_user_in_group( - client.app, - admin_user["id"], - int(sparc_group["gid"]), - new_user_id=logged_user["id"], - ) - await add_user_in_group( - client.app, - admin_user["id"], - int(team_black_group["gid"]), - new_user_email=logged_user["email"], - ) - - _, standard_groups, _ = await list_user_groups_with_read_access( - client.app, logged_user["id"] - ) - - yield standard_groups - - # clean groups - await delete_user_group(client.app, admin_user["id"], int(sparc_group["gid"])) - await delete_user_group( - client.app, admin_user["id"], int(team_black_group["gid"]) - ) - - -@pytest.fixture -async def all_group(client, logged_user) -> dict[str, str]: - _, _, all_group = await list_user_groups_with_read_access( - client.app, logged_user["id"] - ) - return all_group - - @pytest.fixture(scope="session") def osparc_product_name() -> str: return "osparc" diff --git a/services/web/server/tests/unit/with_dbs/conftest.py b/services/web/server/tests/unit/with_dbs/conftest.py index 37217d58519..991d7fd8d56 100644 --- a/services/web/server/tests/unit/with_dbs/conftest.py +++ b/services/web/server/tests/unit/with_dbs/conftest.py @@ -19,7 +19,6 @@ from copy import deepcopy from decimal import Decimal from pathlib import Path -from typing import Any from unittest import mock from unittest.mock import AsyncMock, MagicMock @@ -47,7 +46,7 @@ from pytest_simcore.helpers.faker_factories import random_product from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict from pytest_simcore.helpers.typing_env import EnvVarsDict -from pytest_simcore.helpers.webserver_login import NewUser, UserInfoDict +from pytest_simcore.helpers.webserver_login import UserInfoDict from pytest_simcore.helpers.webserver_parametrizations import MockedStorageSubsystem from pytest_simcore.helpers.webserver_projects import NewProject from redis import Redis @@ -69,12 +68,6 @@ from simcore_service_webserver._constants import INDEX_RESOURCE_NAME from simcore_service_webserver.application import create_application from simcore_service_webserver.db.plugin import get_database_engine -from simcore_service_webserver.groups.api import ( - add_user_in_group, - create_user_group, - delete_user_group, - list_user_groups_with_read_access, -) from simcore_service_webserver.projects.models import ProjectDict from simcore_service_webserver.statics._constants import ( FRONTEND_APP_DEFAULT, @@ -592,97 +585,6 @@ async def redis_locks_client( # Moved to packages/pytest-simcore/src/pytest_simcore/websocket_client.py -# USER GROUP FIXTURES ------------------------------------------------------- - - -@pytest.fixture -async def primary_group( - client: TestClient, - logged_user: UserInfoDict, -) -> dict[str, Any]: - assert client.app - primary_group, _, _ = await list_user_groups_with_read_access( - client.app, logged_user["id"] - ) - return primary_group - - -@pytest.fixture -async def standard_groups( - client: TestClient, - logged_user: UserInfoDict, -) -> AsyncIterator[list[dict[str, Any]]]: - assert client.app - sparc_group = { - "gid": "5", # this will be replaced - "label": "SPARC", - "description": "Stimulating Peripheral Activity to Relieve Conditions", - "thumbnail": "https://commonfund.nih.gov/sites/default/files/sparc-image-homepage500px.png", - "inclusionRules": {"email": r"@(sparc)+\.(io|com)$"}, - } - team_black_group = { - "gid": "5", # this will be replaced - "label": "team Black", - "description": "THE incredible black team", - "thumbnail": None, - "inclusionRules": {"email": r"@(black)+\.(io|com)$"}, - } - - # create a separate account to own standard groups - async with NewUser( - {"name": f"{logged_user['name']}_groups_owner", "role": "USER"}, client.app - ) as owner_user: - # creates two groups - sparc_group = await create_user_group( - app=client.app, - user_id=owner_user["id"], - new_group=sparc_group, - ) - team_black_group = await create_user_group( - app=client.app, - user_id=owner_user["id"], - new_group=team_black_group, - ) - - # adds logged_user to sparc group - await add_user_in_group( - app=client.app, - user_id=owner_user["id"], - gid=sparc_group["gid"], - new_user_id=logged_user["id"], - ) - - # adds logged_user to team-black group - await add_user_in_group( - app=client.app, - user_id=owner_user["id"], - gid=team_black_group["gid"], - new_user_email=logged_user["email"], - ) - - _, std_groups, _ = await list_user_groups_with_read_access( - client.app, logged_user["id"] - ) - - yield std_groups - - # clean groups - await delete_user_group(client.app, owner_user["id"], sparc_group["gid"]) - await delete_user_group(client.app, owner_user["id"], team_black_group["gid"]) - - -@pytest.fixture -async def all_group( - client: TestClient, - logged_user: UserInfoDict, -) -> dict[str, str]: - assert client.app - _, _, all_group = await list_user_groups_with_read_access( - client.app, logged_user["id"] - ) - return all_group - - @pytest.fixture def mock_dynamic_scheduler_rabbitmq(mocker: MockerFixture) -> None: mocker.patch( From 84674ec7fe8b1cf455b493f5e18a62a2d0a7af51 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Fri, 6 Dec 2024 20:11:04 +0100 Subject: [PATCH 17/84] rm dependency --- .../tests/integration/01/test_garbage_collection.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/services/web/server/tests/integration/01/test_garbage_collection.py b/services/web/server/tests/integration/01/test_garbage_collection.py index 9c5c133f378..63ebe4a518f 100644 --- a/services/web/server/tests/integration/01/test_garbage_collection.py +++ b/services/web/server/tests/integration/01/test_garbage_collection.py @@ -21,6 +21,7 @@ from aiohttp import web from aiohttp.test_utils import TestClient from aioresponses import aioresponses +from models_library.groups import EVERYONE_GROUP_ID from models_library.projects_state import RunningState from pytest_mock import MockerFixture from pytest_simcore.helpers.webserver_login import UserInfoDict, log_client_in @@ -35,11 +36,7 @@ from simcore_service_webserver.director_v2.plugin import setup_director_v2 from simcore_service_webserver.garbage_collector import _core as gc_core from simcore_service_webserver.garbage_collector.plugin import setup_garbage_collector -from simcore_service_webserver.groups.api import ( - add_user_in_group, - create_user_group, - list_user_groups_with_read_access, -) +from simcore_service_webserver.groups.api import add_user_in_group, create_user_group from simcore_service_webserver.login.plugin import setup_login from simcore_service_webserver.projects._crud_api_delete import get_scheduled_tasks from simcore_service_webserver.projects._groups_db import update_or_insert_project_group @@ -261,13 +258,12 @@ async def get_template_project( ): """returns a tempalte shared with all""" assert client.app - _, _, all_group = await list_user_groups_with_read_access(client.app, user["id"]) # the information comes from a file, randomize it project_data["name"] = f"Fake template {uuid4()}" project_data["uuid"] = f"{uuid4()}" project_data["accessRights"] = { - str(all_group["gid"]): {"read": True, "write": False, "delete": False} + str(EVERYONE_GROUP_ID): {"read": True, "write": False, "delete": False} } if access_rights is not None: project_data["accessRights"].update(access_rights) From 8714e20b5e85d46273cdb7179b51a3fd8eace70f Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Fri, 6 Dec 2024 20:47:15 +0100 Subject: [PATCH 18/84] factory --- .../simcore_webserver_groups_fixtures.py | 74 +++++++++++++------ .../tests/unit/with_dbs/01/test_groups.py | 8 +- 2 files changed, 55 insertions(+), 27 deletions(-) diff --git a/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py b/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py index ae12be73531..9ae55d296b5 100644 --- a/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py +++ b/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py @@ -3,28 +3,27 @@ # pylint: disable=unused-variable """ - Fixtures for user groups + Fixtures for groups NOTE: These fixtures are used in integration and unit tests """ from collections.abc import AsyncIterator -from typing import Any +from typing import Any, Protocol import pytest +from aiohttp import web from aiohttp.test_utils import TestClient from models_library.api_schemas_webserver.groups import GroupGet +from models_library.users import UserID from pytest_simcore.helpers.webserver_login import NewUser, UserInfoDict +from simcore_service_webserver.groups import _groups_db from simcore_service_webserver.groups._common.types import GroupsByTypeTuple from simcore_service_webserver.groups._groups_api import ( list_user_groups_with_read_access, ) -from simcore_service_webserver.groups.api import ( - add_user_in_group, - create_user_group, - delete_user_group, -) +from simcore_service_webserver.groups.api import add_user_in_group, delete_user_group def _to_group_get_json(group, access_rights) -> dict[str, Any]: @@ -36,6 +35,36 @@ def _to_group_get_json(group, access_rights) -> dict[str, Any]: ).model_dump(mode="json") +# +# FACTORY FIXTURES +# + + +class CreateUserGroupCallable(Protocol): + async def __call__( + self, app: web.Application, user_id: UserID, new_group: dict + ) -> dict[str, Any]: + ... + + +@pytest.fixture +def create_user_group() -> CreateUserGroupCallable: + async def _create( + app: web.Application, user_id: UserID, new_group: dict + ) -> dict[str, Any]: + group, access_rights = await _groups_db.create_user_group( + app, user_id=user_id, new_group=new_group + ) + return _to_group_get_json(group=group, access_rights=access_rights) + + return _create + + +# +# USER'S GROUPS FIXTURES +# + + @pytest.fixture async def logged_user_groups_by_type( client: TestClient, logged_user: UserInfoDict @@ -65,24 +94,10 @@ async def standard_groups( client: TestClient, logged_user: UserInfoDict, logged_user_groups_by_type: GroupsByTypeTuple, + create_user_group: CreateUserGroupCallable, ) -> AsyncIterator[list[dict[str, Any]]]: assert client.app - sparc_group = { - "gid": "5", # this will be replaced - "label": "SPARC", - "description": "Stimulating Peripheral Activity to Relieve Conditions", - "thumbnail": "https://commonfund.nih.gov/sites/default/files/sparc-image-homepage500px.png", - "inclusionRules": {"email": r"@(sparc)+\.(io|com)$"}, - } - team_black_group = { - "gid": "5", # this will be replaced - "label": "team Black", - "description": "THE incredible black team", - "thumbnail": None, - "inclusionRules": {"email": r"@(black)+\.(io|com)$"}, - } - # create a separate account to own standard groups async with NewUser( { @@ -95,12 +110,23 @@ async def standard_groups( sparc_group = await create_user_group( app=client.app, user_id=owner_user["id"], - new_group=sparc_group, + new_group={ + "label": "SPARC", + "description": "Stimulating Peripheral Activity to Relieve Conditions", + "thumbnail": "https://commonfund.nih.gov/sites/default/files/sparc-image-homepage500px.png", + "inclusionRules": {"email": r"@(sparc)+\.(io|com)$"}, + }, ) team_black_group = await create_user_group( app=client.app, user_id=owner_user["id"], - new_group=team_black_group, + new_group={ + "gid": "5", # this will be replaced + "label": "team Black", + "description": "THE incredible black team", + "thumbnail": None, + "inclusionRules": {"email": r"@(black)+\.(io|com)$"}, + }, ) # adds logged_user to sparc group diff --git a/services/web/server/tests/unit/with_dbs/01/test_groups.py b/services/web/server/tests/unit/with_dbs/01/test_groups.py index 197fca37c99..0afd840c657 100644 --- a/services/web/server/tests/unit/with_dbs/01/test_groups.py +++ b/services/web/server/tests/unit/with_dbs/01/test_groups.py @@ -18,20 +18,20 @@ ExpectedResponse, standard_role_response, ) +from pytest_simcore.simcore_webserver_groups_fixtures import CreateUserGroupCallable from servicelib.aiohttp import status from servicelib.aiohttp.application import create_safe_application from simcore_postgres_database.models.users import UserRole from simcore_service_webserver._meta import API_VTAG from simcore_service_webserver.application_settings import setup_settings from simcore_service_webserver.db.plugin import setup_db +from simcore_service_webserver.groups._common.types import AccessRightsDict from simcore_service_webserver.groups._groups_db import ( _DEFAULT_GROUP_OWNER_ACCESS_RIGHTS, _DEFAULT_GROUP_READ_ACCESS_RIGHTS, ) -from simcore_service_webserver.groups._utils import AccessRightsDict from simcore_service_webserver.groups.api import ( auto_add_user_to_groups, - create_user_group, delete_user_group, ) from simcore_service_webserver.groups.plugin import setup_groups @@ -618,7 +618,9 @@ async def test_add_user_gets_added_to_group( @pytest.fixture async def group_where_logged_user_is_the_owner( - client: TestClient, logged_user: UserInfoDict + client: TestClient, + logged_user: UserInfoDict, + create_user_group: CreateUserGroupCallable, ) -> AsyncIterator[dict[str, Any]]: assert client.app group = await create_user_group( From 7458cdf83cea5c2745088a39aa954a7542dcee48 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Fri, 6 Dec 2024 20:47:49 +0100 Subject: [PATCH 19/84] api --- .../groups/_groups_api.py | 59 +++++++++++++++++++ .../simcore_service_webserver/groups/api.py | 45 +++----------- 2 files changed, 66 insertions(+), 38 deletions(-) create mode 100644 services/web/server/src/simcore_service_webserver/groups/_groups_api.py diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py new file mode 100644 index 00000000000..397467808bf --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py @@ -0,0 +1,59 @@ +from aiohttp import web +from models_library.groups import Group +from models_library.users import GroupID, UserID + +from . import _groups_db +from ._common.types import AccessRightsDict, GroupsByTypeTuple + + +async def list_user_groups_ids_with_read_access( + app: web.Application, *, user_id: UserID +) -> list[GroupID]: + # TODO: Room for optimization. For the moment we reuse existing db functions + groups_by_type = await _groups_db.get_all_user_groups_with_read_access( + app, user_id=user_id + ) + assert groups_by_type.primary # nosec + + groups_ids = [groups_by_type.primary[0].gid] + + # NOTE: that product-groups will not be listed here + groups_ids += [g[0].gid for g in groups_by_type.standard] + + assert groups_by_type.everyone # nosec + groups_ids.append(groups_by_type.everyone[0].gid) + + return groups_ids + + +async def list_user_groups_with_read_access( + app: web.Application, user_id: UserID +) -> GroupsByTypeTuple: + """ + Returns the user primary group, standard groups and the all group + """ + # NOTE: Careful! It seems we are filtering out groups, such as Product Groups, + # because they do not have read access. I believe this was done because the + # frontend did not want to display them. + + return await _groups_db.get_all_user_groups_with_read_access(app, user_id=user_id) + + +async def list_all_user_groups_ids( + app: web.Application, user_id: UserID +) -> list[GroupID]: + # TODO: Room for optimization. For the moment we reuse existing db functions + user_groups = await _groups_db.get_all_user_groups(app, user_id=user_id) + return [g.gid for g in user_groups] + + +async def get_product_group_for_user( + app: web.Application, user_id: UserID, product_gid: GroupID +) -> tuple[Group, AccessRightsDict]: + """ + Returns product's group if user belongs to it, otherwise it + raises GroupNotFoundError + """ + return await _groups_db.get_product_group_for_user( + app, user_id=user_id, product_gid=product_gid + ) diff --git a/services/web/server/src/simcore_service_webserver/groups/api.py b/services/web/server/src/simcore_service_webserver/groups/api.py index 01ed47a144b..7971e77f79d 100644 --- a/services/web/server/src/simcore_service_webserver/groups/api.py +++ b/services/web/server/src/simcore_service_webserver/groups/api.py @@ -7,28 +7,15 @@ from ..users.api import get_user from . import _groups_db -from ._utils import AccessRightsDict +from ._common.types import AccessRightsDict +from ._groups_api import list_all_user_groups_ids, list_user_groups_ids_with_read_access from .exceptions import GroupsError - -async def list_user_groups_with_read_access( - app: web.Application, user_id: UserID -) -> tuple[dict[str, Any], list[dict[str, Any]], dict[str, Any]]: - """ - Returns the user primary group, standard groups and the all group - """ - # NOTE: Careful! It seems we are filtering out groups, such as Product Groups, - # because they do not have read access. I believe this was done because the frontend did not want to display them. - return await _groups_db.get_all_user_groups_with_read_access(app, user_id=user_id) - - -async def list_all_user_groups(app: web.Application, user_id: UserID) -> list[Group]: - """ - Return all user groups - """ - groups_db = await _groups_db.get_all_user_groups(app, user_id=user_id) - - return [Group.model_construct(**group.model_dump()) for group in groups_db] +__all__: tuple[str, ...] = ( + "list_user_groups_ids_with_read_access", + "list_all_user_groups_ids", + # nopycln: file +) async def get_user_group( @@ -43,24 +30,6 @@ async def get_user_group( return await _groups_db.get_user_group(app, user_id=user_id, gid=gid) -async def get_product_group_for_user( - app: web.Application, user_id: UserID, product_gid: GroupID -) -> dict[str, str]: - """ - Returns product's group if user belongs to it, otherwise it - raises GroupNotFoundError - """ - return await _groups_db.get_product_group_for_user( - app, user_id=user_id, product_gid=product_gid - ) - - -async def create_user_group( - app: web.Application, user_id: UserID, new_group: dict -) -> dict[str, Any]: - return await _groups_db.create_user_group(app, user_id=user_id, new_group=new_group) - - async def update_user_group( app: web.Application, user_id: UserID, From 6529cce6c06697e8fa89234ab3e167b92b7cf5c2 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Fri, 6 Dec 2024 20:48:14 +0100 Subject: [PATCH 20/84] nodes uses new inteface --- .../simcore_service_webserver/projects/_nodes_handlers.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/projects/_nodes_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_nodes_handlers.py index b1088b67873..4b445cb8379 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_nodes_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_nodes_handlers.py @@ -65,7 +65,7 @@ from ..catalog import client as catalog_client from ..director_v2 import api as director_v2_api from ..dynamic_scheduler import api as dynamic_scheduler_api -from ..groups.api import get_group_from_gid, list_all_user_groups +from ..groups.api import get_group_from_gid, list_all_user_groups_ids from ..groups.exceptions import GroupNotFoundError from ..login.decorators import login_required from ..projects.api import has_user_project_access_rights @@ -571,8 +571,10 @@ async def get_project_services_access_for_gid( _user_id = await get_user_id_from_gid( app=request.app, primary_gid=query_params.for_gid ) - _user_groups = await list_all_user_groups(app=request.app, user_id=_user_id) - groups_to_compare.update({group.gid for group in _user_groups}) + user_groups_ids = await list_all_user_groups_ids( + app=request.app, user_id=_user_id + ) + groups_to_compare.update(set(user_groups_ids)) groups_to_compare.add(query_params.for_gid) elif _sharing_with_group.group_type == GroupTypeInModel.STANDARD: groups_to_compare = {query_params.for_gid} From 88a98ef880333586209bd08cd2ff15e2d9f7647b Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Fri, 6 Dec 2024 20:48:38 +0100 Subject: [PATCH 21/84] tests --- .../02/test_projects_crud_handlers.py | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py b/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py index 26d6f0cfb0e..95a2671739b 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_crud_handlers.py @@ -33,10 +33,8 @@ from simcore_postgres_database.models.projects_to_products import projects_to_products from simcore_service_webserver._meta import api_version_prefix from simcore_service_webserver.db.models import UserRole -from simcore_service_webserver.groups.api import ( - auto_add_user_to_product_group, - get_product_group_for_user, -) +from simcore_service_webserver.groups._groups_api import get_product_group_for_user +from simcore_service_webserver.groups.api import auto_add_user_to_product_group from simcore_service_webserver.groups.exceptions import GroupNotFoundError from simcore_service_webserver.products.api import get_product from simcore_service_webserver.projects._permalink_api import ProjectPermalink @@ -294,9 +292,14 @@ async def logged_user_registed_in_two_products( # registered to osparc osparc_product = get_product(client.app, "osparc") assert osparc_product.group_id - assert await get_product_group_for_user( - client.app, user_id=logged_user["id"], product_gid=osparc_product.group_id + + group, _ = await get_product_group_for_user( + # should not raise + client.app, + user_id=logged_user["id"], + product_gid=osparc_product.group_id, ) + assert group.gid == osparc_product.group_id # not registered to s4l s4l_product = get_product(client.app, s4l_products_db_name) @@ -312,9 +315,13 @@ async def logged_user_registed_in_two_products( client.app, user_id=logged_user["id"], product_name=s4l_products_db_name ) - assert await get_product_group_for_user( - client.app, user_id=logged_user["id"], product_gid=s4l_product.group_id + group, _ = await get_product_group_for_user( + # should not raise + client.app, + user_id=logged_user["id"], + product_gid=s4l_product.group_id, ) + assert group.gid == s4l_product.group_id @pytest.mark.parametrize( From a67a576d8060561d2007d9536c6de605740ff2b0 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Fri, 6 Dec 2024 20:50:46 +0100 Subject: [PATCH 22/84] fixes test_gc --- .../integration/01/test_garbage_collection.py | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/services/web/server/tests/integration/01/test_garbage_collection.py b/services/web/server/tests/integration/01/test_garbage_collection.py index 63ebe4a518f..da40536fe8a 100644 --- a/services/web/server/tests/integration/01/test_garbage_collection.py +++ b/services/web/server/tests/integration/01/test_garbage_collection.py @@ -26,6 +26,7 @@ from pytest_mock import MockerFixture from pytest_simcore.helpers.webserver_login import UserInfoDict, log_client_in from pytest_simcore.helpers.webserver_projects import create_project, empty_project_data +from pytest_simcore.simcore_webserver_groups_fixtures import CreateUserGroupCallable from servicelib.aiohttp.application import create_safe_application from settings_library.rabbit import RabbitSettings from settings_library.redis import RedisDatabase, RedisSettings @@ -36,7 +37,7 @@ from simcore_service_webserver.director_v2.plugin import setup_director_v2 from simcore_service_webserver.garbage_collector import _core as gc_core from simcore_service_webserver.garbage_collector.plugin import setup_garbage_collector -from simcore_service_webserver.groups.api import add_user_in_group, create_user_group +from simcore_service_webserver.groups.api import add_user_in_group from simcore_service_webserver.login.plugin import setup_login from simcore_service_webserver.projects._crud_api_delete import get_scheduled_tasks from simcore_service_webserver.projects._groups_db import update_or_insert_project_group @@ -277,8 +278,11 @@ async def get_template_project( ) -async def get_group(client: TestClient, user): +async def get_group( + client: TestClient, user, create_user_group: CreateUserGroupCallable +): """Creates a group for a given user""" + assert client.app return await create_user_group( app=client.app, user_id=user["id"], @@ -591,6 +595,7 @@ async def test_t4_project_shared_with_group_transferred_to_user_in_group_on_owne aiopg_engine: aiopg.sa.engine.Engine, tests_data_dir: Path, osparc_product_name: str, + create_user_group: CreateUserGroupCallable, ): """ USER "u1" creates a GROUP "g1" and invites USERS "u2" and "u3"; @@ -603,7 +608,7 @@ async def test_t4_project_shared_with_group_transferred_to_user_in_group_on_owne u3 = await login_user(client) # creating g1 and inviting u2 and u3 - g1 = await get_group(client, u1) + g1 = await get_group(client, u1, create_user_group) await invite_user_to_group(client, owner=u1, invitee=u2, group=g1) await invite_user_to_group(client, owner=u1, invitee=u3, group=g1) @@ -681,6 +686,7 @@ async def test_t6_project_shared_with_group_transferred_to_last_user_in_group_on aiopg_engine: aiopg.sa.engine.Engine, tests_data_dir: Path, osparc_product_name: str, + create_user_group: CreateUserGroupCallable, ): """ USER "u1" creates a GROUP "g1" and invites USERS "u2" and "u3"; @@ -695,7 +701,7 @@ async def test_t6_project_shared_with_group_transferred_to_last_user_in_group_on u3 = await login_user(client) # creating g1 and inviting u2 and u3 - g1 = await get_group(client, u1) + g1 = await get_group(client, u1, create_user_group) await invite_user_to_group(client, owner=u1, invitee=u2, group=g1) await invite_user_to_group(client, owner=u1, invitee=u3, group=g1) @@ -752,6 +758,7 @@ async def test_t7_project_shared_with_group_transferred_from_one_member_to_the_l aiopg_engine: aiopg.sa.engine.Engine, tests_data_dir: Path, osparc_product_name: str, + create_user_group: CreateUserGroupCallable, ): """ USER "u1" creates a GROUP "g1" and invites USERS "u2" and "u3"; @@ -769,7 +776,7 @@ async def test_t7_project_shared_with_group_transferred_from_one_member_to_the_l u3 = await login_user(client) # creating g1 and inviting u2 and u3 - g1 = await get_group(client, u1) + g1 = await get_group(client, u1, create_user_group) await invite_user_to_group(client, owner=u1, invitee=u2, group=g1) await invite_user_to_group(client, owner=u1, invitee=u3, group=g1) @@ -1048,6 +1055,7 @@ async def test_t11_owner_and_all_users_in_group_marked_as_guests( aiopg_engine: aiopg.sa.engine.Engine, tests_data_dir: Path, osparc_product_name: str, + create_user_group: CreateUserGroupCallable, ): """ USER "u1" creates a group and invites "u2" and "u3"; @@ -1060,7 +1068,7 @@ async def test_t11_owner_and_all_users_in_group_marked_as_guests( u3 = await login_user(client) # creating g1 and inviting u2 and u3 - g1 = await get_group(client, u1) + g1 = await get_group(client, u1, create_user_group) await invite_user_to_group(client, owner=u1, invitee=u2, group=g1) await invite_user_to_group(client, owner=u1, invitee=u3, group=g1) From 202e8dd987507df937b040f673e45c73b123f1b6 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Fri, 6 Dec 2024 20:54:22 +0100 Subject: [PATCH 23/84] get_user_group --- .../groups/_groups_api.py | 12 +++++ .../groups/_groups_handlers.py | 45 +++++++++++++------ .../simcore_service_webserver/groups/api.py | 12 ----- 3 files changed, 44 insertions(+), 25 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py index 397467808bf..7d5ad84c6e5 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py @@ -57,3 +57,15 @@ async def get_product_group_for_user( return await _groups_db.get_product_group_for_user( app, user_id=user_id, product_gid=product_gid ) + + +async def get_user_group( + app: web.Application, user_id: UserID, gid: GroupID +) -> tuple[Group, AccessRightsDict]: + """ + Gets group gid if user associated to it and has read access + + raises GroupNotFoundError + raises UserInsufficientRightsError + """ + return await _groups_db.get_user_group(app, user_id=user_id, gid=gid) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py index 1c899c0aad9..9f4006c4363 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py @@ -11,6 +11,7 @@ GroupUserUpdate, MyGroupsGet, ) +from models_library.groups import Group from pydantic import TypeAdapter from servicelib.aiohttp import status from servicelib.aiohttp.requests_validation import ( @@ -23,13 +24,14 @@ from ..products.api import Product, get_current_product from ..security.decorators import permission_required from ..utils_aiohttp import envelope_json_response -from . import api +from . import _groups_api from ._common.exceptions_handlers import handle_plugin_requests_exceptions from ._common.models import ( GroupsPathParams, GroupsRequestContext, GroupsUsersPathParams, ) +from ._common.types import AccessRightsDict from .exceptions import GroupNotFoundError _logger = logging.getLogger(__name__) @@ -38,6 +40,16 @@ routes = web.RouteTableDef() +def _to_groupget_model(group: Group, access_rights: AccessRightsDict) -> GroupGet: + # Fuses both dataset into GroupSet + return GroupGet.model_validate( + { + **group.model_dump(), + "access_rights": access_rights, + } + ) + + @routes.get(f"/{API_VTAG}/groups", name="list_groups") @login_required @permission_required("groups.read") @@ -49,28 +61,32 @@ async def list_groups(request: web.Request): product: Product = get_current_product(request) req_ctx = GroupsRequestContext.model_validate(request) - primary_group, user_groups, all_group = await api.list_user_groups_with_read_access( + groups_by_type = await _groups_api.list_user_groups_with_read_access( request.app, req_ctx.user_id ) - my_group = { - "me": primary_group, - "organizations": user_groups, - "all": all_group, - "product": None, - } + assert groups_by_type.primary + assert groups_by_type.everyone + + my_product_group = None if product.group_id: with suppress(GroupNotFoundError): # Product is optional - my_group["product"] = await api.get_product_group_for_user( + my_product_group = await _groups_api.get_product_group_for_user( app=request.app, user_id=req_ctx.user_id, product_gid=product.group_id, ) - assert MyGroupsGet.model_validate(my_group) is not None # nosec - return envelope_json_response(my_group) + my_groups = MyGroupsGet( + me=_to_groupget_model(*groups_by_type.primary), + organizations=[_to_groupget_model(*gi) for gi in groups_by_type.standard], + all=_to_groupget_model(*groups_by_type.everyone), + product=_to_groupget_model(*my_product_group) if my_product_group else None, + ) + + return envelope_json_response(my_groups) # @@ -87,8 +103,11 @@ async def get_group(request: web.Request): req_ctx = GroupsRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(GroupsPathParams, request) - group = await api.get_user_group(request.app, req_ctx.user_id, path_params.gid) - assert GroupGet.model_validate(group) is not None # nosec + group_info = await _groups_api.get_user_group( + request.app, req_ctx.user_id, path_params.gid + ) + + group = _to_groupget_model(*group_info) return envelope_json_response(group) diff --git a/services/web/server/src/simcore_service_webserver/groups/api.py b/services/web/server/src/simcore_service_webserver/groups/api.py index 7971e77f79d..bda3d0c37d5 100644 --- a/services/web/server/src/simcore_service_webserver/groups/api.py +++ b/services/web/server/src/simcore_service_webserver/groups/api.py @@ -18,18 +18,6 @@ ) -async def get_user_group( - app: web.Application, user_id: UserID, gid: GroupID -) -> dict[str, str]: - """ - Gets group gid if user associated to it and has read access - - raises GroupNotFoundError - raises UserInsufficientRightsError - """ - return await _groups_db.get_user_group(app, user_id=user_id, gid=gid) - - async def update_user_group( app: web.Application, user_id: UserID, From ed5583445599c55e9c7d264f6def226d9ce43c0f Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Fri, 6 Dec 2024 20:57:09 +0100 Subject: [PATCH 24/84] update_user_group --- .../simcore_service_webserver/groups/_groups_api.py | 11 +++++++++++ .../groups/_groups_handlers.py | 5 +++-- .../src/simcore_service_webserver/groups/api.py | 11 ----------- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py index 7d5ad84c6e5..11076e03566 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py @@ -69,3 +69,14 @@ async def get_user_group( raises UserInsufficientRightsError """ return await _groups_db.get_user_group(app, user_id=user_id, gid=gid) + + +async def update_user_group( + app: web.Application, + user_id: UserID, + gid: GroupID, + new_group_values: dict[str, str], +) -> tuple[Group, AccessRightsDict]: + return await _groups_db.update_user_group( + app, user_id=user_id, gid=gid, new_group_values=new_group_values + ) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py index 9f4006c4363..c9d6dcb8da3 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py @@ -137,10 +137,11 @@ async def update_group(request: web.Request): update: GroupUpdate = await parse_request_body_as(GroupUpdate, request) new_group_values = update.model_dump(exclude_unset=True) - updated_group = await api.update_user_group( + group_info = await _groups_api.update_user_group( request.app, req_ctx.user_id, path_params.gid, new_group_values ) - assert GroupGet.model_validate(updated_group) is not None # nosec + + updated_group = _to_groupget_model(*group_info) return envelope_json_response(updated_group) diff --git a/services/web/server/src/simcore_service_webserver/groups/api.py b/services/web/server/src/simcore_service_webserver/groups/api.py index bda3d0c37d5..3847cae7615 100644 --- a/services/web/server/src/simcore_service_webserver/groups/api.py +++ b/services/web/server/src/simcore_service_webserver/groups/api.py @@ -18,17 +18,6 @@ ) -async def update_user_group( - app: web.Application, - user_id: UserID, - gid: GroupID, - new_group_values: dict[str, str], -) -> dict[str, str]: - return await _groups_db.update_user_group( - app, user_id=user_id, gid=gid, new_group_values=new_group_values - ) - - async def delete_user_group( app: web.Application, user_id: UserID, gid: GroupID ) -> None: From beedae11b0fca9575df7b0083ea78bc779657a3f Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Fri, 6 Dec 2024 20:59:48 +0100 Subject: [PATCH 25/84] cleanup --- .../groups/_groups_api.py | 23 +++++++++++++------ .../groups/_groups_handlers.py | 9 +++++--- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py index 11076e03566..a59ff9ef57a 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py @@ -27,7 +27,7 @@ async def list_user_groups_ids_with_read_access( async def list_user_groups_with_read_access( - app: web.Application, user_id: UserID + app: web.Application, *, user_id: UserID ) -> GroupsByTypeTuple: """ Returns the user primary group, standard groups and the all group @@ -40,7 +40,7 @@ async def list_user_groups_with_read_access( async def list_all_user_groups_ids( - app: web.Application, user_id: UserID + app: web.Application, *, user_id: UserID ) -> list[GroupID]: # TODO: Room for optimization. For the moment we reuse existing db functions user_groups = await _groups_db.get_all_user_groups(app, user_id=user_id) @@ -48,7 +48,7 @@ async def list_all_user_groups_ids( async def get_product_group_for_user( - app: web.Application, user_id: UserID, product_gid: GroupID + app: web.Application, *, user_id: UserID, product_gid: GroupID ) -> tuple[Group, AccessRightsDict]: """ Returns product's group if user belongs to it, otherwise it @@ -60,7 +60,10 @@ async def get_product_group_for_user( async def get_user_group( - app: web.Application, user_id: UserID, gid: GroupID + app: web.Application, + *, + user_id: UserID, + group_id: GroupID, ) -> tuple[Group, AccessRightsDict]: """ Gets group gid if user associated to it and has read access @@ -68,15 +71,21 @@ async def get_user_group( raises GroupNotFoundError raises UserInsufficientRightsError """ - return await _groups_db.get_user_group(app, user_id=user_id, gid=gid) + return await _groups_db.get_user_group(app, user_id=user_id, gid=group_id) async def update_user_group( app: web.Application, + *, user_id: UserID, - gid: GroupID, + group_id: GroupID, new_group_values: dict[str, str], ) -> tuple[Group, AccessRightsDict]: + """ + + raises GroupNotFoundError + raises UserInsufficientRightsError + """ return await _groups_db.update_user_group( - app, user_id=user_id, gid=gid, new_group_values=new_group_values + app, user_id=user_id, gid=group_id, new_group_values=new_group_values ) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py index c9d6dcb8da3..b96374fe000 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py @@ -62,7 +62,7 @@ async def list_groups(request: web.Request): req_ctx = GroupsRequestContext.model_validate(request) groups_by_type = await _groups_api.list_user_groups_with_read_access( - request.app, req_ctx.user_id + request.app, user_id=req_ctx.user_id ) assert groups_by_type.primary @@ -104,7 +104,7 @@ async def get_group(request: web.Request): path_params = parse_request_path_parameters_as(GroupsPathParams, request) group_info = await _groups_api.get_user_group( - request.app, req_ctx.user_id, path_params.gid + request.app, user_id=req_ctx.user_id, group_id=path_params.gid ) group = _to_groupget_model(*group_info) @@ -138,7 +138,10 @@ async def update_group(request: web.Request): new_group_values = update.model_dump(exclude_unset=True) group_info = await _groups_api.update_user_group( - request.app, req_ctx.user_id, path_params.gid, new_group_values + request.app, + user_id=req_ctx.user_id, + group_id=path_params.gid, + new_group_values=new_group_values, ) updated_group = _to_groupget_model(*group_info) From ae9bb7e97e31e9bbdb856f53fc3e3cde125ddbf2 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Fri, 6 Dec 2024 21:11:51 +0100 Subject: [PATCH 26/84] organizations --- .../groups/_groups_api.py | 32 +++++++++++++++++-- .../groups/_groups_handlers.py | 23 ++++++++----- .../simcore_service_webserver/groups/api.py | 6 ---- 3 files changed, 45 insertions(+), 16 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py index a59ff9ef57a..abe46dcdf5c 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py @@ -59,7 +59,24 @@ async def get_product_group_for_user( ) -async def get_user_group( +# +# ORGANIZATIONS CRUD operations +# + + +async def create_organization( + app: web.Application, *, user_id: UserID, new_group_values: dict +) -> tuple[Group, AccessRightsDict]: + """ + raises GroupNotFoundError + raises UserInsufficientRightsError + """ + return await _groups_db.create_user_group( + app, user_id=user_id, new_group=new_group_values + ) + + +async def get_organization( app: web.Application, *, user_id: UserID, @@ -74,7 +91,7 @@ async def get_user_group( return await _groups_db.get_user_group(app, user_id=user_id, gid=group_id) -async def update_user_group( +async def update_organization( app: web.Application, *, user_id: UserID, @@ -89,3 +106,14 @@ async def update_user_group( return await _groups_db.update_user_group( app, user_id=user_id, gid=group_id, new_group_values=new_group_values ) + + +async def delete_organization( + app: web.Application, *, user_id: UserID, group_id: GroupID +) -> None: + """ + + raises GroupNotFoundError + raises UserInsufficientRightsError + """ + return await _groups_db.delete_user_group(app, user_id=user_id, gid=group_id) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py index b96374fe000..17a15e9392c 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py @@ -90,7 +90,7 @@ async def list_groups(request: web.Request): # -# Organization groups +# ORGANIZATION GROUPS # @@ -103,7 +103,7 @@ async def get_group(request: web.Request): req_ctx = GroupsRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(GroupsPathParams, request) - group_info = await _groups_api.get_user_group( + group_info = await _groups_api.get_organization( request.app, user_id=req_ctx.user_id, group_id=path_params.gid ) @@ -118,11 +118,16 @@ async def get_group(request: web.Request): async def create_group(request: web.Request): """Creates organization groups""" req_ctx = GroupsRequestContext.model_validate(request) + create = await parse_request_body_as(GroupCreate, request) - new_group = create.model_dump(mode="json", exclude_unset=True) - created_group = await api.create_user_group(request.app, req_ctx.user_id, new_group) - assert GroupGet.model_validate(created_group) is not None # nosec + group_info = await _groups_api.create_organization( + request.app, + user_id=req_ctx.user_id, + new_group_values=create.model_dump(mode="json", exclude_unset=True), + ) + + created_group = _to_groupget_model(*group_info) return envelope_json_response(created_group, status_cls=web.HTTPCreated) @@ -137,7 +142,7 @@ async def update_group(request: web.Request): update: GroupUpdate = await parse_request_body_as(GroupUpdate, request) new_group_values = update.model_dump(exclude_unset=True) - group_info = await _groups_api.update_user_group( + group_info = await _groups_api.update_organization( request.app, user_id=req_ctx.user_id, group_id=path_params.gid, @@ -157,12 +162,14 @@ async def delete_group(request: web.Request): req_ctx = GroupsRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(GroupsPathParams, request) - await api.delete_user_group(request.app, req_ctx.user_id, path_params.gid) + await _groups_api.delete_organization( + request.app, user_id=req_ctx.user_id, group_id=path_params.gid + ) return web.json_response(status=status.HTTP_204_NO_CONTENT) # -# Users in organization groups (i.e. members of an organization) +# USERS in ORGANIZATION groupS (i.e. members of an organization) # diff --git a/services/web/server/src/simcore_service_webserver/groups/api.py b/services/web/server/src/simcore_service_webserver/groups/api.py index 3847cae7615..2242ed00213 100644 --- a/services/web/server/src/simcore_service_webserver/groups/api.py +++ b/services/web/server/src/simcore_service_webserver/groups/api.py @@ -18,12 +18,6 @@ ) -async def delete_user_group( - app: web.Application, user_id: UserID, gid: GroupID -) -> None: - return await _groups_db.delete_user_group(app, user_id=user_id, gid=gid) - - async def list_users_in_group( app: web.Application, user_id: UserID, gid: GroupID ) -> list[dict[str, str]]: From 1ef0c7b856f16ff21d125603e28bcb3c7c623f07 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Fri, 6 Dec 2024 21:16:35 +0100 Subject: [PATCH 27/84] cleanup --- .../simcore_service_webserver/groups/_groups_api.py | 5 +++++ .../groups/_groups_handlers.py | 11 ++++++----- .../src/simcore_service_webserver/groups/api.py | 3 +++ 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py index abe46dcdf5c..3cb0871466b 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py @@ -117,3 +117,8 @@ async def delete_organization( raises UserInsufficientRightsError """ return await _groups_db.delete_user_group(app, user_id=user_id, gid=group_id) + + +# +# ORGANIZATION MEMBERS +# diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py index 17a15e9392c..3254f56b7f9 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py @@ -25,6 +25,7 @@ from ..security.decorators import permission_required from ..utils_aiohttp import envelope_json_response from . import _groups_api +from . import api as tmp_api from ._common.exceptions_handlers import handle_plugin_requests_exceptions from ._common.models import ( GroupsPathParams, @@ -182,7 +183,7 @@ async def get_group_users(request: web.Request): req_ctx = GroupsRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(GroupsPathParams, request) - group_user = await api.list_users_in_group( + group_user = await tmp_api.list_users_in_group( request.app, req_ctx.user_id, path_params.gid ) assert ( @@ -203,7 +204,7 @@ async def add_group_user(request: web.Request): path_params = parse_request_path_parameters_as(GroupsPathParams, request) added: GroupUserAdd = await parse_request_body_as(GroupUserAdd, request) - await api.add_user_in_group( + await tmp_api.add_user_in_group( request.app, req_ctx.user_id, path_params.gid, @@ -223,7 +224,7 @@ async def get_group_user(request: web.Request): """ req_ctx = GroupsRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(GroupsUsersPathParams, request) - user = await api.get_user_in_group( + user = await tmp_api.get_user_in_group( request.app, req_ctx.user_id, path_params.gid, path_params.uid ) assert GroupUserGet.model_validate(user) is not None # nosec @@ -239,7 +240,7 @@ async def update_group_user(request: web.Request): path_params = parse_request_path_parameters_as(GroupsUsersPathParams, request) update: GroupUserUpdate = await parse_request_body_as(GroupUserUpdate, request) - user = await api.update_user_in_group( + user = await tmp_api.update_user_in_group( request.app, user_id=req_ctx.user_id, gid=path_params.gid, @@ -257,7 +258,7 @@ async def update_group_user(request: web.Request): async def delete_group_user(request: web.Request): req_ctx = GroupsRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(GroupsUsersPathParams, request) - await api.delete_user_in_group( + await tmp_api.delete_user_in_group( request.app, req_ctx.user_id, path_params.gid, path_params.uid ) return web.json_response(status=status.HTTP_204_NO_CONTENT) diff --git a/services/web/server/src/simcore_service_webserver/groups/api.py b/services/web/server/src/simcore_service_webserver/groups/api.py index 2242ed00213..5638a2ba49b 100644 --- a/services/web/server/src/simcore_service_webserver/groups/api.py +++ b/services/web/server/src/simcore_service_webserver/groups/api.py @@ -18,6 +18,9 @@ ) +# +# TODO: move all these to _groups_api +# async def list_users_in_group( app: web.Application, user_id: UserID, gid: GroupID ) -> list[dict[str, str]]: From 853308c1a140e065946d9cd02d3ec9e82b92476a Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Sun, 8 Dec 2024 23:28:56 +0100 Subject: [PATCH 28/84] minor --- .../src/models_library/api_schemas_webserver/_base.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/models-library/src/models_library/api_schemas_webserver/_base.py b/packages/models-library/src/models_library/api_schemas_webserver/_base.py index 718984116c7..948c4c9b3ea 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/_base.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/_base.py @@ -24,7 +24,8 @@ class InputSchemaWithoutCamelCase(BaseModel): class InputSchema(BaseModel): model_config = ConfigDict( - **InputSchemaWithoutCamelCase.model_config, alias_generator=snake_to_camel + **InputSchemaWithoutCamelCase.model_config, + alias_generator=snake_to_camel, ) @@ -50,7 +51,7 @@ def data( exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, exclude_none=exclude_none, - **kwargs + **kwargs, ) def data_json( @@ -67,5 +68,5 @@ def data_json( exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, exclude_none=exclude_none, - **kwargs + **kwargs, ) From 155ee4ecc4c12647e5d6660590360536199b9380 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Sun, 8 Dec 2024 23:31:18 +0100 Subject: [PATCH 29/84] list users in group --- api/specs/web-server/_groups.py | 2 +- .../api_schemas_webserver/groups.py | 39 ++++++++++++++----- .../src/models_library/groups.py | 36 ++++++++++++++++- .../groups/_classifiers_handlers.py | 2 +- .../groups/_common/{models.py => schemas.py} | 0 .../groups/_common/types.py | 18 --------- .../groups/_groups_api.py | 9 ++++- .../groups/_groups_db.py | 37 ++++++++++++++---- .../groups/_groups_handlers.py | 27 ++++++++----- .../simcore_service_webserver/groups/api.py | 4 -- 10 files changed, 121 insertions(+), 53 deletions(-) rename services/web/server/src/simcore_service_webserver/groups/_common/{models.py => schemas.py} (100%) delete mode 100644 services/web/server/src/simcore_service_webserver/groups/_common/types.py diff --git a/api/specs/web-server/_groups.py b/api/specs/web-server/_groups.py index 7d30714df37..85357f2b8c7 100644 --- a/api/specs/web-server/_groups.py +++ b/api/specs/web-server/_groups.py @@ -18,7 +18,7 @@ ) from models_library.generics import Envelope from simcore_service_webserver._meta import API_VTAG -from simcore_service_webserver.groups._common.models import ( +from simcore_service_webserver.groups._common.schemas import ( GroupsClassifiersQuery, GroupsPathParams, GroupsUsersPathParams, 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 43f86f80e22..0f13436d630 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 @@ -2,6 +2,7 @@ from typing import Annotated from common_library.basic_types import DEFAULT_FACTORY +from models_library.basic_types import IDStr from pydantic import ( AnyHttpUrl, AnyUrl, @@ -163,17 +164,37 @@ class MyGroupsGet(OutputSchema): class GroupUserGet(BaseModel): - id: str | None = Field(None, description="the user id", coerce_numbers_to_str=True) - login: LowerCaseEmailStr | None = Field(None, description="the user login email") - first_name: str | None = Field(None, description="the user first name") - last_name: str | None = Field(None, description="the user last name") - gravatar_id: str | None = Field(None, description="the user gravatar id hash") - gid: str | None = Field( - None, description="the user primary gid", coerce_numbers_to_str=True - ) + + # Identifiers + id: Annotated[ + str | None, Field(description="the user id", coerce_numbers_to_str=True) + ] = None + user_name: Annotated[IDStr, Field(alias="userName")] + gid: Annotated[ + str | None, + Field(description="the user primary gid", coerce_numbers_to_str=True), + ] = None + + # Private Profile + login: Annotated[ + LowerCaseEmailStr | None, + Field(description="the user's email, if privacy settings allows"), + ] = None + first_name: Annotated[ + str | None, Field(description="If privacy settings allows") + ] = None + last_name: Annotated[ + str | None, Field(description="If privacy settings allows") + ] = None + gravatar_id: Annotated[ + str | None, Field(description="the user gravatar id hash", deprecated=True) + ] = None + + # Access Rights access_rights: GroupAccessRights = Field(..., alias="accessRights") model_config = ConfigDict( + populate_by_name=True, json_schema_extra={ "example": { "id": "1", @@ -188,7 +209,7 @@ class GroupUserGet(BaseModel): "delete": False, }, } - } + }, ) diff --git a/packages/models-library/src/models_library/groups.py b/packages/models-library/src/models_library/groups.py index c4790adca81..07d27d89442 100644 --- a/packages/models-library/src/models_library/groups.py +++ b/packages/models-library/src/models_library/groups.py @@ -1,8 +1,11 @@ import enum -from typing import Annotated, Final +from typing import Annotated, Final, NamedTuple, TypeAlias, TypedDict from common_library.basic_types import DEFAULT_FACTORY -from pydantic import BaseModel, ConfigDict, Field, field_validator +from models_library.basic_types import IDStr +from models_library.groups import Group +from models_library.users import GroupID, UserID +from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator from pydantic.types import PositiveInt from .utils.common_validators import create_enums_pre_validator @@ -41,6 +44,35 @@ class Group(BaseModel): ) +class AccessRightsDict(TypedDict): + read: bool + write: bool + delete: bool + + +GroupInfoTuple: TypeAlias = tuple[Group, AccessRightsDict] + + +class GroupsByTypeTuple(NamedTuple): + primary: GroupInfoTuple | None + standard: list[GroupInfoTuple] + everyone: GroupInfoTuple | None + + +class GroupUser(BaseModel): + id: UserID + name: IDStr + primary_gid: GroupID + + email: EmailStr | None + first_name: str | None + last_name: str | None + + access_rights: AccessRightsDict + + model_config = ConfigDict(from_attributes=True) + + class GroupAtDB(Group): model_config = ConfigDict( from_attributes=True, diff --git a/services/web/server/src/simcore_service_webserver/groups/_classifiers_handlers.py b/services/web/server/src/simcore_service_webserver/groups/_classifiers_handlers.py index 910ebf6a176..40ce8c41a34 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_classifiers_handlers.py +++ b/services/web/server/src/simcore_service_webserver/groups/_classifiers_handlers.py @@ -16,7 +16,7 @@ from ..utils_aiohttp import envelope_json_response from ._classifiers_api import GroupClassifierRepository, build_rrids_tree_view from ._common.exceptions_handlers import handle_plugin_requests_exceptions -from ._common.models import GroupsClassifiersQuery, GroupsPathParams +from ._common.schemas import GroupsClassifiersQuery, GroupsPathParams _logger = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/groups/_common/models.py b/services/web/server/src/simcore_service_webserver/groups/_common/schemas.py similarity index 100% rename from services/web/server/src/simcore_service_webserver/groups/_common/models.py rename to services/web/server/src/simcore_service_webserver/groups/_common/schemas.py diff --git a/services/web/server/src/simcore_service_webserver/groups/_common/types.py b/services/web/server/src/simcore_service_webserver/groups/_common/types.py deleted file mode 100644 index 2bafa5950c9..00000000000 --- a/services/web/server/src/simcore_service_webserver/groups/_common/types.py +++ /dev/null @@ -1,18 +0,0 @@ -from typing import NamedTuple, TypeAlias, TypedDict - -from models_library.groups import Group - - -class AccessRightsDict(TypedDict): - read: bool - write: bool - delete: bool - - -GroupInfoTuple: TypeAlias = tuple[Group, AccessRightsDict] - - -class GroupsByTypeTuple(NamedTuple): - primary: GroupInfoTuple | None - standard: list[GroupInfoTuple] - everyone: GroupInfoTuple | None diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py index 3cb0871466b..833f648764a 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py @@ -1,9 +1,8 @@ from aiohttp import web -from models_library.groups import Group +from models_library.groups import AccessRightsDict, Group, GroupsByTypeTuple, GroupUser from models_library.users import GroupID, UserID from . import _groups_db -from ._common.types import AccessRightsDict, GroupsByTypeTuple async def list_user_groups_ids_with_read_access( @@ -122,3 +121,9 @@ async def delete_organization( # # ORGANIZATION MEMBERS # + + +async def list_users_in_group( + app: web.Application, user_id: UserID, gid: GroupID +) -> list[GroupUser]: + return await _groups_db.list_users_in_group(app, user_id=user_id, gid=gid) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py index deccc8b6589..10031ae2966 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py @@ -3,7 +3,14 @@ import sqlalchemy as sa from aiohttp import web -from models_library.groups import Group, GroupAtDB +from models_library.groups import ( + AccessRightsDict, + Group, + GroupAtDB, + GroupInfoTuple, + GroupsByTypeTuple, + GroupUser, +) from models_library.users import GroupID, UserID from simcore_postgres_database.errors import UniqueViolation from simcore_postgres_database.utils_products import execute_get_or_create_product_group @@ -19,7 +26,6 @@ from ..db.models import GroupType, groups, user_to_groups, users from ..db.plugin import get_asyncpg_engine from ..users.exceptions import UserNotFoundError -from ._common.types import AccessRightsDict, GroupInfoTuple, GroupsByTypeTuple from ._users_api import convert_user_in_group_to_schema from ._utils import convert_groups_schema_to_db from .exceptions import ( @@ -93,7 +99,7 @@ async def _get_group_and_access_rights_or_raise( *, user_id: UserID, gid: GroupID, -): +) -> Row: result = await conn.stream( sa.select( groups, @@ -125,7 +131,7 @@ async def get_user_from_email( # -# USER GROUPS: standard operations +# USER GROUPS: Standard operations # @@ -335,7 +341,7 @@ async def list_users_in_group( *, user_id: UserID, gid: GroupID, -) -> list[dict[str, str]]: +) -> list[GroupUser]: 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( @@ -345,13 +351,30 @@ async def list_users_in_group( # now get the list query = ( - sa.select(users, user_to_groups.c.access_rights) + sa.select( + users.c.id, + users.c.name, + sa.case( + [(users.c.privacy_hide_email == sa.true(), None)], + else_=users.c.email, + ).label("email"), + sa.case( + [(users.c.privacy_hide_fullname == sa.true(), None)], + else_=users.c.first_name, + ).label("first_name"), + sa.case( + [(users.c.privacy_hide_fullname == sa.true(), None)], + else_=users.c.last_name, + ).label("last_name"), + users.c.primary_gid, + user_to_groups.c.access_rights, + ) .select_from(users.join(user_to_groups)) .where(user_to_groups.c.gid == gid) ) result = await conn.stream(query) - return [convert_user_in_group_to_schema(row) async for row in result] + return [GroupUser.model_validate(row) async for row in result] async def auto_add_user_to_groups( diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py index 3254f56b7f9..a6c892f6bb8 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py @@ -11,8 +11,7 @@ GroupUserUpdate, MyGroupsGet, ) -from models_library.groups import Group -from pydantic import TypeAdapter +from models_library.groups import AccessRightsDict, Group from servicelib.aiohttp import status from servicelib.aiohttp.requests_validation import ( parse_request_body_as, @@ -27,12 +26,11 @@ from . import _groups_api from . import api as tmp_api from ._common.exceptions_handlers import handle_plugin_requests_exceptions -from ._common.models import ( +from ._common.schemas import ( GroupsPathParams, GroupsRequestContext, GroupsUsersPathParams, ) -from ._common.types import AccessRightsDict from .exceptions import GroupNotFoundError _logger = logging.getLogger(__name__) @@ -183,13 +181,24 @@ async def get_group_users(request: web.Request): req_ctx = GroupsRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(GroupsPathParams, request) - group_user = await tmp_api.list_users_in_group( + users_in_group = await _groups_api.list_users_in_group( request.app, req_ctx.user_id, path_params.gid ) - assert ( - TypeAdapter(list[GroupUserGet]).validate_python(group_user) is not None - ) # nosec - return envelope_json_response(group_user) + + group_members = [ + GroupUserGet( + id=user.id, + user_name=user.name, + login=user.email, + first_name=user.first_name, + last_name=user.last_name, + gid=user.primary_gid, + accessRights=user.access_rights, + ) + for user in users_in_group + ] + + return envelope_json_response(group_members) @routes.post(f"/{API_VTAG}/groups/{{gid}}/users", name="add_group_user") diff --git a/services/web/server/src/simcore_service_webserver/groups/api.py b/services/web/server/src/simcore_service_webserver/groups/api.py index 5638a2ba49b..5e2eb653e2a 100644 --- a/services/web/server/src/simcore_service_webserver/groups/api.py +++ b/services/web/server/src/simcore_service_webserver/groups/api.py @@ -21,10 +21,6 @@ # # TODO: move all these to _groups_api # -async def list_users_in_group( - app: web.Application, user_id: UserID, gid: GroupID -) -> list[dict[str, str]]: - return await _groups_db.list_users_in_group(app, user_id=user_id, gid=gid) async def auto_add_user_to_groups(app: web.Application, user_id: UserID) -> None: From 90a2af7ba07ee3c9048ffff2ab2a31729178ad5b Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 9 Dec 2024 00:00:17 +0100 Subject: [PATCH 30/84] crud on orgazniation members --- .../src/models_library/groups.py | 5 +- .../groups/_groups_api.py | 42 ++- .../groups/_groups_db.py | 331 +++++++++--------- .../groups/_groups_handlers.py | 46 +-- .../simcore_service_webserver/groups/api.py | 33 -- 5 files changed, 236 insertions(+), 221 deletions(-) diff --git a/packages/models-library/src/models_library/groups.py b/packages/models-library/src/models_library/groups.py index 07d27d89442..674cd5acae0 100644 --- a/packages/models-library/src/models_library/groups.py +++ b/packages/models-library/src/models_library/groups.py @@ -59,15 +59,18 @@ class GroupsByTypeTuple(NamedTuple): everyone: GroupInfoTuple | None -class GroupUser(BaseModel): +class GroupMember(BaseModel): + # identifiers id: UserID name: IDStr primary_gid: GroupID + # private profile email: EmailStr | None first_name: str | None last_name: str | None + # group access access_rights: AccessRightsDict model_config = ConfigDict(from_attributes=True) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py index 833f648764a..0549a23f33e 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py @@ -1,5 +1,10 @@ from aiohttp import web -from models_library.groups import AccessRightsDict, Group, GroupsByTypeTuple, GroupUser +from models_library.groups import ( + AccessRightsDict, + Group, + GroupMember, + GroupsByTypeTuple, +) from models_library.users import GroupID, UserID from . import _groups_db @@ -125,5 +130,38 @@ async def delete_organization( async def list_users_in_group( app: web.Application, user_id: UserID, gid: GroupID -) -> list[GroupUser]: +) -> list[GroupMember]: return await _groups_db.list_users_in_group(app, user_id=user_id, gid=gid) + + +async def get_user_in_group( + app: web.Application, user_id: UserID, gid: GroupID, the_user_id_in_group: int +) -> GroupMember: + + return await _groups_db.get_user_in_group( + app, user_id=user_id, gid=gid, the_user_id_in_group=the_user_id_in_group + ) + + +async def update_user_in_group( + app: web.Application, + user_id: UserID, + gid: GroupID, + the_user_id_in_group: int, + access_rights: dict, +) -> GroupMember: + return await _groups_db.update_user_in_group( + app, + user_id=user_id, + gid=gid, + the_user_id_in_group=the_user_id_in_group, + access_rights=access_rights, + ) + + +async def delete_user_in_group( + app: web.Application, user_id: UserID, gid: GroupID, the_user_id_in_group: int +) -> None: + return await _groups_db.delete_user_in_group( + app, user_id=user_id, gid=gid, the_user_id_in_group=the_user_id_in_group + ) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py index 10031ae2966..ba4061c117e 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py @@ -6,10 +6,9 @@ from models_library.groups import ( AccessRightsDict, Group, - GroupAtDB, GroupInfoTuple, + GroupMember, GroupsByTypeTuple, - GroupUser, ) from models_library.users import GroupID, UserID from simcore_postgres_database.errors import UniqueViolation @@ -26,7 +25,6 @@ from ..db.models import GroupType, groups, user_to_groups, users from ..db.plugin import get_asyncpg_engine from ..users.exceptions import UserNotFoundError -from ._users_api import convert_user_in_group_to_schema from ._utils import convert_groups_schema_to_db from .exceptions import ( GroupNotFoundError, @@ -331,17 +329,53 @@ async def delete_user_group( # -# USER GROUPS: Custom operations +# GROUP MEMBERS # +_GROUP_MEMBER_COLS = ( + users.c.id, + users.c.name, + sa.case( + [(users.c.privacy_hide_email == sa.true(), None)], + else_=users.c.email, + ).label("email"), + sa.case( + [(users.c.privacy_hide_fullname == sa.true(), None)], + else_=users.c.first_name, + ).label("first_name"), + sa.case( + [(users.c.privacy_hide_fullname == sa.true(), None)], + else_=users.c.last_name, + ).label("last_name"), + users.c.primary_gid, +) + + +async def _get_user_in_group_permissions( + conn: AsyncConnection, *, gid: GroupID, user_id: int +) -> Row: + # now get the user + result = await conn.stream( + sa.select(_GROUP_MEMBER_COLS, 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 == gid, users.c.id == user_id)) + ) + row = await result.fetchone() + if not row: + raise UserInGroupNotFoundError(uid=user_id, gid=gid) + return row + + async def list_users_in_group( app: web.Application, connection: AsyncConnection | None = None, *, user_id: UserID, gid: GroupID, -) -> list[GroupUser]: +) -> 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( @@ -352,21 +386,7 @@ async def list_users_in_group( # now get the list query = ( sa.select( - users.c.id, - users.c.name, - sa.case( - [(users.c.privacy_hide_email == sa.true(), None)], - else_=users.c.email, - ).label("email"), - sa.case( - [(users.c.privacy_hide_fullname == sa.true(), None)], - else_=users.c.first_name, - ).label("first_name"), - sa.case( - [(users.c.privacy_hide_fullname == sa.true(), None)], - else_=users.c.last_name, - ).label("last_name"), - users.c.primary_gid, + *_GROUP_MEMBER_COLS, user_to_groups.c.access_rights, ) .select_from(users.join(user_to_groups)) @@ -374,7 +394,134 @@ async def list_users_in_group( ) result = await conn.stream(query) - return [GroupUser.model_validate(row) async for row in result] + return [GroupMember.model_validate(row) async for row in result] + + +async def get_user_in_group( + app: web.Application, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + gid: 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=gid + ) + _check_group_permissions(group, user_id, gid, "read") + + # get the user with its permissions + the_user = await _get_user_in_group_permissions( + conn, gid=gid, user_id=the_user_id_in_group + ) + return GroupMember.model_validate(the_user) + + +async def update_user_in_group( + app: web.Application, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + gid: GroupID, + the_user_id_in_group: int, + access_rights: dict, +) -> GroupMember: + if not access_rights: + msg = f"Cannot update empty {access_rights}" + raise ValueError(msg) + + 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=gid + ) + _check_group_permissions(group, user_id, gid, "write") + + # now check the user exists + the_user = await _get_user_in_group_permissions( + conn, gid=gid, user_id=the_user_id_in_group + ) + + # modify the user access rights + new_db_values = {"access_rights": access_rights} + await conn.execute( + # pylint: disable=no-value-for-parameter + user_to_groups.update() + .values(**new_db_values) + .where( + and_( + user_to_groups.c.uid == the_user_id_in_group, + user_to_groups.c.gid == gid, + ) + ) + ) + user = dict(the_user) + user.update(**new_db_values) + return GroupMember.model_validate(user) + + +async def delete_user_in_group( + app: web.Application, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + gid: GroupID, + the_user_id_in_group: int, +) -> 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=gid + ) + _check_group_permissions(group, user_id, gid, "write") + + # check the user exists + await _get_user_in_group_permissions( + conn, gid=gid, user_id=the_user_id_in_group + ) + + # delete him/her + await conn.execute( + # pylint: disable=no-value-for-parameter + user_to_groups.delete().where( + and_( + user_to_groups.c.uid == the_user_id_in_group, + user_to_groups.c.gid == gid, + ) + ) + ) + + +async def get_group_from_gid( + app: web.Application, connection: AsyncConnection | None = None, *, gid: 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 == gid)) + result = await row.first() + if result: + return Group.model_validate(result) + return None + + +async def is_user_by_email_in_group( + app: web.Application, + connection: AsyncConnection | None = None, + *, + email: str, + group_id: GroupID, +) -> bool: + async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: + user_id = await conn.scalar( + sa.select(users.c.id) + .select_from( + sa.join(user_to_groups, users, user_to_groups.c.uid == users.c.id) + ) + .where((users.c.email == email) & (user_to_groups.c.gid == group_id)) + ) + return user_id is not None async def auto_add_user_to_groups( @@ -437,24 +584,6 @@ async def auto_add_user_to_product_group( return product_group_id -async def is_user_by_email_in_group( - app: web.Application, - connection: AsyncConnection | None = None, - *, - email: str, - group_id: GroupID, -) -> bool: - async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: - user_id = await conn.scalar( - sa.select(users.c.id) - .select_from( - sa.join(user_to_groups, users, user_to_groups.c.uid == users.c.id) - ) - .where((users.c.email == email) & (user_to_groups.c.gid == group_id)) - ) - return user_id is not None - - async def add_new_user_in_group( app: web.Application, connection: AsyncConnection | None = None, @@ -498,127 +627,3 @@ async def add_new_user_in_group( raise UserAlreadyInGroupError( uid=new_user_id, gid=gid, user_id=user_id, access_rights=access_rights ) from exc - - -async def _get_user_in_group_permissions( - conn: AsyncConnection, *, gid: GroupID, the_user_id_in_group: int -): - # now get the user - result = await conn.stream( - sa.select(users, 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 == gid, users.c.id == the_user_id_in_group)) - ) - the_user = await result.fetchone() - if not the_user: - raise UserInGroupNotFoundError(uid=the_user_id_in_group, gid=gid) - return the_user - - -async def get_user_in_group( - app: web.Application, - connection: AsyncConnection | None = None, - *, - user_id: UserID, - gid: GroupID, - the_user_id_in_group: int, -) -> dict[str, str]: - 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=gid - ) - _check_group_permissions(group, user_id, gid, "read") - - # get the user with its permissions - the_user = await _get_user_in_group_permissions( - conn, gid=gid, the_user_id_in_group=the_user_id_in_group - ) - return convert_user_in_group_to_schema(the_user) - - -async def update_user_in_group( - app: web.Application, - connection: AsyncConnection | None = None, - *, - user_id: UserID, - gid: GroupID, - the_user_id_in_group: int, - access_rights: dict, -) -> dict[str, str]: - if not access_rights: - msg = f"Cannot update empty {access_rights}" - raise ValueError(msg) - - 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=gid - ) - _check_group_permissions(group, user_id, gid, "write") - - # now check the user exists - the_user = await _get_user_in_group_permissions( - conn, gid=gid, the_user_id_in_group=the_user_id_in_group - ) - - # modify the user access rights - new_db_values = {"access_rights": access_rights} - await conn.execute( - # pylint: disable=no-value-for-parameter - user_to_groups.update() - .values(**new_db_values) - .where( - and_( - user_to_groups.c.uid == the_user_id_in_group, - user_to_groups.c.gid == gid, - ) - ) - ) - user = dict(the_user) - user.update(**new_db_values) - return convert_user_in_group_to_schema(user) - - -async def delete_user_in_group( - app: web.Application, - connection: AsyncConnection | None = None, - *, - user_id: UserID, - gid: GroupID, - the_user_id_in_group: int, -) -> 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=gid - ) - _check_group_permissions(group, user_id, gid, "write") - - # check the user exists - await _get_user_in_group_permissions( - conn, gid=gid, the_user_id_in_group=the_user_id_in_group - ) - - # delete him/her - await conn.execute( - # pylint: disable=no-value-for-parameter - user_to_groups.delete().where( - and_( - user_to_groups.c.uid == the_user_id_in_group, - user_to_groups.c.gid == gid, - ) - ) - ) - - -async def get_group_from_gid( - app: web.Application, connection: AsyncConnection | None = None, *, gid: GroupID -) -> GroupAtDB | None: - async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: - row = await conn.stream(groups.select().where(groups.c.gid == gid)) - result = await row.first() - if result: - return GroupAtDB.model_validate(result) - return None diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py index a6c892f6bb8..38bbf39778f 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py @@ -11,7 +11,7 @@ GroupUserUpdate, MyGroupsGet, ) -from models_library.groups import AccessRightsDict, Group +from models_library.groups import AccessRightsDict, Group, GroupMember from servicelib.aiohttp import status from servicelib.aiohttp.requests_validation import ( parse_request_body_as, @@ -172,6 +172,21 @@ async def delete_group(request: web.Request): # +def _to_groupuserget_model(user: GroupMember) -> GroupUserGet: + # Fuses both dataset into GroupSet + return GroupUserGet.model_validate( + { + "id": user.id, + "user_name": user.name, + "login": user.email, + "first_name": user.first_name, + "last_name": user.last_name, + "gid": user.primary_gid, + "access_rights": user.access_rights, + } + ) + + @routes.get(f"/{API_VTAG}/groups/{{gid}}/users", name="get_all_group_users") @login_required @permission_required("groups.*") @@ -185,20 +200,9 @@ async def get_group_users(request: web.Request): request.app, req_ctx.user_id, path_params.gid ) - group_members = [ - GroupUserGet( - id=user.id, - user_name=user.name, - login=user.email, - first_name=user.first_name, - last_name=user.last_name, - gid=user.primary_gid, - accessRights=user.access_rights, - ) - for user in users_in_group - ] - - return envelope_json_response(group_members) + return envelope_json_response( + [_to_groupuserget_model(user) for user in users_in_group] + ) @routes.post(f"/{API_VTAG}/groups/{{gid}}/users", name="add_group_user") @@ -233,11 +237,10 @@ async def get_group_user(request: web.Request): """ req_ctx = GroupsRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(GroupsUsersPathParams, request) - user = await tmp_api.get_user_in_group( + user = await _groups_api.get_user_in_group( request.app, req_ctx.user_id, path_params.gid, path_params.uid ) - assert GroupUserGet.model_validate(user) is not None # nosec - return envelope_json_response(user) + return envelope_json_response(_to_groupuserget_model(user)) @routes.patch(f"/{API_VTAG}/groups/{{gid}}/users/{{uid}}", name="update_group_user") @@ -249,15 +252,14 @@ async def update_group_user(request: web.Request): path_params = parse_request_path_parameters_as(GroupsUsersPathParams, request) update: GroupUserUpdate = await parse_request_body_as(GroupUserUpdate, request) - user = await tmp_api.update_user_in_group( + user = await _groups_api.update_user_in_group( request.app, user_id=req_ctx.user_id, gid=path_params.gid, the_user_id_in_group=path_params.uid, access_rights=update.access_rights.model_dump(), ) - assert GroupUserGet.model_validate(user) is not None # nosec - return envelope_json_response(user) + return envelope_json_response(_to_groupuserget_model(user)) @routes.delete(f"/{API_VTAG}/groups/{{gid}}/users/{{uid}}", name="delete_group_user") @@ -267,7 +269,7 @@ async def update_group_user(request: web.Request): async def delete_group_user(request: web.Request): req_ctx = GroupsRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(GroupsUsersPathParams, request) - await tmp_api.delete_user_in_group( + await _groups_api.delete_user_in_group( request.app, req_ctx.user_id, path_params.gid, path_params.uid ) return web.json_response(status=status.HTTP_204_NO_CONTENT) diff --git a/services/web/server/src/simcore_service_webserver/groups/api.py b/services/web/server/src/simcore_service_webserver/groups/api.py index 5e2eb653e2a..b77013844b3 100644 --- a/services/web/server/src/simcore_service_webserver/groups/api.py +++ b/services/web/server/src/simcore_service_webserver/groups/api.py @@ -84,39 +84,6 @@ async def add_user_in_group( ) -async def get_user_in_group( - app: web.Application, user_id: UserID, gid: GroupID, the_user_id_in_group: int -) -> dict[str, str]: - - return await _groups_db.get_user_in_group( - app, user_id=user_id, gid=gid, the_user_id_in_group=the_user_id_in_group - ) - - -async def update_user_in_group( - app: web.Application, - user_id: UserID, - gid: GroupID, - the_user_id_in_group: int, - access_rights: dict, -) -> dict[str, str]: - return await _groups_db.update_user_in_group( - app, - user_id=user_id, - gid=gid, - the_user_id_in_group=the_user_id_in_group, - access_rights=access_rights, - ) - - -async def delete_user_in_group( - app: web.Application, user_id: UserID, gid: GroupID, the_user_id_in_group: int -) -> None: - return await _groups_db.delete_user_in_group( - app, user_id=user_id, gid=gid, the_user_id_in_group=the_user_id_in_group - ) - - async def get_group_from_gid(app: web.Application, gid: GroupID) -> Group | None: group_db = await _groups_db.get_group_from_gid(app, gid=gid) From 8c7f01bdca2f4b4a116bbbfd228a105f526a3028 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 9 Dec 2024 10:06:33 +0100 Subject: [PATCH 31/84] db refactor --- .../groups/_groups_db.py | 122 ++++++++++-------- 1 file changed, 66 insertions(+), 56 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py index ba4061c117e..8c778c162e9 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py @@ -129,7 +129,23 @@ async def get_user_from_email( # -# USER GROUPS: Standard operations +# GROUPS +# + + +async def get_group_from_gid( + app: web.Application, connection: AsyncConnection | None = None, *, gid: 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 == gid)) + result = await row.first() + if result: + return Group.model_validate(result) + return None + + +# +# USER's GROUPS # @@ -329,7 +345,7 @@ async def delete_user_group( # -# GROUP MEMBERS +# GROUP MEMBERS - CRUD # @@ -495,15 +511,9 @@ async def delete_user_in_group( ) -async def get_group_from_gid( - app: web.Application, connection: AsyncConnection | None = None, *, gid: 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 == gid)) - result = await row.first() - if result: - return Group.model_validate(result) - return None +# +# GROUP MEMBERS - CUSTOM +# async def is_user_by_email_in_group( @@ -524,6 +534,51 @@ async def is_user_by_email_in_group( return user_id is not None +async def add_new_user_in_group( + app: web.Application, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + gid: GroupID, + new_user_id: UserID, + access_rights: AccessRightsDict | None = None, +) -> None: + """ + adds new_user (either by id or email) in group (with gid) owned by user_id + """ + 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=gid + ) + _check_group_permissions(group, user_id, gid, "write") + + # now check the new user exists + users_count = await conn.scalar( + sa.select(sa.func.count()).where(users.c.id == new_user_id) + ) + if not users_count: + assert new_user_id is not None # nosec + raise UserInGroupNotFoundError(uid=new_user_id, gid=gid) + + # add the new user to the group now + user_access_rights = _DEFAULT_GROUP_READ_ACCESS_RIGHTS + if access_rights: + user_access_rights.update(access_rights) + + try: + 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 + ) + ) + except UniqueViolation as exc: + raise UserAlreadyInGroupError( + uid=new_user_id, gid=gid, user_id=user_id, access_rights=access_rights + ) from exc + + async def auto_add_user_to_groups( app: web.Application, connection: AsyncConnection | None = None, *, user: dict ) -> None: @@ -582,48 +637,3 @@ async def auto_add_user_to_product_group( .on_conflict_do_nothing() # in case the user was already added ) return product_group_id - - -async def add_new_user_in_group( - app: web.Application, - connection: AsyncConnection | None = None, - *, - user_id: UserID, - gid: GroupID, - new_user_id: UserID, - access_rights: AccessRightsDict | None = None, -) -> None: - """ - adds new_user (either by id or email) in group (with gid) owned by user_id - """ - 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=gid - ) - _check_group_permissions(group, user_id, gid, "write") - - # now check the new user exists - users_count = await conn.scalar( - sa.select(sa.func.count()).where(users.c.id == new_user_id) - ) - if not users_count: - assert new_user_id is not None # nosec - raise UserInGroupNotFoundError(uid=new_user_id, gid=gid) - - # add the new user to the group now - user_access_rights = _DEFAULT_GROUP_READ_ACCESS_RIGHTS - if access_rights: - user_access_rights.update(access_rights) - - try: - 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 - ) - ) - except UniqueViolation as exc: - raise UserAlreadyInGroupError( - uid=new_user_id, gid=gid, user_id=user_id, access_rights=access_rights - ) from exc From efda082f177e8226ccfc687a56484bc684aa68e6 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 9 Dec 2024 10:17:04 +0100 Subject: [PATCH 32/84] inter-domain interface --- .../groups/_groups_api.py | 79 +++++++++++++ .../groups/_groups_handlers.py | 15 ++- .../simcore_service_webserver/groups/api.py | 105 +++--------------- 3 files changed, 104 insertions(+), 95 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py index 0549a23f33e..d168efae711 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py @@ -1,4 +1,5 @@ from aiohttp import web +from models_library.emails import LowerCaseEmailStr from models_library.groups import ( AccessRightsDict, Group, @@ -7,9 +8,26 @@ ) from models_library.users import GroupID, UserID +from ..users.api import get_user from . import _groups_db +from .exceptions import GroupsError +# +# GROUPS +# + + +async def get_group_from_gid(app: web.Application, gid: GroupID) -> Group | None: + group_db = await _groups_db.get_group_from_gid(app, gid=gid) + + if group_db: + return Group.model_construct(**group_db.model_dump()) + return None + +# +# USER GROUPS: groups a user belongs to +# async def list_user_groups_ids_with_read_access( app: web.Application, *, user_id: UserID ) -> list[GroupID]: @@ -165,3 +183,64 @@ async def delete_user_in_group( return await _groups_db.delete_user_in_group( app, user_id=user_id, gid=gid, the_user_id_in_group=the_user_id_in_group ) + + +async def is_user_by_email_in_group( + app: web.Application, user_email: LowerCaseEmailStr, group_id: GroupID +) -> bool: + + return await _groups_db.is_user_by_email_in_group( + app, + email=user_email, + group_id=group_id, + ) + + +async def auto_add_user_to_groups(app: web.Application, user_id: UserID) -> None: + user: dict = await get_user(app, user_id) + return await _groups_db.auto_add_user_to_groups(app, user=user) + + +async def auto_add_user_to_product_group( + app: web.Application, user_id: UserID, product_name: str +) -> GroupID: + return await _groups_db.auto_add_user_to_product_group( + app, user_id=user_id, product_name=product_name + ) + + +async def add_user_in_group( + app: web.Application, + user_id: UserID, + gid: GroupID, + *, + new_user_id: UserID | None = None, + new_user_email: str | None = None, + access_rights: AccessRightsDict | None = None, +) -> None: + """Adds new_user (either by id or email) in group (with gid) owned by user_id + + Raises: + UserInGroupNotFoundError + GroupsException + """ + + if not new_user_id and not new_user_email: + msg = "Invalid method call, missing user id or user email" + raise GroupsError(msg=msg) + + if new_user_email: + user = await _groups_db.get_user_from_email(app, email=new_user_email) + new_user_id = user.id + + if not new_user_id: + msg = "Missing new user in arguments" + raise GroupsError(msg=msg) + + return await _groups_db.add_new_user_in_group( + app, + user_id=user_id, + gid=gid, + new_user_id=new_user_id, + access_rights=access_rights, + ) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py index 38bbf39778f..d91e990eb20 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py @@ -24,7 +24,6 @@ from ..security.decorators import permission_required from ..utils_aiohttp import envelope_json_response from . import _groups_api -from . import api as tmp_api from ._common.exceptions_handlers import handle_plugin_requests_exceptions from ._common.schemas import ( GroupsPathParams, @@ -102,11 +101,11 @@ async def get_group(request: web.Request): req_ctx = GroupsRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(GroupsPathParams, request) - group_info = await _groups_api.get_organization( + group, access_rights = await _groups_api.get_organization( request.app, user_id=req_ctx.user_id, group_id=path_params.gid ) - group = _to_groupget_model(*group_info) + group = _to_groupget_model(group, access_rights) return envelope_json_response(group) @@ -120,13 +119,13 @@ async def create_group(request: web.Request): create = await parse_request_body_as(GroupCreate, request) - group_info = await _groups_api.create_organization( + group, access_rights = await _groups_api.create_organization( request.app, user_id=req_ctx.user_id, new_group_values=create.model_dump(mode="json", exclude_unset=True), ) - created_group = _to_groupget_model(*group_info) + created_group = _to_groupget_model(group, access_rights) return envelope_json_response(created_group, status_cls=web.HTTPCreated) @@ -141,14 +140,14 @@ async def update_group(request: web.Request): update: GroupUpdate = await parse_request_body_as(GroupUpdate, request) new_group_values = update.model_dump(exclude_unset=True) - group_info = await _groups_api.update_organization( + group, access_rights = await _groups_api.update_organization( request.app, user_id=req_ctx.user_id, group_id=path_params.gid, new_group_values=new_group_values, ) - updated_group = _to_groupget_model(*group_info) + updated_group = _to_groupget_model(group, access_rights) return envelope_json_response(updated_group) @@ -217,7 +216,7 @@ async def add_group_user(request: web.Request): path_params = parse_request_path_parameters_as(GroupsPathParams, request) added: GroupUserAdd = await parse_request_body_as(GroupUserAdd, request) - await tmp_api.add_user_in_group( + await _groups_api.add_user_in_group( request.app, req_ctx.user_id, path_params.gid, diff --git a/services/web/server/src/simcore_service_webserver/groups/api.py b/services/web/server/src/simcore_service_webserver/groups/api.py index b77013844b3..207e1ffb303 100644 --- a/services/web/server/src/simcore_service_webserver/groups/api.py +++ b/services/web/server/src/simcore_service_webserver/groups/api.py @@ -1,92 +1,23 @@ -from typing import Any - -from aiohttp import web -from models_library.emails import LowerCaseEmailStr -from models_library.groups import Group -from models_library.users import GroupID, UserID - -from ..users.api import get_user -from . import _groups_db -from ._common.types import AccessRightsDict -from ._groups_api import list_all_user_groups_ids, list_user_groups_ids_with_read_access -from .exceptions import GroupsError +# +# Domain-Specific Interfaces +# +from ._groups_api import ( + add_user_in_group, + auto_add_user_to_groups, + auto_add_user_to_product_group, + get_group_from_gid, + is_user_by_email_in_group, + list_all_user_groups_ids, + list_user_groups_ids_with_read_access, +) __all__: tuple[str, ...] = ( - "list_user_groups_ids_with_read_access", + "add_user_in_group", + "auto_add_user_to_groups", + "auto_add_user_to_product_group", + "get_group_from_gid", + "is_user_by_email_in_group", "list_all_user_groups_ids", + "list_user_groups_ids_with_read_access", # nopycln: file ) - - -# -# TODO: move all these to _groups_api -# - - -async def auto_add_user_to_groups(app: web.Application, user_id: UserID) -> None: - user: dict = await get_user(app, user_id) - return await _groups_db.auto_add_user_to_groups(app, user=user) - - -async def auto_add_user_to_product_group( - app: web.Application, user_id: UserID, product_name: str -) -> GroupID: - return await _groups_db.auto_add_user_to_product_group( - app, user_id=user_id, product_name=product_name - ) - - -async def is_user_by_email_in_group( - app: web.Application, user_email: LowerCaseEmailStr, group_id: GroupID -) -> bool: - - return await _groups_db.is_user_by_email_in_group( - app, - email=user_email, - group_id=group_id, - ) - - -async def add_user_in_group( - app: web.Application, - user_id: UserID, - gid: GroupID, - *, - new_user_id: UserID | None = None, - new_user_email: str | None = None, - access_rights: AccessRightsDict | None = None, -) -> None: - """Adds new_user (either by id or email) in group (with gid) owned by user_id - - Raises: - UserInGroupNotFoundError - GroupsException - """ - - if not new_user_id and not new_user_email: - msg = "Invalid method call, missing user id or user email" - raise GroupsError(msg=msg) - - if new_user_email: - user = await _groups_db.get_user_from_email(app, email=new_user_email) - new_user_id = user.id - - if not new_user_id: - msg = "Missing new user in arguments" - raise GroupsError(msg=msg) - - return await _groups_db.add_new_user_in_group( - app, - user_id=user_id, - gid=gid, - new_user_id=new_user_id, - access_rights=access_rights, - ) - - -async def get_group_from_gid(app: web.Application, gid: GroupID) -> Group | None: - group_db = await _groups_db.get_group_from_gid(app, gid=gid) - - if group_db: - return Group.model_construct(**group_db.model_dump()) - return None From 550729502c7e25f8a3860b7a7b4cb74e6b77b5af Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 9 Dec 2024 10:18:26 +0100 Subject: [PATCH 33/84] rm adapter --- .../groups/_users_api.py | 22 ------------------- 1 file changed, 22 deletions(-) delete mode 100644 services/web/server/src/simcore_service_webserver/groups/_users_api.py diff --git a/services/web/server/src/simcore_service_webserver/groups/_users_api.py b/services/web/server/src/simcore_service_webserver/groups/_users_api.py deleted file mode 100644 index f640cdd07d8..00000000000 --- a/services/web/server/src/simcore_service_webserver/groups/_users_api.py +++ /dev/null @@ -1,22 +0,0 @@ -""" -NOTE: Coupling with user's plugin api modules should be added here to avoid cyclic dependencies -""" - -from collections.abc import Mapping -from typing import Any - -from ..utils import gravatar_hash - - -def convert_user_in_group_to_schema(user: Mapping[str, Any]) -> dict[str, str]: - - group_user = { - "id": user["id"], - "first_name": user["first_name"], - "last_name": user["last_name"], - "login": user["email"], - "gravatar_id": gravatar_hash(user["email"]), # deprecated - } - group_user["accessRights"] = user["access_rights"] - group_user["gid"] = user["primary_gid"] - return group_user From 2dd48d450d91142c31dfe420c65f6cbe48299bd6 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 9 Dec 2024 10:24:51 +0100 Subject: [PATCH 34/84] cleanup --- .../simcore_service_webserver/groups/_groups_api.py | 5 ++++- .../web/server/tests/unit/with_dbs/01/test_groups.py | 10 ++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py index d168efae711..78e2482b59c 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py @@ -178,7 +178,10 @@ async def update_user_in_group( async def delete_user_in_group( - app: web.Application, user_id: UserID, gid: GroupID, the_user_id_in_group: int + app: web.Application, + user_id: UserID, + gid: GroupID, + the_user_id_in_group: int, ) -> None: return await _groups_db.delete_user_in_group( app, user_id=user_id, gid=gid, the_user_id_in_group=the_user_id_in_group diff --git a/services/web/server/tests/unit/with_dbs/01/test_groups.py b/services/web/server/tests/unit/with_dbs/01/test_groups.py index 0afd840c657..5137e8d2eec 100644 --- a/services/web/server/tests/unit/with_dbs/01/test_groups.py +++ b/services/web/server/tests/unit/with_dbs/01/test_groups.py @@ -12,6 +12,7 @@ import pytest from aiohttp.test_utils import TestClient from faker import Faker +from models_library.groups import AccessRightsDict from pytest_simcore.helpers.assert_checks import assert_status from pytest_simcore.helpers.webserver_login import LoggedUser, NewUser, UserInfoDict from pytest_simcore.helpers.webserver_parametrizations import ( @@ -25,7 +26,6 @@ from simcore_service_webserver._meta import API_VTAG from simcore_service_webserver.application_settings import setup_settings from simcore_service_webserver.db.plugin import setup_db -from simcore_service_webserver.groups._common.types import AccessRightsDict from simcore_service_webserver.groups._groups_db import ( _DEFAULT_GROUP_OWNER_ACCESS_RIGHTS, _DEFAULT_GROUP_READ_ACCESS_RIGHTS, @@ -62,9 +62,7 @@ def client( # fake config app = create_safe_application(cfg) - settings = setup_settings(app) - print(settings.model_dump_json(indent=1)) - + setup_settings(app) setup_db(app) setup_session(app) setup_security(app) @@ -92,9 +90,9 @@ def _assert__group_user( actual_user: dict, ): assert "first_name" in actual_user - assert actual_user["first_name"] == expected_user["first_name"] + assert actual_user["first_name"] == expected_user.get("first_name") assert "last_name" in actual_user - assert actual_user["last_name"] == expected_user["last_name"] + assert actual_user["last_name"] == expected_user.get("last_name") assert "login" in actual_user assert actual_user["login"] == expected_user["email"] assert "gravatar_id" in actual_user From 74c9fda22a61235cf1d670fabc22b61570389bd3 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 9 Dec 2024 10:40:27 +0100 Subject: [PATCH 35/84] model conversion and fitures --- .../api_schemas_webserver/groups.py | 13 +++++++++- .../simcore_webserver_groups_fixtures.py | 22 ++++++++-------- .../groups/_groups_handlers.py | 26 ++++++------------- 3 files changed, 31 insertions(+), 30 deletions(-) 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 0f13436d630..0ef03083332 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 @@ -1,5 +1,5 @@ from contextlib import suppress -from typing import Annotated +from typing import Annotated, Self from common_library.basic_types import DEFAULT_FACTORY from models_library.basic_types import IDStr @@ -16,6 +16,7 @@ ) from ..emails import LowerCaseEmailStr +from ..groups import AccessRightsDict, Group from ..users import UserID from ..utils.common_validators import create__check_only_one_is_set__root_validator from ._base import InputSchema, OutputSchema @@ -59,6 +60,16 @@ class GroupGet(OutputSchema): ), ] = DEFAULT_FACTORY + @classmethod + def from_model(cls, group: Group, access_rights: AccessRightsDict) -> Self: + # Fuses both dataset into GroupSet + return cls.model_validate( + { + **group.model_dump(), + "access_rights": access_rights, + } + ) + model_config = ConfigDict( json_schema_extra={ "examples": [ diff --git a/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py b/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py index 9ae55d296b5..592eb69883c 100644 --- a/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py +++ b/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py @@ -16,23 +16,19 @@ from aiohttp import web from aiohttp.test_utils import TestClient from models_library.api_schemas_webserver.groups import GroupGet +from models_library.groups import GroupsByTypeTuple from models_library.users import UserID from pytest_simcore.helpers.webserver_login import NewUser, UserInfoDict from simcore_service_webserver.groups import _groups_db -from simcore_service_webserver.groups._common.types import GroupsByTypeTuple from simcore_service_webserver.groups._groups_api import ( + add_user_in_group, + delete_organization, list_user_groups_with_read_access, ) -from simcore_service_webserver.groups.api import add_user_in_group, delete_user_group def _to_group_get_json(group, access_rights) -> dict[str, Any]: - return GroupGet.model_validate( - { - **group.model_dump(), - "access_rights": access_rights, - } - ).model_dump(mode="json") + return GroupGet.from_model(group, access_rights).model_dump(mode="json") # @@ -72,7 +68,7 @@ async def logged_user_groups_by_type( assert client.app groups_by_type = await list_user_groups_with_read_access( - client.app, logged_user["id"] + client.app, user_id=logged_user["id"] ) assert groups_by_type.primary assert groups_by_type.everyone @@ -152,8 +148,12 @@ async def standard_groups( yield standard_groups # clean groups - await delete_user_group(client.app, owner_user["id"], sparc_group["gid"]) - await delete_user_group(client.app, owner_user["id"], team_black_group["gid"]) + await delete_organization( + client.app, user_id=owner_user["id"], group_id=sparc_group["gid"] + ) + await delete_organization( + client.app, user_id=owner_user["id"], group_id=team_black_group["gid"] + ) @pytest.fixture diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py index d91e990eb20..7a0a5723fe8 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py @@ -11,7 +11,7 @@ GroupUserUpdate, MyGroupsGet, ) -from models_library.groups import AccessRightsDict, Group, GroupMember +from models_library.groups import GroupMember from servicelib.aiohttp import status from servicelib.aiohttp.requests_validation import ( parse_request_body_as, @@ -38,16 +38,6 @@ routes = web.RouteTableDef() -def _to_groupget_model(group: Group, access_rights: AccessRightsDict) -> GroupGet: - # Fuses both dataset into GroupSet - return GroupGet.model_validate( - { - **group.model_dump(), - "access_rights": access_rights, - } - ) - - @routes.get(f"/{API_VTAG}/groups", name="list_groups") @login_required @permission_required("groups.read") @@ -78,10 +68,10 @@ async def list_groups(request: web.Request): ) my_groups = MyGroupsGet( - me=_to_groupget_model(*groups_by_type.primary), - organizations=[_to_groupget_model(*gi) for gi in groups_by_type.standard], - all=_to_groupget_model(*groups_by_type.everyone), - product=_to_groupget_model(*my_product_group) if my_product_group else None, + me=GroupGet.from_model(*groups_by_type.primary), + organizations=[GroupGet.from_model(*gi) for gi in groups_by_type.standard], + all=GroupGet.from_model(*groups_by_type.everyone), + product=GroupGet.from_model(*my_product_group) if my_product_group else None, ) return envelope_json_response(my_groups) @@ -105,7 +95,7 @@ async def get_group(request: web.Request): request.app, user_id=req_ctx.user_id, group_id=path_params.gid ) - group = _to_groupget_model(group, access_rights) + group = GroupGet.from_model(group, access_rights) return envelope_json_response(group) @@ -125,7 +115,7 @@ async def create_group(request: web.Request): new_group_values=create.model_dump(mode="json", exclude_unset=True), ) - created_group = _to_groupget_model(group, access_rights) + created_group = GroupGet.from_model(group, access_rights) return envelope_json_response(created_group, status_cls=web.HTTPCreated) @@ -147,7 +137,7 @@ async def update_group(request: web.Request): new_group_values=new_group_values, ) - updated_group = _to_groupget_model(group, access_rights) + updated_group = GroupGet.from_model(group, access_rights) return envelope_json_response(updated_group) From e402440b8ec9b572cb5577241b8c75710160469a Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 9 Dec 2024 10:43:15 +0100 Subject: [PATCH 36/84] fixes circular imports --- .../src/models_library/api_schemas_webserver/groups.py | 2 +- packages/models-library/src/models_library/groups.py | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) 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 0ef03083332..eff11d668ad 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 @@ -2,7 +2,6 @@ from typing import Annotated, Self from common_library.basic_types import DEFAULT_FACTORY -from models_library.basic_types import IDStr from pydantic import ( AnyHttpUrl, AnyUrl, @@ -15,6 +14,7 @@ model_validator, ) +from ..basic_types import IDStr from ..emails import LowerCaseEmailStr from ..groups import AccessRightsDict, Group from ..users import UserID diff --git a/packages/models-library/src/models_library/groups.py b/packages/models-library/src/models_library/groups.py index 674cd5acae0..030914bde7f 100644 --- a/packages/models-library/src/models_library/groups.py +++ b/packages/models-library/src/models_library/groups.py @@ -2,12 +2,11 @@ from typing import Annotated, Final, NamedTuple, TypeAlias, TypedDict from common_library.basic_types import DEFAULT_FACTORY -from models_library.basic_types import IDStr -from models_library.groups import Group -from models_library.users import GroupID, UserID from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator from pydantic.types import PositiveInt +from .basic_types import IDStr +from .users import GroupID, UserID from .utils.common_validators import create_enums_pre_validator EVERYONE_GROUP_ID: Final[int] = 1 From cf83d5976ecdedd387e10417bad7631cb94bc2b0 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 9 Dec 2024 11:05:52 +0100 Subject: [PATCH 37/84] tests passing --- .../api_schemas_webserver/groups.py | 19 ++++++++++++++++++- .../src/models_library/groups.py | 5 ++++- .../groups/_groups_db.py | 12 ++++-------- 3 files changed, 26 insertions(+), 10 deletions(-) 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 eff11d668ad..9bde201b61d 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 @@ -63,9 +63,26 @@ class GroupGet(OutputSchema): @classmethod def from_model(cls, group: Group, access_rights: AccessRightsDict) -> Self: # Fuses both dataset into GroupSet + _to = { + "name": "label", + } + return cls.model_validate( { - **group.model_dump(), + **{ + _to.get(key, key): value + for key, value in group.model_dump( + include={ + "gid", + "name", + "description", + "thumbnail", + "inclusion_rules", + }, + exclude_unset=True, + by_alias=False, + ).items() + }, "access_rights": access_rights, } ) diff --git a/packages/models-library/src/models_library/groups.py b/packages/models-library/src/models_library/groups.py index 030914bde7f..28a91e47519 100644 --- a/packages/models-library/src/models_library/groups.py +++ b/packages/models-library/src/models_library/groups.py @@ -1,9 +1,10 @@ import enum -from typing import Annotated, Final, NamedTuple, TypeAlias, TypedDict +from typing import Annotated, Final, NamedTuple, TypeAlias from common_library.basic_types import DEFAULT_FACTORY from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator from pydantic.types import PositiveInt +from typing_extensions import TypedDict from .basic_types import IDStr from .users import GroupID, UserID @@ -42,6 +43,8 @@ class Group(BaseModel): create_enums_pre_validator(GroupTypeInModel) ) + model_config = ConfigDict(populate_by_name=True) + class AccessRightsDict(TypedDict): read: bool diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py index 8c778c162e9..2b2d984a493 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py @@ -75,11 +75,7 @@ def _row_to_model(group: Row) -> Group: def _to_group_info_tuple(group: Row) -> GroupInfoTuple: return ( _row_to_model(group), - AccessRightsDict( - read=group.access_rights.read, - write=group.access_rights.read, - delete=group.access_rights.delete, - ), + AccessRightsDict(**group.access_rights), ) @@ -353,15 +349,15 @@ async def delete_user_group( users.c.id, users.c.name, sa.case( - [(users.c.privacy_hide_email == sa.true(), None)], + (users.c.privacy_hide_email.is_(True), None), else_=users.c.email, ).label("email"), sa.case( - [(users.c.privacy_hide_fullname == sa.true(), None)], + (users.c.privacy_hide_fullname.is_(True), None), else_=users.c.first_name, ).label("first_name"), sa.case( - [(users.c.privacy_hide_fullname == sa.true(), None)], + (users.c.privacy_hide_fullname.is_(True), None), else_=users.c.last_name, ).label("last_name"), users.c.primary_gid, From 7130f4b20b55b666e6623fa4607ce9c9b40f6a78 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 9 Dec 2024 11:48:12 +0100 Subject: [PATCH 38/84] tests groups 2 passing --- .../simcore_webserver_groups_fixtures.py | 120 +++++++-------- .../unit/with_dbs/01/groups/test_groups_2.py | 137 ++++++++++++++++++ .../tests/unit/with_dbs/01/test_groups.py | 65 --------- 3 files changed, 191 insertions(+), 131 deletions(-) create mode 100644 services/web/server/tests/unit/with_dbs/01/groups/test_groups_2.py diff --git a/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py b/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py index 592eb69883c..39ae93ac35e 100644 --- a/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py +++ b/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py @@ -10,7 +10,7 @@ from collections.abc import AsyncIterator -from typing import Any, Protocol +from typing import Any import pytest from aiohttp import web @@ -19,41 +19,25 @@ from models_library.groups import GroupsByTypeTuple from models_library.users import UserID from pytest_simcore.helpers.webserver_login import NewUser, UserInfoDict -from simcore_service_webserver.groups import _groups_db from simcore_service_webserver.groups._groups_api import ( add_user_in_group, + create_organization, delete_organization, list_user_groups_with_read_access, ) -def _to_group_get_json(group, access_rights) -> dict[str, Any]: +def _groupget_model_dump(group, access_rights) -> dict[str, Any]: return GroupGet.from_model(group, access_rights).model_dump(mode="json") -# -# FACTORY FIXTURES -# - - -class CreateUserGroupCallable(Protocol): - async def __call__( - self, app: web.Application, user_id: UserID, new_group: dict - ) -> dict[str, Any]: - ... - - -@pytest.fixture -def create_user_group() -> CreateUserGroupCallable: - async def _create( - app: web.Application, user_id: UserID, new_group: dict - ) -> dict[str, Any]: - group, access_rights = await _groups_db.create_user_group( - app, user_id=user_id, new_group=new_group - ) - return _to_group_get_json(group=group, access_rights=access_rights) - - return _create +async def _create_organization( + app: web.Application, user_id: UserID, new_group: dict +) -> dict[str, Any]: + group, access_rights = await create_organization( + app, user_id=user_id, new_group_values=new_group + ) + return _groupget_model_dump(group=group, access_rights=access_rights) # @@ -62,36 +46,13 @@ async def _create( @pytest.fixture -async def logged_user_groups_by_type( - client: TestClient, logged_user: UserInfoDict -) -> GroupsByTypeTuple: - assert client.app - - groups_by_type = await list_user_groups_with_read_access( - client.app, user_id=logged_user["id"] - ) - assert groups_by_type.primary - assert groups_by_type.everyone - return groups_by_type - - -@pytest.fixture -async def primary_group( - client: TestClient, - logged_user_groups_by_type: GroupsByTypeTuple, -) -> dict[str, Any]: - assert client.app - assert logged_user_groups_by_type.primary - return _to_group_get_json(*logged_user_groups_by_type.primary) - - -@pytest.fixture -async def standard_groups( +async def standard_groups_owner( client: TestClient, logged_user: UserInfoDict, - logged_user_groups_by_type: GroupsByTypeTuple, - create_user_group: CreateUserGroupCallable, -) -> AsyncIterator[list[dict[str, Any]]]: +) -> AsyncIterator[UserInfoDict]: + """ + standard_groups_owner creates TWO organizations and adds logged_user in them + """ assert client.app # create a separate account to own standard groups @@ -102,8 +63,9 @@ async def standard_groups( }, client.app, ) as owner_user: + # creates two groups - sparc_group = await create_user_group( + sparc_group = await _create_organization( app=client.app, user_id=owner_user["id"], new_group={ @@ -113,7 +75,7 @@ async def standard_groups( "inclusionRules": {"email": r"@(sparc)+\.(io|com)$"}, }, ) - team_black_group = await create_user_group( + team_black_group = await _create_organization( app=client.app, user_id=owner_user["id"], new_group={ @@ -141,11 +103,7 @@ async def standard_groups( new_user_email=logged_user["email"], ) - standard_groups = [ - _to_group_get_json(*sg) for sg in logged_user_groups_by_type.standard - ] - - yield standard_groups + yield owner_user # clean groups await delete_organization( @@ -157,11 +115,41 @@ async def standard_groups( @pytest.fixture -async def all_group( - client: TestClient, +async def logged_user_groups_by_type( + client: TestClient, logged_user: UserInfoDict, standard_groups_owner: UserInfoDict +) -> GroupsByTypeTuple: + assert client.app + + assert logged_user["id"] != standard_groups_owner["id"] + + groups_by_type = await list_user_groups_with_read_access( + client.app, user_id=logged_user["id"] + ) + assert groups_by_type.primary + assert groups_by_type.everyone + return groups_by_type + + +@pytest.fixture +def primary_group( logged_user_groups_by_type: GroupsByTypeTuple, ) -> dict[str, Any]: - assert client.app - assert logged_user_groups_by_type.everyone + """`logged_user`'s primary group""" + assert logged_user_groups_by_type.primary + return _groupget_model_dump(*logged_user_groups_by_type.primary) - return _to_group_get_json(*logged_user_groups_by_type.everyone) + +@pytest.fixture +def standard_groups( + logged_user_groups_by_type: GroupsByTypeTuple, +) -> list[dict[str, Any]]: + """owned by `standard_groups_owner` and shared with `logged_user`""" + return [_groupget_model_dump(*sg) for sg in logged_user_groups_by_type.standard] + + +@pytest.fixture +def all_group( + logged_user_groups_by_type: GroupsByTypeTuple, +) -> dict[str, Any]: + assert logged_user_groups_by_type.everyone + return _groupget_model_dump(*logged_user_groups_by_type.everyone) diff --git a/services/web/server/tests/unit/with_dbs/01/groups/test_groups_2.py b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_2.py new file mode 100644 index 00000000000..aaa520994d5 --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_2.py @@ -0,0 +1,137 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=too-many-arguments +# pylint: disable=too-many-statements +# pylint: disable=unused-argument +# pylint: disable=unused-variable + + +import operator + +import pytest +from aiohttp.test_utils import TestClient +from models_library.api_schemas_webserver.groups import GroupGet, MyGroupsGet +from pydantic import TypeAdapter +from pytest_simcore.helpers.assert_checks import assert_status +from pytest_simcore.helpers.webserver_login import UserInfoDict +from pytest_simcore.helpers.webserver_parametrizations import ( + ExpectedResponse, + standard_role_response, +) +from servicelib.aiohttp import status +from servicelib.aiohttp.application import create_safe_application +from simcore_postgres_database.models.users import UserRole +from simcore_service_webserver._meta import API_VTAG +from simcore_service_webserver.application_settings import setup_settings +from simcore_service_webserver.db.plugin import setup_db +from simcore_service_webserver.groups.plugin import setup_groups +from simcore_service_webserver.login.plugin import setup_login +from simcore_service_webserver.rest.plugin import setup_rest +from simcore_service_webserver.security.plugin import setup_security +from simcore_service_webserver.session.plugin import setup_session +from simcore_service_webserver.users.plugin import setup_users + + +@pytest.fixture +def client( + event_loop, + aiohttp_client, + app_environment, + postgres_db, +) -> TestClient: + app = create_safe_application() + + setup_settings(app) + setup_db(app) + setup_session(app) + setup_security(app) + setup_rest(app) + setup_login(app) + setup_users(app) + setup_groups(app) + + return event_loop.run_until_complete(aiohttp_client(app)) + + +@pytest.mark.parametrize(*standard_role_response(), ids=str) +async def test_list_groups_access_rights( + client: TestClient, + logged_user: UserInfoDict, + user_role: UserRole, + expected: ExpectedResponse, + primary_group: dict[str, str], + standard_groups: list[dict[str, str]], + all_group: dict[str, str], +): + assert client.app + url = client.app.router["list_groups"].url_for() + assert f"{url}" == f"/{API_VTAG}/groups" + + response = await client.get(f"{url}") + await assert_status( + response, expected.ok if user_role != UserRole.GUEST else status.HTTP_200_OK + ) + + +@pytest.mark.parametrize("user_role", [UserRole.USER]) +async def test_list_user_groups( + client: TestClient, + user_role: UserRole, + standard_groups_owner: UserInfoDict, + logged_user: UserInfoDict, + primary_group: dict[str, str], + standard_groups: list[dict[str, str]], + all_group: dict[str, str], +): + assert client.app + assert logged_user["id"] != standard_groups_owner["id"] + assert logged_user["role"] == user_role.value + + # List all groups (organizations, primary, everyone and products) I belong to + url = client.app.router["list_groups"].url_for() + assert f"{url}" == f"/{API_VTAG}/groups" + + response = await client.get(f"{url}") + data, error = await assert_status(response, status.HTTP_200_OK) + + my_groups = MyGroupsGet.model_validate(data) + assert not error + + assert my_groups.me.model_dump() == primary_group + assert my_groups.all.model_dump() == all_group + + assert my_groups.organizations + assert len(my_groups.organizations) == len(standard_groups) + + by_gid = operator.itemgetter("gid") + assert sorted( + TypeAdapter(list[GroupGet]).dump_python(my_groups.organizations, mode="json"), + key=by_gid, + ) == sorted(standard_groups, key=by_gid) + + for group in standard_groups: + # try to delete a group + url = client.app.router["delete_group"].url_for(gid=f"{group['gid']}") + response = await client.delete(f"{url}") + await assert_status(response, status.HTTP_403_FORBIDDEN) + + # try to add some user in the group + url = client.app.router["add_group_user"].url_for(gid=f"{group['gid']}") + response = await client.post(f"{url}", json={"uid": logged_user["id"]}) + await assert_status(response, status.HTTP_403_FORBIDDEN) + + # try to modify the user in the group + url = client.app.router["update_group_user"].url_for( + gid=f"{group['gid']}", uid=f"{logged_user['id']}" + ) + response = await client.patch( + f"{url}", + json={"accessRights": {"read": True, "write": True, "delete": True}}, + ) + await assert_status(response, status.HTTP_403_FORBIDDEN) + + # try to remove the user from the group + url = client.app.router["delete_group_user"].url_for( + gid=f"{group['gid']}", uid=f"{logged_user['id']}" + ) + response = await client.delete(f"{url}") + await assert_status(response, status.HTTP_403_FORBIDDEN) diff --git a/services/web/server/tests/unit/with_dbs/01/test_groups.py b/services/web/server/tests/unit/with_dbs/01/test_groups.py index 5137e8d2eec..a8c102cdd39 100644 --- a/services/web/server/tests/unit/with_dbs/01/test_groups.py +++ b/services/web/server/tests/unit/with_dbs/01/test_groups.py @@ -104,71 +104,6 @@ def _assert__group_user( assert "gid" in actual_user -@pytest.mark.parametrize(*standard_role_response(), ids=str) -async def test_list_groups( - client: TestClient, - logged_user: UserInfoDict, - user_role: UserRole, - expected: ExpectedResponse, - primary_group: dict[str, str], - standard_groups: list[dict[str, str]], - all_group: dict[str, str], -): - assert client.app - url = client.app.router["list_groups"].url_for() - assert f"{url}" == f"/{API_VTAG}/groups" - - response = await client.get(f"{url}") - data, error = await assert_status( - response, expected.ok if user_role != UserRole.GUEST else status.HTTP_200_OK - ) - - if not error: - assert isinstance(data, dict) - - assert "me" in data - _assert_group(data["me"]) - assert data["me"] == primary_group - - assert "organizations" in data - assert isinstance(data["organizations"], list) - for group in data["organizations"]: - _assert_group(group) - assert data["organizations"] == standard_groups - - assert "all" in data - _assert_group(data["all"]) - assert data["all"] == all_group - - for group in standard_groups: - # try to delete a group - url = client.app.router["delete_group"].url_for(gid=f"{group['gid']}") - response = await client.delete(f"{url}") - await assert_status(response, status.HTTP_403_FORBIDDEN) - - # try to add some user in the group - url = client.app.router["add_group_user"].url_for(gid=f"{group['gid']}") - response = await client.post(f"{url}", json={"uid": logged_user["id"]}) - await assert_status(response, status.HTTP_403_FORBIDDEN) - - # try to modify the user in the group - url = client.app.router["update_group_user"].url_for( - gid=f"{group['gid']}", uid=f"{logged_user['id']}" - ) - response = await client.patch( - f"{url}", - json={"accessRights": {"read": True, "write": True, "delete": True}}, - ) - await assert_status(response, status.HTTP_403_FORBIDDEN) - - # try to remove the user from the group - url = client.app.router["delete_group_user"].url_for( - gid=f"{group['gid']}", uid=f"{logged_user['id']}" - ) - response = await client.delete(f"{url}") - await assert_status(response, status.HTTP_403_FORBIDDEN) - - @pytest.mark.parametrize(*standard_role_response()) async def test_group_creation_workflow( client: TestClient, From a25f05b0a8b95766401671848c31b924d4aeeeac Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 9 Dec 2024 13:25:25 +0100 Subject: [PATCH 39/84] cleanup --- .../unit/with_dbs/01/groups/test_groups_2.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/services/web/server/tests/unit/with_dbs/01/groups/test_groups_2.py b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_2.py index aaa520994d5..b52014e825e 100644 --- a/services/web/server/tests/unit/with_dbs/01/groups/test_groups_2.py +++ b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_2.py @@ -6,12 +6,15 @@ import operator +from collections.abc import Callable import pytest +import sqlalchemy as sa from aiohttp.test_utils import TestClient from models_library.api_schemas_webserver.groups import GroupGet, MyGroupsGet from pydantic import TypeAdapter from pytest_simcore.helpers.assert_checks import assert_status +from pytest_simcore.helpers.typing_env import EnvVarsDict from pytest_simcore.helpers.webserver_login import UserInfoDict from pytest_simcore.helpers.webserver_parametrizations import ( ExpectedResponse, @@ -34,9 +37,9 @@ @pytest.fixture def client( event_loop, - aiohttp_client, - app_environment, - postgres_db, + aiohttp_client: Callable, + app_environment: EnvVarsDict, + postgres_db: sa.engine.Engine, ) -> TestClient: app = create_safe_application() @@ -53,14 +56,11 @@ def client( @pytest.mark.parametrize(*standard_role_response(), ids=str) -async def test_list_groups_access_rights( +async def test_groups_access_rights( client: TestClient, logged_user: UserInfoDict, user_role: UserRole, expected: ExpectedResponse, - primary_group: dict[str, str], - standard_groups: list[dict[str, str]], - all_group: dict[str, str], ): assert client.app url = client.app.router["list_groups"].url_for() @@ -73,7 +73,7 @@ async def test_list_groups_access_rights( @pytest.mark.parametrize("user_role", [UserRole.USER]) -async def test_list_user_groups( +async def test_list_user_groups_and_try_modify_organizations( client: TestClient, user_role: UserRole, standard_groups_owner: UserInfoDict, From 12437013efcb4490e26bfdfe520ae2360accff45 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 9 Dec 2024 13:53:26 +0100 Subject: [PATCH 40/84] alternative user name --- .../src/simcore_postgres_database/utils_users.py | 6 +++++- .../web/server/src/simcore_service_webserver/users/api.py | 8 +++++++- .../src/simcore_service_webserver/users/exceptions.py | 5 ++++- 3 files changed, 16 insertions(+), 3 deletions(-) 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 806d950fee5..9026cdd27b4 100644 --- a/packages/postgres-database/src/simcore_postgres_database/utils_users.py +++ b/packages/postgres-database/src/simcore_postgres_database/utils_users.py @@ -36,6 +36,10 @@ def _generate_random_chars(length=5) -> str: return "".join(secrets.choice(string.digits) for _ in range(length - 1)) +def generate_alternative_username(username) -> str: + return f"{username}_{_generate_random_chars()}" + + class UsersRepo: @staticmethod async def new_user( @@ -61,7 +65,7 @@ async def new_user( users.insert().values(**data).returning(users.c.id) ) except UniqueViolation: # noqa: PERF203 - data["name"] = f'{data["name"]}_{_generate_random_chars()}' + data["name"] = generate_alternative_username(data["name"]) result = await conn.execute( sa.select( diff --git a/services/web/server/src/simcore_service_webserver/users/api.py b/services/web/server/src/simcore_service_webserver/users/api.py index 750b9921763..aec54928e76 100644 --- a/services/web/server/src/simcore_service_webserver/users/api.py +++ b/services/web/server/src/simcore_service_webserver/users/api.py @@ -28,6 +28,7 @@ from simcore_postgres_database.utils_groups_extra_properties import ( GroupExtraPropertiesNotFoundError, ) +from simcore_postgres_database.utils_users import generate_alternative_username from ..db.plugin import get_database_engine from ..groups.models import convert_groups_db_to_schema @@ -180,8 +181,13 @@ async def update_user_profile( assert resp.rowcount == 1 # nosec except db_errors.UniqueViolation as err: + user_name = updated_values.get("name") + raise UserNameDuplicateError( - user_name=updated_values.get("name") + user_name=user_name, + alternative_user_name=generate_alternative_username(user_name), + user_id=user_id, + updated_values=updated_values, ) from err 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 653cfeca719..d1f838d2133 100644 --- a/services/web/server/src/simcore_service_webserver/users/exceptions.py +++ b/services/web/server/src/simcore_service_webserver/users/exceptions.py @@ -22,7 +22,10 @@ def __init__(self, *, uid: int | None = None, email: str | None = None, **ctx: A class UserNameDuplicateError(UsersBaseError): - msg_template = "Username {user_name} is already in use. Violates unique constraint" + msg_template = ( + "The username '{user_name}' is already taken. " + "Consider using '{alternative_user_name}' instead." + ) class TokenNotFoundError(UsersBaseError): From 5c8691d86f0087df534383e2b9ad1b8045ca5f0b Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 9 Dec 2024 16:00:47 +0100 Subject: [PATCH 41/84] crud operations --- .../groups/_groups_db.py | 16 +- .../groups/_groups_handlers.py | 3 +- .../tests/unit/with_dbs/01/groups/conftest.py | 43 ++++ .../unit/with_dbs/01/groups/test_groups_2.py | 137 ------------ .../01/groups/test_groups_handlers_crud.py | 208 ++++++++++++++++++ 5 files changed, 261 insertions(+), 146 deletions(-) create mode 100644 services/web/server/tests/unit/with_dbs/01/groups/conftest.py delete mode 100644 services/web/server/tests/unit/with_dbs/01/groups/test_groups_2.py create mode 100644 services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_crud.py diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py index 2b2d984a493..ef6d43f1832 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py @@ -17,7 +17,7 @@ pass_or_acquire_connection, transaction_context, ) -from sqlalchemy import and_, literal_column +from sqlalchemy import and_ from sqlalchemy.dialects.postgresql import insert from sqlalchemy.engine.row import Row from sqlalchemy.ext.asyncio import AsyncConnection @@ -96,7 +96,7 @@ async def _get_group_and_access_rights_or_raise( ) -> Row: result = await conn.stream( sa.select( - groups, + *_GROUP_COLUMNS, user_to_groups.c.access_rights, ) .select_from(user_to_groups.join(groups, user_to_groups.c.gid == groups.c.gid)) @@ -302,22 +302,24 @@ async def update_user_group( k: v for k, v in convert_groups_schema_to_db(new_group_values).items() if v } - group = await _get_group_and_access_rights_or_raise( + row = await _get_group_and_access_rights_or_raise( conn, user_id=user_id, gid=gid ) - _check_group_permissions(group, user_id, gid, "write") + assert row.gid == gid # nosec + _check_group_permissions(row, user_id, gid, "write") + access_rights = AccessRightsDict(**row.access_rights) result = await conn.stream( # pylint: disable=no-value-for-parameter groups.update() .values(**new_values) - .where(groups.c.gid == group.gid) - .returning(literal_column("*")) + .where(groups.c.gid == row.gid) + .returning(*_GROUP_COLUMNS) ) row = await result.fetchone() assert row # nosec - group, access_rights = _to_group_info_tuple(row) + group = _row_to_model(row) return group, access_rights diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py index 7a0a5723fe8..a0025edc923 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py @@ -128,13 +128,12 @@ async def update_group(request: web.Request): req_ctx = GroupsRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(GroupsPathParams, request) update: GroupUpdate = await parse_request_body_as(GroupUpdate, request) - new_group_values = update.model_dump(exclude_unset=True) group, access_rights = await _groups_api.update_organization( request.app, user_id=req_ctx.user_id, group_id=path_params.gid, - new_group_values=new_group_values, + new_group_values=update.model_dump(exclude_unset=True), ) updated_group = GroupGet.from_model(group, access_rights) diff --git a/services/web/server/tests/unit/with_dbs/01/groups/conftest.py b/services/web/server/tests/unit/with_dbs/01/groups/conftest.py new file mode 100644 index 00000000000..67e733cfd78 --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/01/groups/conftest.py @@ -0,0 +1,43 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=too-many-arguments +# pylint: disable=too-many-statements +# pylint: disable=unused-argument +# pylint: disable=unused-variable + + +from collections.abc import Callable + +import pytest +import sqlalchemy as sa +from aiohttp.test_utils import TestClient +from pytest_simcore.helpers.typing_env import EnvVarsDict +from servicelib.aiohttp.application import create_safe_application +from simcore_service_webserver.application_settings import setup_settings +from simcore_service_webserver.db.plugin import setup_db +from simcore_service_webserver.groups.plugin import setup_groups +from simcore_service_webserver.login.plugin import setup_login +from simcore_service_webserver.rest.plugin import setup_rest +from simcore_service_webserver.security.plugin import setup_security +from simcore_service_webserver.session.plugin import setup_session +from simcore_service_webserver.users.plugin import setup_users + + +@pytest.fixture +def client( + event_loop, + aiohttp_client: Callable, + app_environment: EnvVarsDict, + postgres_db: sa.engine.Engine, +) -> TestClient: + app = create_safe_application() + + setup_settings(app) + setup_db(app) + setup_session(app) + setup_security(app) + setup_rest(app) + setup_login(app) + setup_users(app) + setup_groups(app) + + return event_loop.run_until_complete(aiohttp_client(app)) diff --git a/services/web/server/tests/unit/with_dbs/01/groups/test_groups_2.py b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_2.py deleted file mode 100644 index b52014e825e..00000000000 --- a/services/web/server/tests/unit/with_dbs/01/groups/test_groups_2.py +++ /dev/null @@ -1,137 +0,0 @@ -# pylint: disable=redefined-outer-name -# pylint: disable=too-many-arguments -# pylint: disable=too-many-statements -# pylint: disable=unused-argument -# pylint: disable=unused-variable - - -import operator -from collections.abc import Callable - -import pytest -import sqlalchemy as sa -from aiohttp.test_utils import TestClient -from models_library.api_schemas_webserver.groups import GroupGet, MyGroupsGet -from pydantic import TypeAdapter -from pytest_simcore.helpers.assert_checks import assert_status -from pytest_simcore.helpers.typing_env import EnvVarsDict -from pytest_simcore.helpers.webserver_login import UserInfoDict -from pytest_simcore.helpers.webserver_parametrizations import ( - ExpectedResponse, - standard_role_response, -) -from servicelib.aiohttp import status -from servicelib.aiohttp.application import create_safe_application -from simcore_postgres_database.models.users import UserRole -from simcore_service_webserver._meta import API_VTAG -from simcore_service_webserver.application_settings import setup_settings -from simcore_service_webserver.db.plugin import setup_db -from simcore_service_webserver.groups.plugin import setup_groups -from simcore_service_webserver.login.plugin import setup_login -from simcore_service_webserver.rest.plugin import setup_rest -from simcore_service_webserver.security.plugin import setup_security -from simcore_service_webserver.session.plugin import setup_session -from simcore_service_webserver.users.plugin import setup_users - - -@pytest.fixture -def client( - event_loop, - aiohttp_client: Callable, - app_environment: EnvVarsDict, - postgres_db: sa.engine.Engine, -) -> TestClient: - app = create_safe_application() - - setup_settings(app) - setup_db(app) - setup_session(app) - setup_security(app) - setup_rest(app) - setup_login(app) - setup_users(app) - setup_groups(app) - - return event_loop.run_until_complete(aiohttp_client(app)) - - -@pytest.mark.parametrize(*standard_role_response(), ids=str) -async def test_groups_access_rights( - client: TestClient, - logged_user: UserInfoDict, - user_role: UserRole, - expected: ExpectedResponse, -): - assert client.app - url = client.app.router["list_groups"].url_for() - assert f"{url}" == f"/{API_VTAG}/groups" - - response = await client.get(f"{url}") - await assert_status( - response, expected.ok if user_role != UserRole.GUEST else status.HTTP_200_OK - ) - - -@pytest.mark.parametrize("user_role", [UserRole.USER]) -async def test_list_user_groups_and_try_modify_organizations( - client: TestClient, - user_role: UserRole, - standard_groups_owner: UserInfoDict, - logged_user: UserInfoDict, - primary_group: dict[str, str], - standard_groups: list[dict[str, str]], - all_group: dict[str, str], -): - assert client.app - assert logged_user["id"] != standard_groups_owner["id"] - assert logged_user["role"] == user_role.value - - # List all groups (organizations, primary, everyone and products) I belong to - url = client.app.router["list_groups"].url_for() - assert f"{url}" == f"/{API_VTAG}/groups" - - response = await client.get(f"{url}") - data, error = await assert_status(response, status.HTTP_200_OK) - - my_groups = MyGroupsGet.model_validate(data) - assert not error - - assert my_groups.me.model_dump() == primary_group - assert my_groups.all.model_dump() == all_group - - assert my_groups.organizations - assert len(my_groups.organizations) == len(standard_groups) - - by_gid = operator.itemgetter("gid") - assert sorted( - TypeAdapter(list[GroupGet]).dump_python(my_groups.organizations, mode="json"), - key=by_gid, - ) == sorted(standard_groups, key=by_gid) - - for group in standard_groups: - # try to delete a group - url = client.app.router["delete_group"].url_for(gid=f"{group['gid']}") - response = await client.delete(f"{url}") - await assert_status(response, status.HTTP_403_FORBIDDEN) - - # try to add some user in the group - url = client.app.router["add_group_user"].url_for(gid=f"{group['gid']}") - response = await client.post(f"{url}", json={"uid": logged_user["id"]}) - await assert_status(response, status.HTTP_403_FORBIDDEN) - - # try to modify the user in the group - url = client.app.router["update_group_user"].url_for( - gid=f"{group['gid']}", uid=f"{logged_user['id']}" - ) - response = await client.patch( - f"{url}", - json={"accessRights": {"read": True, "write": True, "delete": True}}, - ) - await assert_status(response, status.HTTP_403_FORBIDDEN) - - # try to remove the user from the group - url = client.app.router["delete_group_user"].url_for( - gid=f"{group['gid']}", uid=f"{logged_user['id']}" - ) - response = await client.delete(f"{url}") - await assert_status(response, status.HTTP_403_FORBIDDEN) diff --git a/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_crud.py b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_crud.py new file mode 100644 index 00000000000..3bcd99593aa --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_crud.py @@ -0,0 +1,208 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=too-many-arguments +# pylint: disable=too-many-statements +# pylint: disable=unused-argument +# pylint: disable=unused-variable + + +import operator + +import pytest +from aiohttp.test_utils import TestClient +from models_library.api_schemas_webserver.groups import GroupGet, MyGroupsGet +from pydantic import TypeAdapter +from pytest_simcore.helpers.assert_checks import assert_status +from pytest_simcore.helpers.webserver_login import UserInfoDict +from pytest_simcore.helpers.webserver_parametrizations import ( + ExpectedResponse, + standard_role_response, +) +from servicelib.aiohttp import status +from simcore_postgres_database.models.users import UserRole +from simcore_service_webserver._meta import API_VTAG + + +@pytest.mark.parametrize(*standard_role_response(), ids=str) +async def test_groups_access_rights( + client: TestClient, + logged_user: UserInfoDict, + user_role: UserRole, + expected: ExpectedResponse, +): + assert client.app + url = client.app.router["list_groups"].url_for() + assert f"{url}" == f"/{API_VTAG}/groups" + + response = await client.get(f"{url}") + await assert_status( + response, expected.ok if user_role != UserRole.GUEST else status.HTTP_200_OK + ) + + url = client.app.router["create_group"].url_for() + assert f"{url}" == f"/{API_VTAG}/groups" + resp = await client.post( + f"{url}", + json={"label": "Black Sabbath", "description": "The founders of Rock'N'Roll"}, + ) + await assert_status(resp, expected.created) + + +@pytest.mark.parametrize("user_role", [UserRole.USER]) +async def test_list_user_groups_and_try_modify_organizations( + client: TestClient, + user_role: UserRole, + standard_groups_owner: UserInfoDict, + logged_user: UserInfoDict, + primary_group: dict[str, str], + standard_groups: list[dict[str, str]], + all_group: dict[str, str], +): + assert client.app + assert logged_user["id"] != standard_groups_owner["id"] + assert logged_user["role"] == user_role.value + + # List all groups (organizations, primary, everyone and products) I belong to + url = client.app.router["list_groups"].url_for() + assert f"{url}" == f"/{API_VTAG}/groups" + + response = await client.get(f"{url}") + data, error = await assert_status(response, status.HTTP_200_OK) + + my_groups = MyGroupsGet.model_validate(data) + assert not error + + assert my_groups.me.model_dump() == primary_group + assert my_groups.all.model_dump() == all_group + + assert my_groups.organizations + assert len(my_groups.organizations) == len(standard_groups) + + by_gid = operator.itemgetter("gid") + assert sorted( + TypeAdapter(list[GroupGet]).dump_python(my_groups.organizations, mode="json"), + key=by_gid, + ) == sorted(standard_groups, key=by_gid) + + for group in standard_groups: + # try to delete a group + url = client.app.router["delete_group"].url_for(gid=f"{group['gid']}") + response = await client.delete(f"{url}") + await assert_status(response, status.HTTP_403_FORBIDDEN) + + # try to add some user in the group + url = client.app.router["add_group_user"].url_for(gid=f"{group['gid']}") + response = await client.post(f"{url}", json={"uid": logged_user["id"]}) + await assert_status(response, status.HTTP_403_FORBIDDEN) + + # try to modify the user in the group + url = client.app.router["update_group_user"].url_for( + gid=f"{group['gid']}", uid=f"{logged_user['id']}" + ) + response = await client.patch( + f"{url}", + json={"accessRights": {"read": True, "write": True, "delete": True}}, + ) + await assert_status(response, status.HTTP_403_FORBIDDEN) + + # try to remove the user from the group + url = client.app.router["delete_group_user"].url_for( + gid=f"{group['gid']}", uid=f"{logged_user['id']}" + ) + response = await client.delete(f"{url}") + await assert_status(response, status.HTTP_403_FORBIDDEN) + + +@pytest.mark.parametrize("user_role", [UserRole.USER]) +async def test_group_creation_workflow( + client: TestClient, + user_role: UserRole, + logged_user: UserInfoDict, +): + assert client.app + assert logged_user["id"] != 0 + assert logged_user["role"] == user_role.value + + url = client.app.router["create_group"].url_for() + new_group_data = { + "label": "Black Sabbath", + "description": "The founders of Rock'N'Roll", + "thumbnail": "https://www.startpage.com/av/proxy-image?piurl=https%3A%2F%2Fencrypted-tbn0.gstatic.com%2Fimages%3Fq%3Dtbn%3AANd9GcS3pAUISv_wtYDL9Ih4JtUfAWyHj9PkYMlEBGHJsJB9QlTZuuaK%26s&sp=1591105967T00f0b7ff95c7b3bca035102fa1ead205ab29eb6cd95acedcedf6320e64634f0c", + } + + resp = await client.post(f"{url}", json=new_group_data) + data, error = await assert_status(resp, status.HTTP_201_CREATED) + + assert not error + group = GroupGet.model_validate(data) + + # we get a new gid and the rest keeps the same + assert ( + group.model_dump(include={"label", "description", "thumbnail"}, mode="json") + == new_group_data + ) + + # we get full ownership (i.e all rights) on the group since we are the creator + assert group.access_rights.model_dump() == { + "read": True, + "write": True, + "delete": True, + } + + # get the groups and check we are part of this new group + url = client.app.router["list_groups"].url_for() + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + + my_groups = MyGroupsGet.model_validate(data) + assert my_groups.organizations + assert len(my_groups.organizations) == 1 + assert ( + my_groups.organizations[0].model_dump(include=set(new_group_data), mode="json") + == new_group_data + ) + + # check getting one group + url = client.app.router["get_group"].url_for(gid=f"{group.gid}") + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + + got_group = GroupGet.model_validate(data) + assert got_group == group + + # modify the group + url = client.app.router["update_group"].url_for(gid=f"{group.gid}") + resp = await client.patch(f"{url}", json={"label": "Led Zeppelin"}) + data, _ = await assert_status(resp, status.HTTP_200_OK) + + updated_group = GroupGet.model_validate(data) + assert updated_group.model_dump(exclude={"label"}) == got_group.model_dump( + exclude={"label"} + ) + assert updated_group.label == "Led Zeppelin" + + # check getting the group returns the newly modified group + url = client.app.router["get_group"].url_for(gid=f"{updated_group.gid}") + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + + got_group = GroupGet.model_validate(data) + assert got_group == updated_group + + # delete the group + url = client.app.router["delete_group"].url_for(gid=f"{updated_group.gid}") + resp = await client.delete(f"{url}") + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # check deleting the same group again fails + url = client.app.router["delete_group"].url_for(gid=f"{updated_group.gid}") + resp = await client.delete(f"{url}") + _, error = await assert_status(resp, status.HTTP_404_NOT_FOUND) + + assert f"{group.gid}" in error["message"] + + # check getting the group fails + url = client.app.router["get_group"].url_for(gid=f"{updated_group.gid}") + resp = await client.get(f"{url}") + _, error = await assert_status(resp, status.HTTP_404_NOT_FOUND) + + assert f"{group.gid}" in error["message"] From 5676c86e297fa100368a06f7ca124654e1e786bc Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 9 Dec 2024 16:07:41 +0100 Subject: [PATCH 42/84] cleanup --- .../test_groups_handlers_users.py} | 194 +++--------------- 1 file changed, 25 insertions(+), 169 deletions(-) rename services/web/server/tests/unit/with_dbs/01/{test_groups.py => groups/test_groups_handlers_users.py} (72%) diff --git a/services/web/server/tests/unit/with_dbs/01/test_groups.py b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py similarity index 72% rename from services/web/server/tests/unit/with_dbs/01/test_groups.py rename to services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py index a8c102cdd39..6c90d3c8bad 100644 --- a/services/web/server/tests/unit/with_dbs/01/test_groups.py +++ b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py @@ -4,84 +4,38 @@ # pylint: disable=unused-argument # pylint: disable=unused-variable -from collections.abc import AsyncIterator, Callable +from collections.abc import AsyncIterator from contextlib import AsyncExitStack -from copy import deepcopy -from typing import Any import pytest from aiohttp.test_utils import TestClient from faker import Faker -from models_library.groups import AccessRightsDict +from models_library.api_schemas_webserver.groups import GroupGet +from models_library.groups import AccessRightsDict, Group from pytest_simcore.helpers.assert_checks import assert_status from pytest_simcore.helpers.webserver_login import LoggedUser, NewUser, UserInfoDict from pytest_simcore.helpers.webserver_parametrizations import ( ExpectedResponse, standard_role_response, ) -from pytest_simcore.simcore_webserver_groups_fixtures import CreateUserGroupCallable from servicelib.aiohttp import status -from servicelib.aiohttp.application import create_safe_application from simcore_postgres_database.models.users import UserRole from simcore_service_webserver._meta import API_VTAG -from simcore_service_webserver.application_settings import setup_settings -from simcore_service_webserver.db.plugin import setup_db +from simcore_service_webserver.groups._groups_api import ( + create_organization, + delete_organization, +) from simcore_service_webserver.groups._groups_db import ( _DEFAULT_GROUP_OWNER_ACCESS_RIGHTS, _DEFAULT_GROUP_READ_ACCESS_RIGHTS, ) -from simcore_service_webserver.groups.api import ( - auto_add_user_to_groups, - delete_user_group, -) -from simcore_service_webserver.groups.plugin import setup_groups -from simcore_service_webserver.login.plugin import setup_login -from simcore_service_webserver.rest.plugin import setup_rest +from simcore_service_webserver.groups.api import auto_add_user_to_groups from simcore_service_webserver.security.api import clean_auth_policy_cache -from simcore_service_webserver.security.plugin import setup_security -from simcore_service_webserver.session.plugin import setup_session -from simcore_service_webserver.users.plugin import setup_users from simcore_service_webserver.utils import gravatar_hash -@pytest.fixture -def client( - event_loop, - aiohttp_client, - app_cfg, - postgres_db, - monkeypatch_setenv_from_app_config: Callable, -) -> TestClient: - cfg = deepcopy(app_cfg) - - port = cfg["main"]["port"] - - assert cfg["rest"]["version"] == API_VTAG - monkeypatch_setenv_from_app_config(cfg) - - # fake config - app = create_safe_application(cfg) - - setup_settings(app) - setup_db(app) - setup_session(app) - setup_security(app) - setup_rest(app) - setup_login(app) - setup_users(app) - setup_groups(app) - - return event_loop.run_until_complete( - aiohttp_client(app, server_kwargs={"port": port, "host": "localhost"}) - ) - - def _assert_group(group: dict[str, str]): - properties = ["gid", "label", "description", "thumbnail", "accessRights"] - assert all(x in group for x in properties) - access_rights = group["accessRights"] - access_rights_properties = ["read", "write", "delete"] - assert all(x in access_rights for x in access_rights_properties) + return GroupGet.model_validate(group) def _assert__group_user( @@ -104,113 +58,6 @@ def _assert__group_user( assert "gid" in actual_user -@pytest.mark.parametrize(*standard_role_response()) -async def test_group_creation_workflow( - client: TestClient, - logged_user: UserInfoDict, - user_role: UserRole, - expected: ExpectedResponse, -): - assert client.app - url = client.app.router["create_group"].url_for() - assert f"{url}" == f"/{API_VTAG}/groups" - - new_group = { - "gid": "4564", - "label": "Black Sabbath", - "description": "The founders of Rock'N'Roll", - "thumbnail": "https://www.startpage.com/av/proxy-image?piurl=https%3A%2F%2Fencrypted-tbn0.gstatic.com%2Fimages%3Fq%3Dtbn%3AANd9GcS3pAUISv_wtYDL9Ih4JtUfAWyHj9PkYMlEBGHJsJB9QlTZuuaK%26s&sp=1591105967T00f0b7ff95c7b3bca035102fa1ead205ab29eb6cd95acedcedf6320e64634f0c", - } - - resp = await client.post(f"{url}", json=new_group) - data, error = await assert_status(resp, expected.created) - - assigned_group = new_group - if not error: - assert isinstance(data, dict) - assigned_group = data - _assert_group(assigned_group) - # we get a new gid and the rest keeps the same - assert assigned_group["gid"] != new_group["gid"] - for prop in ["label", "description", "thumbnail"]: - assert assigned_group[prop] == new_group[prop] - # we get all rights on the group since we are the creator - assert assigned_group["accessRights"] == { - "read": True, - "write": True, - "delete": True, - } - - # get the groups and check we are part of this new group - url = client.app.router["list_groups"].url_for() - assert f"{url}" == f"/{API_VTAG}/groups" - - resp = await client.get(f"{url}") - data, error = await assert_status( - resp, expected.ok if user_role != UserRole.GUEST else status.HTTP_200_OK - ) - if not error and user_role != UserRole.GUEST: - assert len(data["organizations"]) == 1 - assert data["organizations"][0] == assigned_group - - # check getting one group - url = client.app.router["get_group"].url_for(gid=f"{assigned_group['gid']}") - assert f"{url}" == f"/{API_VTAG}/groups/{assigned_group['gid']}" - resp = await client.get(f"{url}") - data, error = await assert_status( - resp, expected.ok if user_role != UserRole.GUEST else status.HTTP_404_NOT_FOUND - ) - if not error: - assert data == assigned_group - - # modify the group - modified_group = {"label": "Led Zeppelin"} - url = client.app.router["update_group"].url_for(gid=f"{assigned_group['gid']}") - assert f"{url}" == f"/{API_VTAG}/groups/{assigned_group['gid']}" - resp = await client.patch(f"{url}", json=modified_group) - data, error = await assert_status(resp, expected.ok) - if not error: - assert data != assigned_group - _assert_group(data) - assigned_group.update(**modified_group) - assert data == assigned_group - # check getting the group returns the newly modified group - url = client.app.router["get_group"].url_for(gid=f"{assigned_group['gid']}") - assert f"{url}" == f"/{API_VTAG}/groups/{assigned_group['gid']}" - resp = await client.get(f"{url}") - data, error = await assert_status( - resp, expected.ok if user_role != UserRole.GUEST else status.HTTP_404_NOT_FOUND - ) - if not error: - _assert_group(data) - assert data == assigned_group - - # delete the group - url = client.app.router["delete_group"].url_for(gid=f"{assigned_group['gid']}") - assert f"{url}" == f"/{API_VTAG}/groups/{assigned_group['gid']}" - resp = await client.delete(f"{url}") - data, error = await assert_status(resp, expected.no_content) - if not error: - assert not data - - # check deleting the same group again fails - url = client.app.router["delete_group"].url_for(gid=f"{assigned_group['gid']}") - assert f"{url}" == f"/{API_VTAG}/groups/{assigned_group['gid']}" - resp = await client.delete(f"{url}") - data, error = await assert_status(resp, expected.not_found) - - # check getting the group fails - url = client.app.router["get_group"].url_for(gid=f"{assigned_group['gid']}") - assert f"{url}" == f"/{API_VTAG}/groups/{assigned_group['gid']}" - resp = await client.get(f"{url}") - data, error = await assert_status( - resp, - expected.not_found - if user_role != UserRole.GUEST - else status.HTTP_404_NOT_FOUND, - ) - - @pytest.mark.parametrize(*standard_role_response()) async def test_add_remove_users_from_group( client: TestClient, @@ -428,6 +275,7 @@ async def test_group_access_rights( params = {"uid": user["id"]} if i % 2 == 0 else {"email": user["email"]} resp = await client.post(f"{add_group_user_url}", json=params) data, error = await assert_status(resp, expected.no_content) + # 3. user 1 shall be a manager patch_group_user_url = client.app.router["update_group_user"].url_for( gid=f"{assigned_group['gid']}", uid=f"{users[0]['id']}" @@ -439,6 +287,7 @@ async def test_group_access_rights( params = {"accessRights": {"read": True, "write": True, "delete": False}} resp = await client.patch(f"{patch_group_user_url}", json=params) data, error = await assert_status(resp, expected.ok) + # 4. user 2 shall be a member patch_group_user_url = client.app.router["update_group_user"].url_for( gid=f"{assigned_group['gid']}", uid=f"{users[1]['id']}" @@ -462,6 +311,7 @@ async def test_group_access_rights( }, ) await assert_status(resp, expected.ok) + # check as a manager I can remove user 2 delete_group_user_url = client.app.router["delete_group_user"].url_for( gid=f"{assigned_group['gid']}", uid=f"{users[1]['id']}" @@ -472,9 +322,11 @@ async def test_group_access_rights( ) resp = await client.delete(f"{delete_group_user_url}") data, error = await assert_status(resp, expected.no_content) + # as a manager I can add user 2 again resp = await client.post(f"{add_group_user_url}", json={"uid": users[1]["id"]}) data, error = await assert_status(resp, expected.no_content) + # as a manager I cannot delete the group url = client.app.router["delete_group"].url_for(gid=f"{assigned_group['gid']}") resp = await client.delete(f"{url}") @@ -491,6 +343,7 @@ async def test_group_access_rights( }, ) await assert_status(resp, expected.ok) + # as a member I cannot remove user 1 delete_group_user_url = client.app.router["delete_group_user"].url_for( gid=f"{assigned_group['gid']}", uid=f"{users[0]['id']}" @@ -501,9 +354,11 @@ async def test_group_access_rights( ) resp = await client.delete(f"{delete_group_user_url}") data, error = await assert_status(resp, status.HTTP_403_FORBIDDEN) + # as a member I cannot add user 1 resp = await client.post(f"{add_group_user_url}", json={"uid": users[0]["id"]}) data, error = await assert_status(resp, status.HTTP_403_FORBIDDEN) + # as a member I cannot delete the grouop url = client.app.router["delete_group"].url_for(gid=f"{assigned_group['gid']}") resp = await client.delete(f"{url}") @@ -553,21 +408,22 @@ async def test_add_user_gets_added_to_group( async def group_where_logged_user_is_the_owner( client: TestClient, logged_user: UserInfoDict, - create_user_group: CreateUserGroupCallable, -) -> AsyncIterator[dict[str, Any]]: +) -> AsyncIterator[Group]: assert client.app - group = await create_user_group( + group, _ = await create_organization( app=client.app, user_id=logged_user["id"], - new_group={ + new_group_values={ "gid": "6543", "label": f"this is user {logged_user['id']} group", "description": f"user {logged_user['email']} is the owner of that one", "thumbnail": None, }, ) + yield group - await delete_user_group(client.app, logged_user["id"], group["gid"]) + + await delete_organization(client.app, user_id=logged_user["id"], group_id=group.gid) @pytest.mark.acceptance_test( @@ -577,12 +433,12 @@ async def group_where_logged_user_is_the_owner( async def test_adding_user_to_group_with_upper_case_email( client: TestClient, user_role: UserRole, - group_where_logged_user_is_the_owner: dict[str, str], + group_where_logged_user_is_the_owner: Group, faker: Faker, ): assert client.app url = client.app.router["add_group_user"].url_for( - gid=f"{group_where_logged_user_is_the_owner['gid']}" + gid=f"{group_where_logged_user_is_the_owner.gid}" ) # adding a user to group with the email in capital letters # Tests 🐛 https://github.com/ITISFoundation/osparc-issues/issues/812 From 1948db39c39ebd2988d8f4c4094251203eeb8824 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 9 Dec 2024 16:18:36 +0100 Subject: [PATCH 43/84] cleanup tests --- .../01/groups/test_groups_handlers_users.py | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py index 6c90d3c8bad..ded36988970 100644 --- a/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py +++ b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py @@ -31,7 +31,6 @@ ) from simcore_service_webserver.groups.api import auto_add_user_to_groups from simcore_service_webserver.security.api import clean_auth_policy_cache -from simcore_service_webserver.utils import gravatar_hash def _assert_group(group: dict[str, str]): @@ -43,19 +42,26 @@ def _assert__group_user( expected_access_rights: AccessRightsDict, actual_user: dict, ): - assert "first_name" in actual_user - assert actual_user["first_name"] == expected_user.get("first_name") - assert "last_name" in actual_user - assert actual_user["last_name"] == expected_user.get("last_name") - assert "login" in actual_user - assert actual_user["login"] == expected_user["email"] - assert "gravatar_id" in actual_user - assert actual_user["gravatar_id"] == gravatar_hash(expected_user["email"]) - assert "accessRights" in actual_user - assert actual_user["accessRights"] == expected_access_rights + # identifiers + assert actual_user["userName"] == expected_user["name"] + assert "id" in actual_user - assert actual_user["id"] == expected_user["id"] + assert int(actual_user["id"]) == expected_user["id"] + assert "gid" in actual_user + assert int(actual_user["gid"]) == expected_user.get("primary_id") + + # privacy + # assert "first_name" in actual_user + # assert actual_user["first_name"] == expected_user.get("first_name") + # assert "last_name" in actual_user + # assert actual_user["last_name"] == expected_user.get("last_name") + # assert "login" in actual_user + # assert actual_user["login"] == expected_user["email"] + + # access-rights + assert "accessRights" in actual_user + assert actual_user["accessRights"] == expected_access_rights @pytest.mark.parametrize(*standard_role_response()) From ceb96ef201c236f55921b4131dc6294abe75a4bd Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 9 Dec 2024 16:25:38 +0100 Subject: [PATCH 44/84] fixing tests --- .../groups/_groups_db.py | 2 +- .../01/groups/test_groups_handlers_users.py | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py index ef6d43f1832..e394b793c3c 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py @@ -371,7 +371,7 @@ async def _get_user_in_group_permissions( ) -> Row: # now get the user result = await conn.stream( - sa.select(_GROUP_MEMBER_COLS, user_to_groups.c.access_rights) + sa.select(*_GROUP_MEMBER_COLS, user_to_groups.c.access_rights) .select_from( users.join(user_to_groups, users.c.id == user_to_groups.c.uid), ) diff --git a/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py index ded36988970..619de66f7aa 100644 --- a/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py +++ b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py @@ -10,7 +10,7 @@ import pytest from aiohttp.test_utils import TestClient from faker import Faker -from models_library.api_schemas_webserver.groups import GroupGet +from models_library.api_schemas_webserver.groups import GroupGet, GroupUserGet from models_library.groups import AccessRightsDict, Group from pytest_simcore.helpers.assert_checks import assert_status from pytest_simcore.helpers.webserver_login import LoggedUser, NewUser, UserInfoDict @@ -42,14 +42,19 @@ def _assert__group_user( expected_access_rights: AccessRightsDict, actual_user: dict, ): + user = GroupUserGet.model_validate(actual_user) + + assert user.id + assert user.gid + # identifiers assert actual_user["userName"] == expected_user["name"] assert "id" in actual_user - assert int(actual_user["id"]) == expected_user["id"] + assert int(user.id) == expected_user["id"] assert "gid" in actual_user - assert int(actual_user["gid"]) == expected_user.get("primary_id") + assert int(user.gid) == expected_user.get("primary_gid") # privacy # assert "first_name" in actual_user @@ -175,7 +180,7 @@ async def test_add_remove_users_from_group( expected_users_list = list( filter( - lambda x, ac=actual_user: x["email"] == ac["login"], + lambda x, ac=actual_user: x["id"] == ac["id"], all_created_users, ) ) @@ -183,7 +188,7 @@ async def test_add_remove_users_from_group( expected_user = expected_users_list[0] expected_access_rigths = _DEFAULT_GROUP_READ_ACCESS_RIGHTS - if actual_user["login"] == logged_user["email"]: + if actual_user["id"] == logged_user["id"]: expected_access_rigths = _DEFAULT_GROUP_OWNER_ACCESS_RIGHTS _assert__group_user( From 36418d58253fc4cf6b2b11f59a7abb52d57fe6b8 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 9 Dec 2024 16:27:54 +0100 Subject: [PATCH 45/84] pre --- .../integration/01/test_garbage_collection.py | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/services/web/server/tests/integration/01/test_garbage_collection.py b/services/web/server/tests/integration/01/test_garbage_collection.py index da40536fe8a..10a3f63f2e2 100644 --- a/services/web/server/tests/integration/01/test_garbage_collection.py +++ b/services/web/server/tests/integration/01/test_garbage_collection.py @@ -37,6 +37,7 @@ from simcore_service_webserver.director_v2.plugin import setup_director_v2 from simcore_service_webserver.garbage_collector import _core as gc_core from simcore_service_webserver.garbage_collector.plugin import setup_garbage_collector +from simcore_service_webserver.groups._groups_api import create_organization from simcore_service_webserver.groups.api import add_user_in_group from simcore_service_webserver.login.plugin import setup_login from simcore_service_webserver.projects._crud_api_delete import get_scheduled_tasks @@ -283,15 +284,18 @@ async def get_group( ): """Creates a group for a given user""" assert client.app - return await create_user_group( + + return await create_organization( app=client.app, user_id=user["id"], - new_group={"label": uuid4(), "description": uuid4(), "thumbnail": None}, + new_group_values={"label": uuid4(), "description": uuid4(), "thumbnail": None}, ) async def invite_user_to_group(client: TestClient, owner, invitee, group): """Invite a user to a group on which the owner has writes over""" + assert client.app + await add_user_in_group( client.app, owner["id"], @@ -595,7 +599,6 @@ async def test_t4_project_shared_with_group_transferred_to_user_in_group_on_owne aiopg_engine: aiopg.sa.engine.Engine, tests_data_dir: Path, osparc_product_name: str, - create_user_group: CreateUserGroupCallable, ): """ USER "u1" creates a GROUP "g1" and invites USERS "u2" and "u3"; @@ -608,7 +611,7 @@ async def test_t4_project_shared_with_group_transferred_to_user_in_group_on_owne u3 = await login_user(client) # creating g1 and inviting u2 and u3 - g1 = await get_group(client, u1, create_user_group) + g1 = await get_group(client, u1) await invite_user_to_group(client, owner=u1, invitee=u2, group=g1) await invite_user_to_group(client, owner=u1, invitee=u3, group=g1) @@ -686,7 +689,6 @@ async def test_t6_project_shared_with_group_transferred_to_last_user_in_group_on aiopg_engine: aiopg.sa.engine.Engine, tests_data_dir: Path, osparc_product_name: str, - create_user_group: CreateUserGroupCallable, ): """ USER "u1" creates a GROUP "g1" and invites USERS "u2" and "u3"; @@ -701,7 +703,7 @@ async def test_t6_project_shared_with_group_transferred_to_last_user_in_group_on u3 = await login_user(client) # creating g1 and inviting u2 and u3 - g1 = await get_group(client, u1, create_user_group) + g1 = await get_group(client, u1) await invite_user_to_group(client, owner=u1, invitee=u2, group=g1) await invite_user_to_group(client, owner=u1, invitee=u3, group=g1) @@ -758,7 +760,6 @@ async def test_t7_project_shared_with_group_transferred_from_one_member_to_the_l aiopg_engine: aiopg.sa.engine.Engine, tests_data_dir: Path, osparc_product_name: str, - create_user_group: CreateUserGroupCallable, ): """ USER "u1" creates a GROUP "g1" and invites USERS "u2" and "u3"; @@ -776,7 +777,7 @@ async def test_t7_project_shared_with_group_transferred_from_one_member_to_the_l u3 = await login_user(client) # creating g1 and inviting u2 and u3 - g1 = await get_group(client, u1, create_user_group) + g1 = await get_group(client, u1) await invite_user_to_group(client, owner=u1, invitee=u2, group=g1) await invite_user_to_group(client, owner=u1, invitee=u3, group=g1) @@ -1055,7 +1056,6 @@ async def test_t11_owner_and_all_users_in_group_marked_as_guests( aiopg_engine: aiopg.sa.engine.Engine, tests_data_dir: Path, osparc_product_name: str, - create_user_group: CreateUserGroupCallable, ): """ USER "u1" creates a group and invites "u2" and "u3"; @@ -1068,7 +1068,7 @@ async def test_t11_owner_and_all_users_in_group_marked_as_guests( u3 = await login_user(client) # creating g1 and inviting u2 and u3 - g1 = await get_group(client, u1, create_user_group) + g1 = await get_group(client, u1) await invite_user_to_group(client, owner=u1, invitee=u2, group=g1) await invite_user_to_group(client, owner=u1, invitee=u3, group=g1) From f2a44a5b7ead99bbe42a55a8213fdf9912b63610 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 9 Dec 2024 16:28:10 +0100 Subject: [PATCH 46/84] rm callable fixture --- .../server/tests/integration/01/test_garbage_collection.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/services/web/server/tests/integration/01/test_garbage_collection.py b/services/web/server/tests/integration/01/test_garbage_collection.py index 10a3f63f2e2..991f6967136 100644 --- a/services/web/server/tests/integration/01/test_garbage_collection.py +++ b/services/web/server/tests/integration/01/test_garbage_collection.py @@ -26,7 +26,6 @@ from pytest_mock import MockerFixture from pytest_simcore.helpers.webserver_login import UserInfoDict, log_client_in from pytest_simcore.helpers.webserver_projects import create_project, empty_project_data -from pytest_simcore.simcore_webserver_groups_fixtures import CreateUserGroupCallable from servicelib.aiohttp.application import create_safe_application from settings_library.rabbit import RabbitSettings from settings_library.redis import RedisDatabase, RedisSettings @@ -279,9 +278,7 @@ async def get_template_project( ) -async def get_group( - client: TestClient, user, create_user_group: CreateUserGroupCallable -): +async def get_group(client: TestClient, user: dict): """Creates a group for a given user""" assert client.app From 2545bf4d29601469477443bd13c99353971757f6 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 9 Dec 2024 16:49:43 +0100 Subject: [PATCH 47/84] rm mapping --- .../groups/_utils.py | 14 -------- .../groups/models.py | 6 ---- .../simcore_service_webserver/users/api.py | 33 +++++++++++++++---- 3 files changed, 27 insertions(+), 26 deletions(-) delete mode 100644 services/web/server/src/simcore_service_webserver/groups/models.py diff --git a/services/web/server/src/simcore_service_webserver/groups/_utils.py b/services/web/server/src/simcore_service_webserver/groups/_utils.py index c380b8f0fbf..65f98d7476a 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_utils.py +++ b/services/web/server/src/simcore_service_webserver/groups/_utils.py @@ -1,5 +1,3 @@ -from aiopg.sa.result import RowProxy - _GROUPS_SCHEMA_TO_DB = { "gid": "gid", "label": "name", @@ -10,18 +8,6 @@ } -def convert_groups_db_to_schema( - db_row: RowProxy, *, prefix: str | None = "", **kwargs -) -> dict: - converted_dict = { - k: db_row[f"{prefix}{v}"] - for k, v in _GROUPS_SCHEMA_TO_DB.items() - if f"{prefix}{v}" in db_row - } - converted_dict.update(**kwargs) - return converted_dict - - def convert_groups_schema_to_db(schema: dict) -> dict: return { v: schema[k] diff --git a/services/web/server/src/simcore_service_webserver/groups/models.py b/services/web/server/src/simcore_service_webserver/groups/models.py deleted file mode 100644 index bac7f2987bd..00000000000 --- a/services/web/server/src/simcore_service_webserver/groups/models.py +++ /dev/null @@ -1,6 +0,0 @@ -# mypy: disable-error-code=truthy-function -from ._utils import convert_groups_db_to_schema - -assert convert_groups_db_to_schema # nosec - -__all__: tuple[str, ...] = ("convert_groups_db_to_schema",) diff --git a/services/web/server/src/simcore_service_webserver/users/api.py b/services/web/server/src/simcore_service_webserver/users/api.py index aec54928e76..ff9ade31ac7 100644 --- a/services/web/server/src/simcore_service_webserver/users/api.py +++ b/services/web/server/src/simcore_service_webserver/users/api.py @@ -31,7 +31,6 @@ from simcore_postgres_database.utils_users import generate_alternative_username from ..db.plugin import get_database_engine -from ..groups.models import convert_groups_db_to_schema from ..login.storage import AsyncpgStorage, get_plugin_storage from ..security.api import clean_auth_policy_cache from . import _db @@ -47,6 +46,28 @@ _logger = logging.getLogger(__name__) +_GROUPS_SCHEMA_TO_DB = { + "gid": "gid", + "label": "name", + "description": "description", + "thumbnail": "thumbnail", + "accessRights": "access_rights", + "inclusionRules": "inclusion_rules", +} + + +def _convert_groups_db_to_schema( + db_row: RowProxy, *, prefix: str | None = "", **kwargs +) -> dict: + converted_dict = { + k: db_row[f"{prefix}{v}"] + for k, v in _GROUPS_SCHEMA_TO_DB.items() + if f"{prefix}{v}" in db_row + } + converted_dict.update(**kwargs) + return converted_dict + + def _parse_as_user(user_id: Any) -> UserID: try: return TypeAdapter(UserID).validate_python(user_id) @@ -64,7 +85,7 @@ async def get_user_profile( engine = get_database_engine(app) user_profile: dict[str, Any] = {} - user_primary_group = all_group = {} + user_primary_group = everyone_group = {} user_standard_groups = [] user_id = _parse_as_user(user_id) @@ -99,20 +120,20 @@ async def get_user_profile( assert user_profile["id"] == user_id # nosec if row.groups_type == GroupType.EVERYONE: - all_group = convert_groups_db_to_schema( + everyone_group = _convert_groups_db_to_schema( row, prefix="groups_", accessRights=row["user_to_groups_access_rights"], ) elif row.groups_type == GroupType.PRIMARY: - user_primary_group = convert_groups_db_to_schema( + user_primary_group = _convert_groups_db_to_schema( row, prefix="groups_", accessRights=row["user_to_groups_access_rights"], ) else: user_standard_groups.append( - convert_groups_db_to_schema( + _convert_groups_db_to_schema( row, prefix="groups_", accessRights=row["user_to_groups_access_rights"], @@ -147,7 +168,7 @@ async def get_user_profile( groups={ # type: ignore[arg-type] "me": user_primary_group, "organizations": user_standard_groups, - "all": all_group, + "all": everyone_group, }, privacy=MyProfilePrivacyGet( hide_fullname=user_profile["privacy_hide_fullname"], From 15e31b4a731fb865cfffd21d1f6343f71070c923 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 9 Dec 2024 17:27:52 +0100 Subject: [PATCH 48/84] models for organizations --- .../api_schemas_webserver/groups.py | 35 +++++++++++++++++-- .../src/models_library/groups.py | 12 +++++++ .../simcore_webserver_groups_fixtures.py | 6 ++-- .../groups/_groups_api.py | 19 +++++++--- .../groups/_groups_db.py | 29 +++++++++------ .../groups/_groups_handlers.py | 4 +-- .../integration/01/test_garbage_collection.py | 6 ++-- .../01/groups/test_groups_handlers_users.py | 16 +++++---- 8 files changed, 97 insertions(+), 30 deletions(-) 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 9bde201b61d..36506c960c0 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 @@ -1,5 +1,6 @@ +from ast import TypeVar from contextlib import suppress -from typing import Annotated, Self +from typing import Annotated, Any, Self from common_library.basic_types import DEFAULT_FACTORY from pydantic import ( @@ -16,7 +17,7 @@ from ..basic_types import IDStr from ..emails import LowerCaseEmailStr -from ..groups import AccessRightsDict, Group +from ..groups import AccessRightsDict, Group, OrganizationCreate, OrganizationUpdate from ..users import UserID from ..utils.common_validators import create__check_only_one_is_set__root_validator from ._base import InputSchema, OutputSchema @@ -131,17 +132,47 @@ def _sanitize_legacy_data(cls, v): return None +S = TypeVar("S", bound=BaseModel) + + +def _model_dump_with_mapping( + schema: S, field_mapping: dict[str, str] +) -> dict[str, Any]: + return { + field_mapping.get(k, k): v + for k, v in schema.model_dump(mode="json", exclude_unset=True).items() + } + + class GroupCreate(InputSchema): label: str description: str thumbnail: AnyUrl | None = None + def to_model(self) -> OrganizationCreate: + data = _model_dump_with_mapping( + self, + { + "label": "name", + }, + ) + return OrganizationCreate(**data) + class GroupUpdate(InputSchema): label: str | None = None description: str | None = None thumbnail: AnyUrl | None = None + def to_model(self) -> OrganizationUpdate: + data = _model_dump_with_mapping( + self, + { + "label": "name", + }, + ) + return OrganizationUpdate(**data) + class MyGroupsGet(OutputSchema): me: GroupGet diff --git a/packages/models-library/src/models_library/groups.py b/packages/models-library/src/models_library/groups.py index 28a91e47519..9b68549317a 100644 --- a/packages/models-library/src/models_library/groups.py +++ b/packages/models-library/src/models_library/groups.py @@ -78,6 +78,18 @@ class GroupMember(BaseModel): model_config = ConfigDict(from_attributes=True) +class OrganizationCreate(BaseModel): + name: str + description: str | None + thumbnail: str | None + + +class OrganizationUpdate(BaseModel): + name: str | None + description: str | None + thumbnail: str | None + + class GroupAtDB(Group): model_config = ConfigDict( from_attributes=True, diff --git a/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py b/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py index 39ae93ac35e..96fa7cc4803 100644 --- a/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py +++ b/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py @@ -16,7 +16,7 @@ from aiohttp import web from aiohttp.test_utils import TestClient from models_library.api_schemas_webserver.groups import GroupGet -from models_library.groups import GroupsByTypeTuple +from models_library.groups import GroupsByTypeTuple, OrganizationCreate from models_library.users import UserID from pytest_simcore.helpers.webserver_login import NewUser, UserInfoDict from simcore_service_webserver.groups._groups_api import ( @@ -35,7 +35,9 @@ async def _create_organization( app: web.Application, user_id: UserID, new_group: dict ) -> dict[str, Any]: group, access_rights = await create_organization( - app, user_id=user_id, new_group_values=new_group + app, + user_id=user_id, + new_group_values=OrganizationCreate.model_validate(new_group), ) return _groupget_model_dump(group=group, access_rights=access_rights) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py index 78e2482b59c..43b2d8a56cc 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py @@ -5,6 +5,8 @@ Group, GroupMember, GroupsByTypeTuple, + OrganizationCreate, + OrganizationUpdate, ) from models_library.users import GroupID, UserID @@ -87,14 +89,19 @@ async def get_product_group_for_user( async def create_organization( - app: web.Application, *, user_id: UserID, new_group_values: dict + app: web.Application, + *, + user_id: UserID, + new_group_values: OrganizationCreate, ) -> tuple[Group, AccessRightsDict]: """ raises GroupNotFoundError raises UserInsufficientRightsError """ return await _groups_db.create_user_group( - app, user_id=user_id, new_group=new_group_values + app, + user_id=user_id, + new_group_values=new_group_values, ) @@ -118,15 +125,19 @@ async def update_organization( *, user_id: UserID, group_id: GroupID, - new_group_values: dict[str, str], + new_group_values: OrganizationUpdate, ) -> tuple[Group, AccessRightsDict]: """ raises GroupNotFoundError raises UserInsufficientRightsError """ + return await _groups_db.update_user_group( - app, user_id=user_id, gid=group_id, new_group_values=new_group_values + app, + user_id=user_id, + gid=group_id, + updated_group_values=new_group_values, ) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py index e394b793c3c..6c59357a243 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py @@ -9,6 +9,8 @@ GroupInfoTuple, GroupMember, GroupsByTypeTuple, + OrganizationCreate, + OrganizationUpdate, ) from models_library.users import GroupID, UserID from simcore_postgres_database.errors import UniqueViolation @@ -25,7 +27,6 @@ from ..db.models import GroupType, groups, user_to_groups, users from ..db.plugin import get_asyncpg_engine from ..users.exceptions import UserNotFoundError -from ._utils import convert_groups_schema_to_db from .exceptions import ( GroupNotFoundError, UserAlreadyInGroupError, @@ -251,25 +252,30 @@ async def get_product_group_for_user( return group, access_rights +assert set(OrganizationCreate.model_fields).issubset({c.name for c in groups.columns}) + + async def create_user_group( app: web.Application, connection: AsyncConnection | None = None, *, user_id: UserID, - new_group: dict, + new_group_values: OrganizationCreate, ) -> tuple[Group, AccessRightsDict]: + + values = new_group_values.model_dump(mode="json", exclude_unset=True) + async with transaction_context(get_asyncpg_engine(app), connection) as conn: result = await conn.stream( sa.select(users.c.primary_gid).where(users.c.id == user_id) ) - user = await result.fetchone() - if not user: + if not await result.scalar_one_or_none(): raise UserNotFoundError(uid=user_id) result = await conn.stream( # pylint: disable=no-value-for-parameter groups.insert() - .values(**convert_groups_schema_to_db(new_group)) + .values(**values) .returning(*_GROUP_COLUMNS) ) row = await result.fetchone() @@ -288,20 +294,21 @@ async def create_user_group( return group, deepcopy(_DEFAULT_GROUP_OWNER_ACCESS_RIGHTS) +assert set(OrganizationUpdate.model_fields).issubset({c.name for c in groups.columns}) + + async def update_user_group( app: web.Application, connection: AsyncConnection | None = None, *, user_id: UserID, gid: GroupID, - new_group_values: dict[str, str], + updated_group_values: OrganizationUpdate, ) -> tuple[Group, AccessRightsDict]: - async with transaction_context(get_asyncpg_engine(app), connection) as conn: - new_values = { - k: v for k, v in convert_groups_schema_to_db(new_group_values).items() if v - } + values = updated_group_values.model_dump(mode="json", exclude_unset=True) + 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=gid ) @@ -312,7 +319,7 @@ async def update_user_group( result = await conn.stream( # pylint: disable=no-value-for-parameter groups.update() - .values(**new_values) + .values(**values) .where(groups.c.gid == row.gid) .returning(*_GROUP_COLUMNS) ) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py index a0025edc923..9e7c9b52062 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py @@ -112,7 +112,7 @@ async def create_group(request: web.Request): group, access_rights = await _groups_api.create_organization( request.app, user_id=req_ctx.user_id, - new_group_values=create.model_dump(mode="json", exclude_unset=True), + new_group_values=create.to_model(), ) created_group = GroupGet.from_model(group, access_rights) @@ -133,7 +133,7 @@ async def update_group(request: web.Request): request.app, user_id=req_ctx.user_id, group_id=path_params.gid, - new_group_values=update.model_dump(exclude_unset=True), + new_group_values=update.to_model(), ) updated_group = GroupGet.from_model(group, access_rights) diff --git a/services/web/server/tests/integration/01/test_garbage_collection.py b/services/web/server/tests/integration/01/test_garbage_collection.py index 991f6967136..f1d94945abd 100644 --- a/services/web/server/tests/integration/01/test_garbage_collection.py +++ b/services/web/server/tests/integration/01/test_garbage_collection.py @@ -21,7 +21,7 @@ from aiohttp import web from aiohttp.test_utils import TestClient from aioresponses import aioresponses -from models_library.groups import EVERYONE_GROUP_ID +from models_library.groups import EVERYONE_GROUP_ID, OrganizationCreate from models_library.projects_state import RunningState from pytest_mock import MockerFixture from pytest_simcore.helpers.webserver_login import UserInfoDict, log_client_in @@ -285,7 +285,9 @@ async def get_group(client: TestClient, user: dict): return await create_organization( app=client.app, user_id=user["id"], - new_group_values={"label": uuid4(), "description": uuid4(), "thumbnail": None}, + new_group_values=OrganizationCreate.model_validate( + {"label": uuid4(), "description": uuid4(), "thumbnail": None} + ), ) diff --git a/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py index 619de66f7aa..5dc39d8cc68 100644 --- a/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py +++ b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py @@ -11,7 +11,7 @@ from aiohttp.test_utils import TestClient from faker import Faker from models_library.api_schemas_webserver.groups import GroupGet, GroupUserGet -from models_library.groups import AccessRightsDict, Group +from models_library.groups import AccessRightsDict, Group, OrganizationCreate from pytest_simcore.helpers.assert_checks import assert_status from pytest_simcore.helpers.webserver_login import LoggedUser, NewUser, UserInfoDict from pytest_simcore.helpers.webserver_parametrizations import ( @@ -424,12 +424,14 @@ async def group_where_logged_user_is_the_owner( group, _ = await create_organization( app=client.app, user_id=logged_user["id"], - new_group_values={ - "gid": "6543", - "label": f"this is user {logged_user['id']} group", - "description": f"user {logged_user['email']} is the owner of that one", - "thumbnail": None, - }, + new_group_values=OrganizationCreate.model_validate( + { + "gid": "6543", + "label": f"this is user {logged_user['id']} group", + "description": f"user {logged_user['email']} is the owner of that one", + "thumbnail": None, + } + ), ) yield group From 8cec4b9ed341a981a0efb342b39214bb82f51f0e Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 9 Dec 2024 17:29:10 +0100 Subject: [PATCH 49/84] drops utils --- .../simcore_service_webserver/groups/_utils.py | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 services/web/server/src/simcore_service_webserver/groups/_utils.py diff --git a/services/web/server/src/simcore_service_webserver/groups/_utils.py b/services/web/server/src/simcore_service_webserver/groups/_utils.py deleted file mode 100644 index 65f98d7476a..00000000000 --- a/services/web/server/src/simcore_service_webserver/groups/_utils.py +++ /dev/null @@ -1,16 +0,0 @@ -_GROUPS_SCHEMA_TO_DB = { - "gid": "gid", - "label": "name", - "description": "description", - "thumbnail": "thumbnail", - "accessRights": "access_rights", - "inclusionRules": "inclusion_rules", -} - - -def convert_groups_schema_to_db(schema: dict) -> dict: - return { - v: schema[k] - for k, v in _GROUPS_SCHEMA_TO_DB.items() - if k in schema and k != "gid" - } From bab2277f0d077147fa1a77775cc40306f9503b65 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 9 Dec 2024 17:36:48 +0100 Subject: [PATCH 50/84] cleanup --- .../api_schemas_webserver/groups.py | 20 ++++++----------- .../src/models_library/groups.py | 1 + .../simcore_webserver_groups_fixtures.py | 2 +- .../groups/_groups_api.py | 22 ++++++++++++------- .../groups/_groups_db.py | 10 ++++----- .../groups/_groups_handlers.py | 4 ++-- .../integration/01/test_garbage_collection.py | 2 +- .../01/groups/test_groups_handlers_users.py | 2 +- 8 files changed, 31 insertions(+), 32 deletions(-) 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 36506c960c0..cee2bff342a 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 @@ -132,14 +132,12 @@ def _sanitize_legacy_data(cls, v): return None -S = TypeVar("S", bound=BaseModel) +Sc = TypeVar("Sc", bound=BaseModel) -def _model_dump_with_mapping( - schema: S, field_mapping: dict[str, str] -) -> dict[str, Any]: +def _model_dump_with_map(schema: Sc, alias_map: dict[str, str]) -> dict[str, Any]: return { - field_mapping.get(k, k): v + alias_map.get(k, k): v for k, v in schema.model_dump(mode="json", exclude_unset=True).items() } @@ -150,11 +148,9 @@ class GroupCreate(InputSchema): thumbnail: AnyUrl | None = None def to_model(self) -> OrganizationCreate: - data = _model_dump_with_mapping( + data = _model_dump_with_map( self, - { - "label": "name", - }, + alias_map={"label": "name"}, ) return OrganizationCreate(**data) @@ -165,11 +161,9 @@ class GroupUpdate(InputSchema): thumbnail: AnyUrl | None = None def to_model(self) -> OrganizationUpdate: - data = _model_dump_with_mapping( + data = _model_dump_with_map( self, - { - "label": "name", - }, + alias_map={"label": "name"}, ) return OrganizationUpdate(**data) diff --git a/packages/models-library/src/models_library/groups.py b/packages/models-library/src/models_library/groups.py index 9b68549317a..89c9b854d6e 100644 --- a/packages/models-library/src/models_library/groups.py +++ b/packages/models-library/src/models_library/groups.py @@ -91,6 +91,7 @@ class OrganizationUpdate(BaseModel): class GroupAtDB(Group): + # NOTE: deprecate and use `Group` instead model_config = ConfigDict( from_attributes=True, json_schema_extra={ diff --git a/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py b/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py index 96fa7cc4803..8a5886f0368 100644 --- a/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py +++ b/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py @@ -37,7 +37,7 @@ async def _create_organization( group, access_rights = await create_organization( app, user_id=user_id, - new_group_values=OrganizationCreate.model_validate(new_group), + create=OrganizationCreate.model_validate(new_group), ) return _groupget_model_dump(group=group, access_rights=access_rights) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py index 43b2d8a56cc..a75e5b661de 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py @@ -8,6 +8,7 @@ OrganizationCreate, OrganizationUpdate, ) +from models_library.products import ProductName from models_library.users import GroupID, UserID from ..users.api import get_user @@ -92,7 +93,7 @@ async def create_organization( app: web.Application, *, user_id: UserID, - new_group_values: OrganizationCreate, + create: OrganizationCreate, ) -> tuple[Group, AccessRightsDict]: """ raises GroupNotFoundError @@ -101,7 +102,7 @@ async def create_organization( return await _groups_db.create_user_group( app, user_id=user_id, - new_group_values=new_group_values, + create=create, ) @@ -125,7 +126,7 @@ async def update_organization( *, user_id: UserID, group_id: GroupID, - new_group_values: OrganizationUpdate, + update: OrganizationUpdate, ) -> tuple[Group, AccessRightsDict]: """ @@ -137,7 +138,7 @@ async def update_organization( app, user_id=user_id, gid=group_id, - updated_group_values=new_group_values, + update=update, ) @@ -164,7 +165,10 @@ async def list_users_in_group( async def get_user_in_group( - app: web.Application, user_id: UserID, gid: GroupID, the_user_id_in_group: int + app: web.Application, + user_id: UserID, + gid: GroupID, + the_user_id_in_group: UserID, ) -> GroupMember: return await _groups_db.get_user_in_group( @@ -176,7 +180,7 @@ async def update_user_in_group( app: web.Application, user_id: UserID, gid: GroupID, - the_user_id_in_group: int, + the_user_id_in_group: UserID, access_rights: dict, ) -> GroupMember: return await _groups_db.update_user_in_group( @@ -192,7 +196,7 @@ async def delete_user_in_group( app: web.Application, user_id: UserID, gid: GroupID, - the_user_id_in_group: int, + the_user_id_in_group: UserID, ) -> None: return await _groups_db.delete_user_in_group( app, user_id=user_id, gid=gid, the_user_id_in_group=the_user_id_in_group @@ -216,7 +220,9 @@ async def auto_add_user_to_groups(app: web.Application, user_id: UserID) -> None async def auto_add_user_to_product_group( - app: web.Application, user_id: UserID, product_name: str + app: web.Application, + user_id: UserID, + product_name: ProductName, ) -> GroupID: return await _groups_db.auto_add_user_to_product_group( app, user_id=user_id, product_name=product_name diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py index 6c59357a243..8fc56544347 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py @@ -260,11 +260,9 @@ async def create_user_group( connection: AsyncConnection | None = None, *, user_id: UserID, - new_group_values: OrganizationCreate, + create: OrganizationCreate, ) -> tuple[Group, AccessRightsDict]: - values = new_group_values.model_dump(mode="json", exclude_unset=True) - async with transaction_context(get_asyncpg_engine(app), connection) as conn: result = await conn.stream( sa.select(users.c.primary_gid).where(users.c.id == user_id) @@ -275,7 +273,7 @@ async def create_user_group( result = await conn.stream( # pylint: disable=no-value-for-parameter groups.insert() - .values(**values) + .values(**create.model_dump(mode="json", exclude_unset=True)) .returning(*_GROUP_COLUMNS) ) row = await result.fetchone() @@ -303,10 +301,10 @@ async def update_user_group( *, user_id: UserID, gid: GroupID, - updated_group_values: OrganizationUpdate, + update: OrganizationUpdate, ) -> tuple[Group, AccessRightsDict]: - values = updated_group_values.model_dump(mode="json", exclude_unset=True) + values = update.model_dump(mode="json", exclude_unset=True) async with transaction_context(get_asyncpg_engine(app), connection) as conn: row = await _get_group_and_access_rights_or_raise( diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py index 9e7c9b52062..a9a61ad5b83 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py @@ -112,7 +112,7 @@ async def create_group(request: web.Request): group, access_rights = await _groups_api.create_organization( request.app, user_id=req_ctx.user_id, - new_group_values=create.to_model(), + create=create.to_model(), ) created_group = GroupGet.from_model(group, access_rights) @@ -133,7 +133,7 @@ async def update_group(request: web.Request): request.app, user_id=req_ctx.user_id, group_id=path_params.gid, - new_group_values=update.to_model(), + update=update.to_model(), ) updated_group = GroupGet.from_model(group, access_rights) diff --git a/services/web/server/tests/integration/01/test_garbage_collection.py b/services/web/server/tests/integration/01/test_garbage_collection.py index f1d94945abd..edff94f51f8 100644 --- a/services/web/server/tests/integration/01/test_garbage_collection.py +++ b/services/web/server/tests/integration/01/test_garbage_collection.py @@ -285,7 +285,7 @@ async def get_group(client: TestClient, user: dict): return await create_organization( app=client.app, user_id=user["id"], - new_group_values=OrganizationCreate.model_validate( + create=OrganizationCreate.model_validate( {"label": uuid4(), "description": uuid4(), "thumbnail": None} ), ) diff --git a/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py index 5dc39d8cc68..a3fea618d89 100644 --- a/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py +++ b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py @@ -424,7 +424,7 @@ async def group_where_logged_user_is_the_owner( group, _ = await create_organization( app=client.app, user_id=logged_user["id"], - new_group_values=OrganizationCreate.model_validate( + create=OrganizationCreate.model_validate( { "gid": "6543", "label": f"this is user {logged_user['id']} group", From 6d03a7901cdb1d7f43c003c05d64fe0740e9b95d Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 9 Dec 2024 17:56:36 +0100 Subject: [PATCH 51/84] update privacy --- .../groups/_groups_api.py | 2 +- .../groups/_groups_db.py | 48 ++++++++++--------- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py index a75e5b661de..a7bd3b0e070 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py @@ -198,7 +198,7 @@ async def delete_user_in_group( gid: GroupID, the_user_id_in_group: UserID, ) -> None: - return await _groups_db.delete_user_in_group( + return await _groups_db.delete_user_from_group( app, user_id=user_id, gid=gid, the_user_id_in_group=the_user_id_in_group ) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py index 8fc56544347..fa962e799ee 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py @@ -352,23 +352,25 @@ async def delete_user_group( # -_GROUP_MEMBER_COLS = ( - users.c.id, - users.c.name, - sa.case( - (users.c.privacy_hide_email.is_(True), None), - else_=users.c.email, - ).label("email"), - sa.case( - (users.c.privacy_hide_fullname.is_(True), None), - else_=users.c.first_name, - ).label("first_name"), - sa.case( - (users.c.privacy_hide_fullname.is_(True), None), - else_=users.c.last_name, - ).label("last_name"), - users.c.primary_gid, -) +def _group_user_cols(user_id: int): + return ( + users.c.id, + users.c.name, + # privacy settings + sa.case( + (users.c.privacy_hide_email.is_(True) and users.c.id != user_id, None), + else_=users.c.email, + ).label("email"), + sa.case( + (users.c.privacy_hide_fullname.is_(True) and users.c.id != user_id, None), + else_=users.c.first_name, + ).label("first_name"), + sa.case( + (users.c.privacy_hide_fullname.is_(True) and users.c.id != user_id, None), + else_=users.c.last_name, + ).label("last_name"), + users.c.primary_gid, + ) async def _get_user_in_group_permissions( @@ -376,7 +378,7 @@ async def _get_user_in_group_permissions( ) -> Row: # now get the user result = await conn.stream( - sa.select(*_GROUP_MEMBER_COLS, user_to_groups.c.access_rights) + sa.select(*_group_user_cols(user_id), user_to_groups.c.access_rights) .select_from( users.join(user_to_groups, users.c.id == user_to_groups.c.uid), ) @@ -405,7 +407,7 @@ async def list_users_in_group( # now get the list query = ( sa.select( - *_GROUP_MEMBER_COLS, + *_group_user_cols(user_id), user_to_groups.c.access_rights, ) .select_from(users.join(user_to_groups)) @@ -444,8 +446,8 @@ async def update_user_in_group( *, user_id: UserID, gid: GroupID, - the_user_id_in_group: int, - access_rights: dict, + the_user_id_in_group: UserID, + access_rights: AccessRightsDict, ) -> GroupMember: if not access_rights: msg = f"Cannot update empty {access_rights}" @@ -482,13 +484,13 @@ async def update_user_in_group( return GroupMember.model_validate(user) -async def delete_user_in_group( +async def delete_user_from_group( app: web.Application, connection: AsyncConnection | None = None, *, user_id: UserID, gid: GroupID, - the_user_id_in_group: int, + the_user_id_in_group: UserID, ) -> None: async with transaction_context(get_asyncpg_engine(app), connection) as conn: # first check if the group exists From 12ad5373a29b19971b11b8d90d77639ff709f956 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 9 Dec 2024 18:41:54 +0100 Subject: [PATCH 52/84] move adapters to schemas --- .../api_schemas_webserver/groups.py | 72 +++++++++++-------- .../groups/_groups_api.py | 5 +- .../groups/_groups_db.py | 6 +- .../groups/_groups_handlers.py | 22 +----- 4 files changed, 54 insertions(+), 51 deletions(-) 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 cee2bff342a..eb5d80d0f6c 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 @@ -1,6 +1,5 @@ -from ast import TypeVar from contextlib import suppress -from typing import Annotated, Any, Self +from typing import Annotated, Any, Self, TypeVar from common_library.basic_types import DEFAULT_FACTORY from pydantic import ( @@ -17,11 +16,23 @@ from ..basic_types import IDStr from ..emails import LowerCaseEmailStr -from ..groups import AccessRightsDict, Group, OrganizationCreate, OrganizationUpdate +from ..groups import ( + AccessRightsDict, + Group, + GroupMember, + OrganizationCreate, + OrganizationUpdate, +) from ..users import UserID from ..utils.common_validators import create__check_only_one_is_set__root_validator from ._base import InputSchema, OutputSchema +S = TypeVar("S", bound=BaseModel) + + +def _rename_keys(source: dict, name_map: dict[str, str]) -> dict[str, Any]: + return {name_map.get(k, k): v for k, v in source.items()} + class GroupAccessRights(BaseModel): """ @@ -63,16 +74,11 @@ class GroupGet(OutputSchema): @classmethod def from_model(cls, group: Group, access_rights: AccessRightsDict) -> Self: - # Fuses both dataset into GroupSet - _to = { - "name": "label", - } - + # Merges both service models into this schema return cls.model_validate( { - **{ - _to.get(key, key): value - for key, value in group.model_dump( + **_rename_keys( + group.model_dump( include={ "gid", "name", @@ -82,8 +88,11 @@ def from_model(cls, group: Group, access_rights: AccessRightsDict) -> Self: }, exclude_unset=True, by_alias=False, - ).items() - }, + ), + name_map={ + "name": "label", + }, + ), "access_rights": access_rights, } ) @@ -132,25 +141,15 @@ def _sanitize_legacy_data(cls, v): return None -Sc = TypeVar("Sc", bound=BaseModel) - - -def _model_dump_with_map(schema: Sc, alias_map: dict[str, str]) -> dict[str, Any]: - return { - alias_map.get(k, k): v - for k, v in schema.model_dump(mode="json", exclude_unset=True).items() - } - - class GroupCreate(InputSchema): label: str description: str thumbnail: AnyUrl | None = None def to_model(self) -> OrganizationCreate: - data = _model_dump_with_map( - self, - alias_map={"label": "name"}, + data = _rename_keys( + self.model_dump(mode="json", exclude_unset=True), + name_map={"label": "name"}, ) return OrganizationCreate(**data) @@ -161,9 +160,9 @@ class GroupUpdate(InputSchema): thumbnail: AnyUrl | None = None def to_model(self) -> OrganizationUpdate: - data = _model_dump_with_map( - self, - alias_map={"label": "name"}, + data = _rename_keys( + self.model_dump(mode="json", exclude_unset=True), + name_map={"label": "name"}, ) return OrganizationUpdate(**data) @@ -217,6 +216,7 @@ class MyGroupsGet(OutputSchema): class GroupUserGet(BaseModel): + # OutputSchema # Identifiers id: Annotated[ @@ -265,6 +265,20 @@ class GroupUserGet(BaseModel): }, ) + @classmethod + def from_model(cls, user: GroupMember) -> Self: + return cls.model_validate( + { + "id": user.id, + "user_name": user.name, + "login": user.email, + "first_name": user.first_name, + "last_name": user.last_name, + "gid": user.primary_gid, + "access_rights": user.access_rights, + } + ) + class GroupUserAdd(InputSchema): """ diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py index a7bd3b0e070..08e81aac8a1 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py @@ -10,6 +10,7 @@ ) from models_library.products import ProductName from models_library.users import GroupID, UserID +from pydantic import EmailStr from ..users.api import get_user from . import _groups_db @@ -181,7 +182,7 @@ async def update_user_in_group( user_id: UserID, gid: GroupID, the_user_id_in_group: UserID, - access_rights: dict, + access_rights: AccessRightsDict, ) -> GroupMember: return await _groups_db.update_user_in_group( app, @@ -235,7 +236,7 @@ async def add_user_in_group( gid: GroupID, *, new_user_id: UserID | None = None, - new_user_email: str | None = None, + new_user_email: EmailStr | None = None, access_rights: AccessRightsDict | None = None, ) -> None: """Adds new_user (either by id or email) in group (with gid) owned by user_id diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py index fa962e799ee..e88fe0efff8 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py @@ -76,7 +76,11 @@ def _row_to_model(group: Row) -> Group: def _to_group_info_tuple(group: Row) -> GroupInfoTuple: return ( _row_to_model(group), - AccessRightsDict(**group.access_rights), + AccessRightsDict( + read=group.access_rights["read"], + write=group.access_rights["write"], + delete=group.access_rights["delete"], + ), ) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py index a9a61ad5b83..8abf1e276e5 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py @@ -11,7 +11,6 @@ GroupUserUpdate, MyGroupsGet, ) -from models_library.groups import GroupMember from servicelib.aiohttp import status from servicelib.aiohttp.requests_validation import ( parse_request_body_as, @@ -160,21 +159,6 @@ async def delete_group(request: web.Request): # -def _to_groupuserget_model(user: GroupMember) -> GroupUserGet: - # Fuses both dataset into GroupSet - return GroupUserGet.model_validate( - { - "id": user.id, - "user_name": user.name, - "login": user.email, - "first_name": user.first_name, - "last_name": user.last_name, - "gid": user.primary_gid, - "access_rights": user.access_rights, - } - ) - - @routes.get(f"/{API_VTAG}/groups/{{gid}}/users", name="get_all_group_users") @login_required @permission_required("groups.*") @@ -189,7 +173,7 @@ async def get_group_users(request: web.Request): ) return envelope_json_response( - [_to_groupuserget_model(user) for user in users_in_group] + [GroupUserGet.from_model(user) for user in users_in_group] ) @@ -228,7 +212,7 @@ async def get_group_user(request: web.Request): user = await _groups_api.get_user_in_group( request.app, req_ctx.user_id, path_params.gid, path_params.uid ) - return envelope_json_response(_to_groupuserget_model(user)) + return envelope_json_response(GroupUserGet.from_model(user)) @routes.patch(f"/{API_VTAG}/groups/{{gid}}/users/{{uid}}", name="update_group_user") @@ -247,7 +231,7 @@ async def update_group_user(request: web.Request): the_user_id_in_group=path_params.uid, access_rights=update.access_rights.model_dump(), ) - return envelope_json_response(_to_groupuserget_model(user)) + return envelope_json_response(GroupUserGet.from_model(user)) @routes.delete(f"/{API_VTAG}/groups/{{gid}}/users/{{uid}}", name="delete_group_user") From fd2c736e496c60b57775bf0f99cc64e04f1383bc Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 9 Dec 2024 18:47:26 +0100 Subject: [PATCH 53/84] mypy --- .../src/simcore_service_webserver/groups/_groups_db.py | 2 +- .../simcore_service_webserver/groups/_groups_handlers.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py index e88fe0efff8..81fc7393abe 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py @@ -316,7 +316,7 @@ async def update_user_group( ) assert row.gid == gid # nosec _check_group_permissions(row, user_id, gid, "write") - access_rights = AccessRightsDict(**row.access_rights) + access_rights = AccessRightsDict(**row.access_rights) # type: ignore[typeddict-item] result = await conn.stream( # pylint: disable=no-value-for-parameter diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py index 8abf1e276e5..d4d2b3c1a03 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py @@ -94,8 +94,7 @@ async def get_group(request: web.Request): request.app, user_id=req_ctx.user_id, group_id=path_params.gid ) - group = GroupGet.from_model(group, access_rights) - return envelope_json_response(group) + return envelope_json_response(GroupGet.from_model(group, access_rights)) @routes.post(f"/{API_VTAG}/groups", name="create_group") @@ -209,6 +208,7 @@ async def get_group_user(request: web.Request): """ req_ctx = GroupsRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(GroupsUsersPathParams, request) + user = await _groups_api.get_user_in_group( request.app, req_ctx.user_id, path_params.gid, path_params.uid ) @@ -229,7 +229,7 @@ async def update_group_user(request: web.Request): user_id=req_ctx.user_id, gid=path_params.gid, the_user_id_in_group=path_params.uid, - access_rights=update.access_rights.model_dump(), + access_rights=update.access_rights.model_dump(mode="json"), # type: ignore[arg-type] ) return envelope_json_response(GroupUserGet.from_model(user)) From 98e846fd67cf408fe4e2b91fd8be6eb99f70b8be Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 9 Dec 2024 19:09:45 +0100 Subject: [PATCH 54/84] fixes caller --- .../groups/_groups_api.py | 8 +- .../groups/_groups_db.py | 76 +++++++++++-------- .../01/groups/test_groups_handlers_users.py | 48 +++++++++--- 3 files changed, 85 insertions(+), 47 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py index 08e81aac8a1..520f24c1748 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py @@ -138,7 +138,7 @@ async def update_organization( return await _groups_db.update_user_group( app, user_id=user_id, - gid=group_id, + group_id=group_id, update=update, ) @@ -151,7 +151,7 @@ async def delete_organization( raises GroupNotFoundError raises UserInsufficientRightsError """ - return await _groups_db.delete_user_group(app, user_id=user_id, gid=group_id) + return await _groups_db.delete_user_group(app, user_id=user_id, group_id=group_id) # @@ -162,7 +162,7 @@ async def delete_organization( async def list_users_in_group( app: web.Application, user_id: UserID, gid: GroupID ) -> list[GroupMember]: - return await _groups_db.list_users_in_group(app, user_id=user_id, gid=gid) + return await _groups_db.list_users_in_group(app, user_id=user_id, group_id=gid) async def get_user_in_group( @@ -173,7 +173,7 @@ async def get_user_in_group( ) -> GroupMember: return await _groups_db.get_user_in_group( - app, user_id=user_id, gid=gid, the_user_id_in_group=the_user_id_in_group + app, user_id=user_id, group_id=gid, the_user_id_in_group=the_user_id_in_group ) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py index 81fc7393abe..797c4fdd47b 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py @@ -268,10 +268,10 @@ async def create_user_group( ) -> tuple[Group, AccessRightsDict]: async with transaction_context(get_asyncpg_engine(app), connection) as conn: - result = await conn.stream( + user = await conn.scalar( sa.select(users.c.primary_gid).where(users.c.id == user_id) ) - if not await result.scalar_one_or_none(): + if not user: raise UserNotFoundError(uid=user_id) result = await conn.stream( @@ -304,7 +304,7 @@ async def update_user_group( connection: AsyncConnection | None = None, *, user_id: UserID, - gid: GroupID, + group_id: GroupID, update: OrganizationUpdate, ) -> tuple[Group, AccessRightsDict]: @@ -312,10 +312,10 @@ async def update_user_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=gid + conn, user_id=user_id, gid=group_id ) - assert row.gid == gid # nosec - _check_group_permissions(row, user_id, gid, "write") + assert row.gid == group_id # nosec + _check_group_permissions(row, user_id, group_id, "write") access_rights = AccessRightsDict(**row.access_rights) # type: ignore[typeddict-item] result = await conn.stream( @@ -337,13 +337,13 @@ async def delete_user_group( connection: AsyncConnection | None = None, *, user_id: UserID, - gid: GroupID, + 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=gid + conn, user_id=user_id, gid=group_id ) - _check_group_permissions(group, user_id, gid, "delete") + _check_group_permissions(group, user_id, group_id, "delete") await conn.execute( # pylint: disable=no-value-for-parameter @@ -356,41 +356,52 @@ async def delete_user_group( # -def _group_user_cols(user_id: int): +def _group_user_cols(caller_user_id: int): return ( users.c.id, users.c.name, # privacy settings sa.case( - (users.c.privacy_hide_email.is_(True) and users.c.id != user_id, None), + ( + 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) and users.c.id != user_id, None), + ( + 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) and users.c.id != user_id, None), + ( + users.c.privacy_hide_fullname.is_(True) + & (users.c.id != caller_user_id), + None, + ), else_=users.c.last_name, ).label("last_name"), users.c.primary_gid, ) -async def _get_user_in_group_permissions( - conn: AsyncConnection, *, gid: GroupID, user_id: int +async def _get_user_in_group( + conn: AsyncConnection, *, caller_user_id, group_id: GroupID, user_id: int ) -> Row: # now get the user result = await conn.stream( - sa.select(*_group_user_cols(user_id), user_to_groups.c.access_rights) + sa.select(*_group_user_cols(caller_user_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 == gid, users.c.id == user_id)) + .where(and_(user_to_groups.c.gid == group_id, users.c.id == user_id)) ) row = await result.fetchone() if not row: - raise UserInGroupNotFoundError(uid=user_id, gid=gid) + raise UserInGroupNotFoundError(uid=user_id, gid=group_id) return row @@ -399,14 +410,14 @@ async def list_users_in_group( connection: AsyncConnection | None = None, *, user_id: UserID, - gid: GroupID, + 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=gid + conn, user_id=user_id, gid=group_id ) - _check_group_permissions(group, user_id, gid, "read") + _check_group_permissions(group, user_id, group_id, "read") # now get the list query = ( @@ -415,7 +426,7 @@ async def list_users_in_group( user_to_groups.c.access_rights, ) .select_from(users.join(user_to_groups)) - .where(user_to_groups.c.gid == gid) + .where(user_to_groups.c.gid == group_id) ) result = await conn.stream(query) @@ -427,19 +438,22 @@ async def get_user_in_group( connection: AsyncConnection | None = None, *, user_id: UserID, - gid: GroupID, + 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=gid + conn, user_id=user_id, gid=group_id ) - _check_group_permissions(group, user_id, gid, "read") + _check_group_permissions(group, user_id, group_id, "read") # get the user with its permissions - the_user = await _get_user_in_group_permissions( - conn, gid=gid, user_id=the_user_id_in_group + the_user = await _get_user_in_group( + conn, + caller_user_id=user_id, + group_id=group_id, + user_id=the_user_id_in_group, ) return GroupMember.model_validate(the_user) @@ -466,8 +480,8 @@ async def update_user_in_group( _check_group_permissions(group, user_id, gid, "write") # now check the user exists - the_user = await _get_user_in_group_permissions( - conn, gid=gid, user_id=the_user_id_in_group + the_user = await _get_user_in_group( + conn, caller_user_id=user_id, group_id=gid, user_id=the_user_id_in_group ) # modify the user access rights @@ -504,8 +518,8 @@ async def delete_user_from_group( _check_group_permissions(group, user_id, gid, "write") # check the user exists - await _get_user_in_group_permissions( - conn, gid=gid, user_id=the_user_id_in_group + await _get_user_in_group( + conn, caller_user_id=user_id, group_id=gid, user_id=the_user_id_in_group ) # delete him/her diff --git a/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py index a3fea618d89..05d5dc396c7 100644 --- a/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py +++ b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py @@ -41,6 +41,7 @@ def _assert__group_user( expected_user: UserInfoDict, expected_access_rights: AccessRightsDict, actual_user: dict, + group_owner_id: int, ): user = GroupUserGet.model_validate(actual_user) @@ -49,20 +50,24 @@ def _assert__group_user( # identifiers assert actual_user["userName"] == expected_user["name"] - assert "id" in actual_user assert int(user.id) == expected_user["id"] assert "gid" in actual_user assert int(user.gid) == expected_user.get("primary_gid") - # privacy - # assert "first_name" in actual_user - # assert actual_user["first_name"] == expected_user.get("first_name") - # assert "last_name" in actual_user - # assert actual_user["last_name"] == expected_user.get("last_name") - # assert "login" in actual_user - # assert actual_user["login"] == expected_user["email"] + # private profile + is_private = group_owner_id != actual_user["id"] + assert "first_name" in actual_user + assert actual_user["first_name"] == ( + None if is_private else expected_user.get("first_name") + ) + assert "last_name" in actual_user + assert actual_user["last_name"] == ( + None if is_private else expected_user.get("last_name") + ) + assert "login" in actual_user + assert actual_user["login"] == (None if is_private else expected_user["email"]) # access-rights assert "accessRights" in actual_user @@ -127,7 +132,12 @@ async def test_add_remove_users_from_group( list_of_users = data assert len(list_of_users) == 1 the_owner = list_of_users[0] - _assert__group_user(logged_user, _DEFAULT_GROUP_OWNER_ACCESS_RIGHTS, the_owner) + _assert__group_user( + logged_user, + _DEFAULT_GROUP_OWNER_ACCESS_RIGHTS, + the_owner, + group_owner_id=the_owner["id"], + ) # create a random number of users and put them in the group add_group_user_url = client.app.router["add_group_user"].url_for( @@ -165,7 +175,10 @@ async def test_add_remove_users_from_group( data, error = await assert_status(resp, expected.ok) if not error: _assert__group_user( - created_users_list[i], _DEFAULT_GROUP_READ_ACCESS_RIGHTS, data + created_users_list[i], + _DEFAULT_GROUP_READ_ACCESS_RIGHTS, + data, + group_owner_id=the_owner["id"], ) # check list is correct resp = await client.get(f"{get_group_users_url}") @@ -195,6 +208,7 @@ async def test_add_remove_users_from_group( expected_user, expected_access_rigths, actual_user, + group_owner_id=the_owner["id"], ) all_created_users.remove(expected_users_list[0]) @@ -213,7 +227,12 @@ async def test_add_remove_users_from_group( ) data, error = await assert_status(resp, expected.ok) if not error: - _assert__group_user(created_users_list[i], MANAGER_ACCESS_RIGHTS, data) + _assert__group_user( + created_users_list[i], + MANAGER_ACCESS_RIGHTS, + data, + group_owner_id=the_owner["id"], + ) # check it is there get_group_user_url = client.app.router["get_group_user"].url_for( gid=f"{assigned_group['gid']}", uid=f"{created_users_list[i]['id']}" @@ -221,7 +240,12 @@ async def test_add_remove_users_from_group( resp = await client.get(f"{get_group_user_url}") data, error = await assert_status(resp, expected.ok) if not error: - _assert__group_user(created_users_list[i], MANAGER_ACCESS_RIGHTS, data) + _assert__group_user( + created_users_list[i], + MANAGER_ACCESS_RIGHTS, + data, + group_owner_id=the_owner["id"], + ) # remove the user from the group delete_group_user_url = client.app.router["delete_group_user"].url_for( gid=f"{assigned_group['gid']}", uid=f"{created_users_list[i]['id']}" From aaf9c4a88f00a92caaf1cab8705079c9e2340810 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 9 Dec 2024 21:20:08 +0100 Subject: [PATCH 55/84] gid --- .../simcore_webserver_groups_fixtures.py | 4 +- .../garbage_collector/_core_utils.py | 2 +- .../groups/_groups_api.py | 33 +++++++----- .../groups/_groups_db.py | 54 ++++++++++++------- .../groups/_groups_handlers.py | 2 +- .../projects/_nodes_handlers.py | 2 +- 6 files changed, 59 insertions(+), 38 deletions(-) diff --git a/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py b/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py index 8a5886f0368..94272aa3030 100644 --- a/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py +++ b/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py @@ -93,7 +93,7 @@ async def standard_groups_owner( await add_user_in_group( app=client.app, user_id=owner_user["id"], - gid=sparc_group["gid"], + group_id=sparc_group["gid"], new_user_id=logged_user["id"], ) @@ -101,7 +101,7 @@ async def standard_groups_owner( await add_user_in_group( app=client.app, user_id=owner_user["id"], - gid=team_black_group["gid"], + group_id=team_black_group["gid"], new_user_email=logged_user["email"], ) diff --git a/services/web/server/src/simcore_service_webserver/garbage_collector/_core_utils.py b/services/web/server/src/simcore_service_webserver/garbage_collector/_core_utils.py index 528fed2e3c5..53750a3c27d 100644 --- a/services/web/server/src/simcore_service_webserver/garbage_collector/_core_utils.py +++ b/services/web/server/src/simcore_service_webserver/garbage_collector/_core_utils.py @@ -78,7 +78,7 @@ async def get_new_project_owner_gid( standard_groups = {} # groups of users, multiple users can be part of this primary_groups = {} # each individual user has a unique primary group for other_gid in other_users_access_rights: - group: Group | None = await get_group_from_gid(app=app, gid=int(other_gid)) + group: Group | None = await get_group_from_gid(app=app, group_id=int(other_gid)) # only process for users and groups with write access right if group is None: diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py index 520f24c1748..68b6ad37969 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py @@ -21,8 +21,8 @@ # -async def get_group_from_gid(app: web.Application, gid: GroupID) -> Group | None: - group_db = await _groups_db.get_group_from_gid(app, gid=gid) +async def get_group_from_gid(app: web.Application, group_id: GroupID) -> Group | None: + group_db = await _groups_db.get_group_from_gid(app, group_id=group_id) if group_db: return Group.model_construct(**group_db.model_dump()) @@ -119,7 +119,7 @@ async def get_organization( raises GroupNotFoundError raises UserInsufficientRightsError """ - return await _groups_db.get_user_group(app, user_id=user_id, gid=group_id) + return await _groups_db.get_user_group(app, user_id=user_id, group_9d=group_id) async def update_organization( @@ -160,34 +160,37 @@ async def delete_organization( async def list_users_in_group( - app: web.Application, user_id: UserID, gid: GroupID + app: web.Application, user_id: UserID, group_id: GroupID ) -> list[GroupMember]: - return await _groups_db.list_users_in_group(app, user_id=user_id, group_id=gid) + return await _groups_db.list_users_in_group(app, user_id=user_id, group_id=group_id) async def get_user_in_group( app: web.Application, user_id: UserID, - gid: GroupID, + group_id: GroupID, the_user_id_in_group: UserID, ) -> GroupMember: return await _groups_db.get_user_in_group( - app, user_id=user_id, group_id=gid, the_user_id_in_group=the_user_id_in_group + app, + user_id=user_id, + group_id=group_id, + the_user_id_in_group=the_user_id_in_group, ) async def update_user_in_group( app: web.Application, user_id: UserID, - gid: GroupID, + group_id: GroupID, the_user_id_in_group: UserID, access_rights: AccessRightsDict, ) -> GroupMember: return await _groups_db.update_user_in_group( app, user_id=user_id, - gid=gid, + group_id=group_id, the_user_id_in_group=the_user_id_in_group, access_rights=access_rights, ) @@ -196,11 +199,14 @@ async def update_user_in_group( async def delete_user_in_group( app: web.Application, user_id: UserID, - gid: GroupID, + group_id: GroupID, the_user_id_in_group: UserID, ) -> None: return await _groups_db.delete_user_from_group( - app, user_id=user_id, gid=gid, the_user_id_in_group=the_user_id_in_group + app, + user_id=user_id, + group_id=group_id, + the_user_id_in_group=the_user_id_in_group, ) @@ -233,7 +239,7 @@ async def auto_add_user_to_product_group( async def add_user_in_group( app: web.Application, user_id: UserID, - gid: GroupID, + group_id: GroupID, *, new_user_id: UserID | None = None, new_user_email: EmailStr | None = None, @@ -250,6 +256,7 @@ async def add_user_in_group( msg = "Invalid method call, missing user id or user email" raise GroupsError(msg=msg) + # FIXME: check privacy if new_user_email: user = await _groups_db.get_user_from_email(app, email=new_user_email) new_user_id = user.id @@ -261,7 +268,7 @@ async def add_user_in_group( return await _groups_db.add_new_user_in_group( app, user_id=user_id, - gid=gid, + group_id=group_id, new_user_id=new_user_id, access_rights=access_rights, ) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py index 797c4fdd47b..330df3fa0c9 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py @@ -121,6 +121,8 @@ async def get_user_from_email( UserNotFoundError """ + # FIXME: check privacy + async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: result = await conn.stream(sa.select(users).where(users.c.email == email)) user = await result.fetchone() @@ -135,10 +137,13 @@ async def get_user_from_email( async def get_group_from_gid( - app: web.Application, connection: AsyncConnection | None = None, *, gid: GroupID + app: web.Application, + connection: AsyncConnection | None = None, + *, + 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 == gid)) + row = await conn.stream(groups.select().where(groups.c.gid == group_id)) result = await row.first() if result: return Group.model_validate(result) @@ -219,7 +224,7 @@ async def get_user_group( connection: AsyncConnection | None = None, *, user_id: UserID, - gid: GroupID, + group_9d: GroupID, ) -> tuple[Group, AccessRightsDict]: """ Gets group gid if user associated to it and has read access @@ -229,9 +234,9 @@ 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=gid + conn, user_id=user_id, gid=group_9d ) - _check_group_permissions(row, user_id, gid, "read") + _check_group_permissions(row, user_id, group_9d, "read") group, access_rights = _to_group_info_tuple(row) return group, access_rights @@ -463,7 +468,7 @@ async def update_user_in_group( connection: AsyncConnection | None = None, *, user_id: UserID, - gid: GroupID, + group_id: GroupID, the_user_id_in_group: UserID, access_rights: AccessRightsDict, ) -> GroupMember: @@ -475,13 +480,16 @@ async def update_user_in_group( # first check if the group exists group = await _get_group_and_access_rights_or_raise( - conn, user_id=user_id, gid=gid + conn, user_id=user_id, gid=group_id ) - _check_group_permissions(group, user_id, gid, "write") + _check_group_permissions(group, user_id, group_id, "write") # now check the user exists the_user = await _get_user_in_group( - conn, caller_user_id=user_id, group_id=gid, user_id=the_user_id_in_group + conn, + caller_user_id=user_id, + group_id=group_id, + user_id=the_user_id_in_group, ) # modify the user access rights @@ -493,7 +501,7 @@ async def update_user_in_group( .where( and_( user_to_groups.c.uid == the_user_id_in_group, - user_to_groups.c.gid == gid, + user_to_groups.c.gid == group_id, ) ) ) @@ -507,19 +515,22 @@ async def delete_user_from_group( connection: AsyncConnection | None = None, *, user_id: UserID, - gid: GroupID, + 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=gid + conn, user_id=user_id, gid=group_id ) - _check_group_permissions(group, user_id, gid, "write") + _check_group_permissions(group, user_id, group_id, "write") # check the user exists await _get_user_in_group( - conn, caller_user_id=user_id, group_id=gid, user_id=the_user_id_in_group + conn, + caller_user_id=user_id, + group_id=group_id, + user_id=the_user_id_in_group, ) # delete him/her @@ -528,7 +539,7 @@ async def delete_user_from_group( user_to_groups.delete().where( and_( user_to_groups.c.uid == the_user_id_in_group, - user_to_groups.c.gid == gid, + user_to_groups.c.gid == group_id, ) ) ) @@ -562,7 +573,7 @@ async def add_new_user_in_group( connection: AsyncConnection | None = None, *, user_id: UserID, - gid: GroupID, + group_id: GroupID, new_user_id: UserID, access_rights: AccessRightsDict | None = None, ) -> None: @@ -572,9 +583,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=gid + conn, user_id=user_id, gid=group_id ) - _check_group_permissions(group, user_id, gid, "write") + _check_group_permissions(group, user_id, group_id, "write") # now check the new user exists users_count = await conn.scalar( @@ -582,7 +593,7 @@ async def add_new_user_in_group( ) if not users_count: assert new_user_id is not None # nosec - raise UserInGroupNotFoundError(uid=new_user_id, gid=gid) + raise UserInGroupNotFoundError(uid=new_user_id, gid=group_id) # add the new user to the group now user_access_rights = _DEFAULT_GROUP_READ_ACCESS_RIGHTS @@ -598,7 +609,10 @@ async def add_new_user_in_group( ) except UniqueViolation as exc: raise UserAlreadyInGroupError( - uid=new_user_id, gid=gid, user_id=user_id, access_rights=access_rights + uid=new_user_id, + gid=group_id, + user_id=user_id, + access_rights=access_rights, ) from exc diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py index d4d2b3c1a03..7fc88bfe484 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py @@ -227,7 +227,7 @@ async def update_group_user(request: web.Request): user = await _groups_api.update_user_in_group( request.app, user_id=req_ctx.user_id, - gid=path_params.gid, + group_id=path_params.gid, the_user_id_in_group=path_params.uid, access_rights=update.access_rights.model_dump(mode="json"), # type: ignore[arg-type] ) diff --git a/services/web/server/src/simcore_service_webserver/projects/_nodes_handlers.py b/services/web/server/src/simcore_service_webserver/projects/_nodes_handlers.py index 4b445cb8379..d5978f794d2 100644 --- a/services/web/server/src/simcore_service_webserver/projects/_nodes_handlers.py +++ b/services/web/server/src/simcore_service_webserver/projects/_nodes_handlers.py @@ -559,7 +559,7 @@ async def get_project_services_access_for_gid( # Get the group from the provided group ID _sharing_with_group: Group | None = await get_group_from_gid( - app=request.app, gid=query_params.for_gid + app=request.app, group_id=query_params.for_gid ) # Check if the group exists From 604e6a5b304f9150a5076bf77bf47ded93a95e10 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 9 Dec 2024 21:27:46 +0100 Subject: [PATCH 56/84] hid email --- .../groups/_groups_api.py | 5 +- .../groups/_groups_db.py | 51 ++++++++++++------- 2 files changed, 36 insertions(+), 20 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py index 68b6ad37969..c636a91d66d 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py @@ -256,9 +256,10 @@ async def add_user_in_group( msg = "Invalid method call, missing user id or user email" raise GroupsError(msg=msg) - # FIXME: check privacy if new_user_email: - user = await _groups_db.get_user_from_email(app, email=new_user_email) + user = await _groups_db.get_user_from_email( + app, email=new_user_email, caller_user_id=user_id + ) new_user_id = user.id if not new_user_id: diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py index 330df3fa0c9..a4ef41e2369 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py @@ -113,24 +113,6 @@ async def _get_group_and_access_rights_or_raise( return row -async def get_user_from_email( - app: web.Application, connection: AsyncConnection | None = None, *, email: str -) -> Row: - """ - Raises: - UserNotFoundError - - """ - # FIXME: check privacy - - async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: - result = await conn.stream(sa.select(users).where(users.c.email == email)) - user = await result.fetchone() - if not user: - raise UserNotFoundError(email=email) - return user - - # # GROUPS # @@ -356,6 +338,39 @@ async def delete_user_group( ) +# +# USERS +# + + +async def get_user_from_email( + app: web.Application, + connection: AsyncConnection | None = None, + *, + caller_user_id: UserID, + email: str, +) -> Row: + """ + Raises: + UserNotFoundError: if not found or privacy hides email + + """ + async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: + 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) + ) + ) + ) + user = await result.fetchone() + if not user: + raise UserNotFoundError(email=email) + return user + + # # GROUP MEMBERS - CRUD # From 6d8c770ca41c854b53d1300d3d2e6270d390733e Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 9 Dec 2024 22:10:19 +0100 Subject: [PATCH 57/84] hide email --- .../groups/_groups_db.py | 2 +- .../01/groups/test_groups_handlers_users.py | 71 ++++++++++++------- 2 files changed, 45 insertions(+), 28 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py index a4ef41e2369..40e1417a476 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py @@ -361,7 +361,7 @@ async def get_user_from_email( (users.c.email == email) & ( users.c.privacy_hide_email.is_(False) - | (users.c.id != caller_user_id) + | (users.c.id == caller_user_id) ) ) ) diff --git a/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py index 05d5dc396c7..8addddb05eb 100644 --- a/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py +++ b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py @@ -57,7 +57,7 @@ def _assert__group_user( assert int(user.gid) == expected_user.get("primary_gid") # private profile - is_private = group_owner_id != actual_user["id"] + is_private = int(group_owner_id) != int(actual_user["id"]) assert "first_name" in actual_user assert actual_user["first_name"] == ( None if is_private else expected_user.get("first_name") @@ -151,35 +151,54 @@ async def test_add_remove_users_from_group( async with AsyncExitStack() as users_stack: for i in range(num_new_users): - created_users_list.append( - await users_stack.enter_async_context(NewUser(app=client.app)) - ) - # add the user once per email once per id to test both - params = ( - {"uid": created_users_list[i]["id"]} - if i % 2 == 0 - else {"email": created_users_list[i]["email"]} + is_private = i % 2 == 0 + created_users_list.append( + await users_stack.enter_async_context( + NewUser( + app=client.app, user_data={"privacy_hide_email": is_private} + ) + ) ) - resp = await client.post(f"{add_group_user_url}", json=params) - data, error = await assert_status(resp, expected.no_content) + created_users_list[i]["is_private"] = is_private + user_id = created_users_list[i]["id"] + user_email = created_users_list[i]["email"] + + if is_private: + # only if privacy allows + resp = await client.post( + f"{add_group_user_url}", json={"email": user_email} + ) + data, error = await assert_status(resp, expected.not_found) + + # always allowed + resp = await client.post(f"{add_group_user_url}", json={"uid": user_id}) + await assert_status(resp, expected.no_content) + else: + # both work + resp = await client.post( + f"{add_group_user_url}", json={"email": user_email} + ) + await assert_status(resp, expected.no_content) - get_group_user_url = client.app.router["get_group_user"].url_for( + # GET + url = client.app.router["get_group_user"].url_for( gid=f"{assigned_group['gid']}", uid=f"{created_users_list[i]['id']}" ) assert ( - f"{get_group_user_url}" + f"{url}" == f"/{API_VTAG}/groups/{assigned_group['gid']}/users/{created_users_list[i]['id']}" ) - resp = await client.get(f"{get_group_user_url}") + resp = await client.get(f"{url}") data, error = await assert_status(resp, expected.ok) if not error: _assert__group_user( created_users_list[i], _DEFAULT_GROUP_READ_ACCESS_RIGHTS, data, - group_owner_id=the_owner["id"], + group_owner_id=the_owner["id"] if is_private else user_id, ) + # check list is correct resp = await client.get(f"{get_group_users_url}") data, error = await assert_status(resp, expected.ok) @@ -190,13 +209,9 @@ async def test_add_remove_users_from_group( all_created_users = [*created_users_list, logged_user] assert len(list_of_users) == len(all_created_users) for actual_user in list_of_users: - - expected_users_list = list( - filter( - lambda x, ac=actual_user: x["id"] == ac["id"], - all_created_users, - ) - ) + expected_users_list = [ + usr for usr in all_created_users if usr["id"] == actual_user["id"] + ] assert len(expected_users_list) == 1 expected_user = expected_users_list[0] @@ -208,7 +223,9 @@ async def test_add_remove_users_from_group( expected_user, expected_access_rigths, actual_user, - group_owner_id=the_owner["id"], + group_owner_id=the_owner["id"] + if actual_user.get("is_private", False) + else actual_user["id"], ) all_created_users.remove(expected_users_list[0]) @@ -234,10 +251,10 @@ async def test_add_remove_users_from_group( group_owner_id=the_owner["id"], ) # check it is there - get_group_user_url = client.app.router["get_group_user"].url_for( + url = client.app.router["get_group_user"].url_for( gid=f"{assigned_group['gid']}", uid=f"{created_users_list[i]['id']}" ) - resp = await client.get(f"{get_group_user_url}") + resp = await client.get(f"{url}") data, error = await assert_status(resp, expected.ok) if not error: _assert__group_user( @@ -257,10 +274,10 @@ async def test_add_remove_users_from_group( data, error = await assert_status(resp, expected.not_found) # check it is not there anymore - get_group_user_url = client.app.router["get_group_user"].url_for( + url = client.app.router["get_group_user"].url_for( gid=f"{assigned_group['gid']}", uid=f"{created_users_list[i]['id']}" ) - resp = await client.get(f"{get_group_user_url}") + resp = await client.get(f"{url}") data, error = await assert_status(resp, expected.not_found) From 5380899257fda7182f3dc40806cc167244cb9712 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 9 Dec 2024 22:31:38 +0100 Subject: [PATCH 58/84] updates OAS --- .../api_schemas_webserver/groups.py | 14 ++++++++-- .../api/v0/openapi.yaml | 27 ++++++++++++------- 2 files changed, 30 insertions(+), 11 deletions(-) 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 eb5d80d0f6c..0d573616d7f 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 @@ -286,14 +286,24 @@ class GroupUserAdd(InputSchema): """ uid: UserID | None = None - email: LowerCaseEmailStr | None = None + email: Annotated[ + LowerCaseEmailStr | None, + Field( + description="Accessible only if the user has opted to share their email in privacy settings" + ), + ] = None _check_uid_or_email = model_validator(mode="after")( create__check_only_one_is_set__root_validator(["uid", "email"]) ) model_config = ConfigDict( - json_schema_extra={"examples": [{"uid": 42}, {"email": "foo@email.com"}]} + json_schema_extra={ + "examples": [ + {"uid": 42}, + {"email": "foo@email.com"}, + ] + } ) 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 9da262c366e..4fffc2fef54 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 @@ -9917,6 +9917,8 @@ components: format: email - type: 'null' title: Email + description: Accessible only if the user has opted to share their email + in privacy settings type: object title: GroupUserAdd description: "Identify the user with either `email` or `uid` \u2014 only one." @@ -9928,41 +9930,48 @@ components: - type: 'null' title: Id description: the user id + userName: + type: string + maxLength: 100 + minLength: 1 + title: Username + gid: + anyOf: + - type: string + - type: 'null' + title: Gid + description: the user primary gid login: anyOf: - type: string format: email - type: 'null' title: Login - description: the user login email + description: the user's email, if privacy settings allows first_name: anyOf: - type: string - type: 'null' title: First Name - description: the user first name + description: If privacy settings allows last_name: anyOf: - type: string - type: 'null' title: Last Name - description: the user last name + description: If privacy settings allows gravatar_id: anyOf: - type: string - type: 'null' title: Gravatar Id description: the user gravatar id hash - gid: - anyOf: - - type: string - - type: 'null' - title: Gid - description: the user primary gid + deprecated: true accessRights: $ref: '#/components/schemas/GroupAccessRights' type: object required: + - userName - accessRights title: GroupUserGet example: From ebbd5c1183d3e14ff67e3de36136258134b2a1b2 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Mon, 9 Dec 2024 22:32:24 +0100 Subject: [PATCH 59/84] =?UTF-8?q?services/webserver=20api=20version:=200.4?= =?UTF-8?q?8.0=20=E2=86=92=200.49.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/web/server/VERSION | 2 +- services/web/server/setup.cfg | 2 +- .../server/src/simcore_service_webserver/api/v0/openapi.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/services/web/server/VERSION b/services/web/server/VERSION index a758a09aae5..5c4503b7043 100644 --- a/services/web/server/VERSION +++ b/services/web/server/VERSION @@ -1 +1 @@ -0.48.0 +0.49.0 diff --git a/services/web/server/setup.cfg b/services/web/server/setup.cfg index 0b6157ef959..0e40e2535ee 100644 --- a/services/web/server/setup.cfg +++ b/services/web/server/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.48.0 +current_version = 0.49.0 commit = True message = services/webserver api version: {current_version} → {new_version} tag = False 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 4fffc2fef54..5a002550eb7 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 @@ -2,7 +2,7 @@ openapi: 3.1.0 info: title: simcore-service-webserver description: Main service with an interface (http-API & websockets) to the web front-end - version: 0.48.0 + version: 0.49.0 servers: - url: '' description: webserver From 1e0e3768e50d4ede96f96e942d9635956a3ce381 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 10 Dec 2024 00:20:09 +0100 Subject: [PATCH 60/84] tests --- .../src/models_library/api_schemas_webserver/groups.py | 1 + .../web/server/tests/integration/01/test_garbage_collection.py | 2 +- .../unit/with_dbs/01/groups/test_groups_handlers_users.py | 3 +-- 3 files changed, 3 insertions(+), 3 deletions(-) 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 0d573616d7f..32a9e36a48a 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 @@ -251,6 +251,7 @@ class GroupUserGet(BaseModel): json_schema_extra={ "example": { "id": "1", + "userName": "mrmith", "login": "mr.smith@matrix.com", "first_name": "Mr", "last_name": "Smith", diff --git a/services/web/server/tests/integration/01/test_garbage_collection.py b/services/web/server/tests/integration/01/test_garbage_collection.py index edff94f51f8..10f6db8fae4 100644 --- a/services/web/server/tests/integration/01/test_garbage_collection.py +++ b/services/web/server/tests/integration/01/test_garbage_collection.py @@ -286,7 +286,7 @@ async def get_group(client: TestClient, user: dict): app=client.app, user_id=user["id"], create=OrganizationCreate.model_validate( - {"label": uuid4(), "description": uuid4(), "thumbnail": None} + {"name": uuid4(), "description": uuid4(), "thumbnail": None} ), ) diff --git a/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py index 8addddb05eb..6805a438372 100644 --- a/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py +++ b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py @@ -467,8 +467,7 @@ async def group_where_logged_user_is_the_owner( user_id=logged_user["id"], create=OrganizationCreate.model_validate( { - "gid": "6543", - "label": f"this is user {logged_user['id']} group", + "name": f"this is user {logged_user['id']} group", "description": f"user {logged_user['email']} is the owner of that one", "thumbnail": None, } From f0d9cbbf65d4f195dd132dcda6b74089a9e93220 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 10 Dec 2024 00:23:17 +0100 Subject: [PATCH 61/84] tests --- .../unit/with_dbs/01/groups/test_groups_handlers_users.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py index 6805a438372..2ed66232bba 100644 --- a/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py +++ b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py @@ -324,8 +324,7 @@ async def test_group_access_rights( == f"/{API_VTAG}/groups/{assigned_group['gid']}/users" ) for i, user in enumerate(users): - params = {"uid": user["id"]} if i % 2 == 0 else {"email": user["email"]} - resp = await client.post(f"{add_group_user_url}", json=params) + resp = await client.post(f"{add_group_user_url}", json={"uid": user["id"]}) data, error = await assert_status(resp, expected.no_content) # 3. user 1 shall be a manager From 8bb03e6456862c31da13b35bf0e380678f3b00e8 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 10 Dec 2024 11:00:53 +0100 Subject: [PATCH 62/84] adds tests for conversions --- .../api_schemas_webserver/groups.py | 1 + .../src/models_library/groups.py | 10 +-- .../simcore_webserver_groups_fixtures.py | 5 +- .../tests/unit/isolated/test_groups_models.py | 63 ++++++++++++++++++- 4 files changed, 70 insertions(+), 9 deletions(-) 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 32a9e36a48a..476ab4aeb57 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 @@ -86,6 +86,7 @@ def from_model(cls, group: Group, access_rights: AccessRightsDict) -> Self: "thumbnail", "inclusion_rules", }, + exclude={"access_rights"}, exclude_unset=True, by_alias=False, ), diff --git a/packages/models-library/src/models_library/groups.py b/packages/models-library/src/models_library/groups.py index 89c9b854d6e..e1ea3accc98 100644 --- a/packages/models-library/src/models_library/groups.py +++ b/packages/models-library/src/models_library/groups.py @@ -80,14 +80,14 @@ class GroupMember(BaseModel): class OrganizationCreate(BaseModel): name: str - description: str | None - thumbnail: str | None + description: str | None = None + thumbnail: str | None = None class OrganizationUpdate(BaseModel): - name: str | None - description: str | None - thumbnail: str | None + name: str | None = None + description: str | None = None + thumbnail: str | None = None class GroupAtDB(Group): diff --git a/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py b/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py index 94272aa3030..f7d35d80f9c 100644 --- a/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py +++ b/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py @@ -71,7 +71,7 @@ async def standard_groups_owner( app=client.app, user_id=owner_user["id"], new_group={ - "label": "SPARC", + "name": "SPARC", "description": "Stimulating Peripheral Activity to Relieve Conditions", "thumbnail": "https://commonfund.nih.gov/sites/default/files/sparc-image-homepage500px.png", "inclusionRules": {"email": r"@(sparc)+\.(io|com)$"}, @@ -81,8 +81,7 @@ async def standard_groups_owner( app=client.app, user_id=owner_user["id"], new_group={ - "gid": "5", # this will be replaced - "label": "team Black", + "name": "team Black", "description": "THE incredible black team", "thumbnail": None, "inclusionRules": {"email": r"@(black)+\.(io|com)$"}, diff --git a/services/web/server/tests/unit/isolated/test_groups_models.py b/services/web/server/tests/unit/isolated/test_groups_models.py index d51b467c015..1a06552e5c1 100644 --- a/services/web/server/tests/unit/isolated/test_groups_models.py +++ b/services/web/server/tests/unit/isolated/test_groups_models.py @@ -1,6 +1,21 @@ import models_library.groups import simcore_postgres_database.models.groups -from models_library.api_schemas_webserver.groups import GroupGet +from faker import Faker +from models_library.api_schemas_webserver._base import OutputSchema +from models_library.api_schemas_webserver.groups import ( + GroupCreate, + GroupGet, + GroupUpdate, + GroupUserGet, +) +from models_library.groups import ( + AccessRightsDict, + Group, + GroupMember, + GroupTypeInModel, + OrganizationCreate, + OrganizationUpdate, +) from models_library.utils.enums import enum_to_dict @@ -39,3 +54,49 @@ def test_sanitize_legacy_data(): assert users_group_2.thumbnail is None assert users_group_1 == users_group_2 + + +def test_output_schemas_from_models(faker: Faker): + # output : schema <- model + assert issubclass(GroupGet, OutputSchema) + domain_model = Group( + gid=1, + name=faker.word(), + description=faker.sentence(), + group_type=GroupTypeInModel.STANDARD, + thumbnail=None, + ) + output_schema = GroupGet.from_model( + domain_model, + access_rights=AccessRightsDict(read=True, write=False, delete=False), + ) + assert output_schema.label == domain_model.name + + # output : schema <- model + domain_model = GroupMember( + id=12, + name=faker.user_name(), + email=None, + first_name=None, + last_name=None, + primary_gid=13, + access_rights=AccessRightsDict(read=True, write=False, delete=False), + ) + output_schema = GroupUserGet.from_model(user=domain_model) + assert output_schema.user_name == domain_model.name + + +def test_input_schemas_to_models(faker: Faker): + # input : scheam -> model + input_schema = GroupCreate( + label=faker.word(), description=faker.sentence(), thumbnail=faker.url() + ) + domain_model = input_schema.to_model() + assert isinstance(domain_model, OrganizationCreate) + assert domain_model.name == input_schema.label + + # input : scheam -> model + input_schema = GroupUpdate(label=faker.word()) + domain_model = input_schema.to_model() + assert isinstance(domain_model, OrganizationUpdate) + assert domain_model.name == input_schema.label From 9d381065e4cb47a08b3a208215343390be5506a7 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 10 Dec 2024 11:06:07 +0100 Subject: [PATCH 63/84] tests --- .../src/pytest_simcore/simcore_webserver_groups_fixtures.py | 2 +- .../src/simcore_service_webserver/groups/_groups_handlers.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py b/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py index f7d35d80f9c..1a2fcd10f05 100644 --- a/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py +++ b/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py @@ -101,7 +101,7 @@ async def standard_groups_owner( app=client.app, user_id=owner_user["id"], group_id=team_black_group["gid"], - new_user_email=logged_user["email"], + new_user_id=logged_user["id"], ) yield owner_user diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py index 7fc88bfe484..235925114d9 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py @@ -150,6 +150,7 @@ async def delete_group(request: web.Request): await _groups_api.delete_organization( request.app, user_id=req_ctx.user_id, group_id=path_params.gid ) + return web.json_response(status=status.HTTP_204_NO_CONTENT) @@ -195,6 +196,7 @@ async def add_group_user(request: web.Request): new_user_id=added.uid, new_user_email=added.email, ) + return web.json_response(status=status.HTTP_204_NO_CONTENT) @@ -212,6 +214,7 @@ async def get_group_user(request: web.Request): user = await _groups_api.get_user_in_group( request.app, req_ctx.user_id, path_params.gid, path_params.uid ) + return envelope_json_response(GroupUserGet.from_model(user)) @@ -231,6 +234,7 @@ async def update_group_user(request: web.Request): the_user_id_in_group=path_params.uid, access_rights=update.access_rights.model_dump(mode="json"), # type: ignore[arg-type] ) + return envelope_json_response(GroupUserGet.from_model(user)) @@ -244,4 +248,5 @@ async def delete_group_user(request: web.Request): await _groups_api.delete_user_in_group( request.app, req_ctx.user_id, path_params.gid, path_params.uid ) + return web.json_response(status=status.HTTP_204_NO_CONTENT) From 6630877059234d1141af674e403da921703f89db Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 10 Dec 2024 11:27:44 +0100 Subject: [PATCH 64/84] oas --- .../web/server/src/simcore_service_webserver/api/v0/openapi.yaml | 1 + 1 file changed, 1 insertion(+) 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 5a002550eb7..ad0fc205f43 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 @@ -9985,6 +9985,7 @@ components: id: '1' last_name: Smith login: mr.smith@matrix.com + userName: mrmith GroupUserUpdate: properties: accessRights: From 92a5cce2bd92f8a7e5063d05208561b083cc9847 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 10 Dec 2024 11:44:52 +0100 Subject: [PATCH 65/84] fixes tsets --- .../01/groups/test_groups_handlers_users.py | 40 ++++++++++++------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py index 2ed66232bba..d814182c4ef 100644 --- a/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py +++ b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py @@ -236,11 +236,15 @@ async def test_add_remove_users_from_group( "delete": False, } for i in range(num_new_users): - update_group_user_url = client.app.router["update_group_user"].url_for( - gid=f"{assigned_group['gid']}", uid=f"{created_users_list[i]['id']}" + group_id = assigned_group["gid"] + user_id = created_users_list[i]["id"] + is_private = created_users_list[i].get("is_private", False) + + url = client.app.router["update_group_user"].url_for( + gid=f"{group_id}", uid=f"{user_id}" ) resp = await client.patch( - f"{update_group_user_url}", json={"accessRights": MANAGER_ACCESS_RIGHTS} + f"{url}", json={"accessRights": MANAGER_ACCESS_RIGHTS} ) data, error = await assert_status(resp, expected.ok) if not error: @@ -248,11 +252,12 @@ async def test_add_remove_users_from_group( created_users_list[i], MANAGER_ACCESS_RIGHTS, data, - group_owner_id=the_owner["id"], + group_owner_id=the_owner["id"] if is_private else user_id, ) + # check it is there url = client.app.router["get_group_user"].url_for( - gid=f"{assigned_group['gid']}", uid=f"{created_users_list[i]['id']}" + gid=f"{group_id}", uid=f"{user_id}" ) resp = await client.get(f"{url}") data, error = await assert_status(resp, expected.ok) @@ -261,21 +266,23 @@ async def test_add_remove_users_from_group( created_users_list[i], MANAGER_ACCESS_RIGHTS, data, - group_owner_id=the_owner["id"], + group_owner_id=the_owner["id"] if is_private else user_id, ) + # remove the user from the group - delete_group_user_url = client.app.router["delete_group_user"].url_for( - gid=f"{assigned_group['gid']}", uid=f"{created_users_list[i]['id']}" + url = client.app.router["delete_group_user"].url_for( + gid=f"{group_id}", uid=f"{user_id}" ) - resp = await client.delete(f"{delete_group_user_url}") + resp = await client.delete(f"{url}") data, error = await assert_status(resp, expected.no_content) + # do it again to check it is not found anymore - resp = await client.delete(f"{delete_group_user_url}") + resp = await client.delete(f"{url}") data, error = await assert_status(resp, expected.not_found) # check it is not there anymore url = client.app.router["get_group_user"].url_for( - gid=f"{assigned_group['gid']}", uid=f"{created_users_list[i]['id']}" + gid=f"{group_id}", uid=f"{user_id}" ) resp = await client.get(f"{url}") data, error = await assert_status(resp, expected.not_found) @@ -435,7 +442,11 @@ async def test_add_user_gets_added_to_group( user = await users_stack.enter_async_context( LoggedUser( client, - user_data={"role": user_role.name, "email": email}, + user_data={ + "role": user_role.name, + "email": email, + "privacy_hide_email": False, + }, check_if_succeeds=user_role != UserRole.ANONYMOUS, ) ) @@ -495,15 +506,16 @@ async def test_adding_user_to_group_with_upper_case_email( # adding a user to group with the email in capital letters # Tests 🐛 https://github.com/ITISFoundation/osparc-issues/issues/812 async with NewUser( - app=client.app, + app=client.app, user_data={"privacy_hide_email": False} ) as registered_user: assert registered_user["email"] # <--- this email is lower case response = await client.post( f"{url}", json={ + # <--- email in upper case "email": registered_user["email"].upper() - }, # <--- email in upper case + }, ) data, error = await assert_status(response, status.HTTP_204_NO_CONTENT) From e4efff76c44b58ebabbbeae454852f7ab339c299 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 10 Dec 2024 13:55:18 +0100 Subject: [PATCH 66/84] cleanup tests --- .../groups/_groups_db.py | 2 +- .../01/groups/test_groups_handlers_users.py | 82 ++++++++----------- 2 files changed, 36 insertions(+), 48 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py index 40e1417a476..db7981b8350 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py @@ -520,7 +520,7 @@ async def update_user_in_group( ) ) ) - user = dict(the_user) + user = the_user._asdict() user.update(**new_db_values) return GroupMember.model_validate(user) diff --git a/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py index d814182c4ef..f624a765cbc 100644 --- a/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py +++ b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py @@ -118,14 +118,12 @@ async def test_add_remove_users_from_group( # we get all rights on the group since we are the creator assert assigned_group["accessRights"] == _DEFAULT_GROUP_OWNER_ACCESS_RIGHTS + group_id = assigned_group["gid"] + # check that our user is in the group of users - get_group_users_url = client.app.router["get_all_group_users"].url_for( - gid=f"{assigned_group['gid']}" - ) - assert ( - f"{get_group_users_url}" == f"/{API_VTAG}/groups/{assigned_group['gid']}/users" - ) - resp = await client.get(f"{get_group_users_url}") + url = client.app.router["get_all_group_users"].url_for(gid=f"{group_id}") + assert f"{url}" == f"/{API_VTAG}/groups/{group_id}/users" + resp = await client.get(f"{url}") data, error = await assert_status(resp, expected.ok) if not error: @@ -140,15 +138,8 @@ async def test_add_remove_users_from_group( ) # create a random number of users and put them in the group - add_group_user_url = client.app.router["add_group_user"].url_for( - gid=f"{assigned_group['gid']}" - ) - assert ( - f"{add_group_user_url}" == f"/{API_VTAG}/groups/{assigned_group['gid']}/users" - ) num_new_users = faker.random_int(1, 10) created_users_list = [] - async with AsyncExitStack() as users_stack: for i in range(num_new_users): @@ -164,31 +155,27 @@ async def test_add_remove_users_from_group( user_id = created_users_list[i]["id"] user_email = created_users_list[i]["email"] + # ADD + url = client.app.router["add_group_user"].url_for(gid=f"{group_id}") + assert f"{url}" == f"/{API_VTAG}/groups/{group_id}/users" if is_private: # only if privacy allows - resp = await client.post( - f"{add_group_user_url}", json={"email": user_email} - ) + resp = await client.post(f"{url}", json={"email": user_email}) data, error = await assert_status(resp, expected.not_found) # always allowed - resp = await client.post(f"{add_group_user_url}", json={"uid": user_id}) + resp = await client.post(f"{url}", json={"uid": user_id}) await assert_status(resp, expected.no_content) else: # both work - resp = await client.post( - f"{add_group_user_url}", json={"email": user_email} - ) + resp = await client.post(f"{url}", json={"email": user_email}) await assert_status(resp, expected.no_content) # GET url = client.app.router["get_group_user"].url_for( - gid=f"{assigned_group['gid']}", uid=f"{created_users_list[i]['id']}" - ) - assert ( - f"{url}" - == f"/{API_VTAG}/groups/{assigned_group['gid']}/users/{created_users_list[i]['id']}" + gid=f"{group_id}", uid=f"{user_id}" ) + assert f"{url}" == f"/{API_VTAG}/groups/{group_id}/users/{user_id}" resp = await client.get(f"{url}") data, error = await assert_status(resp, expected.ok) if not error: @@ -199,37 +186,37 @@ async def test_add_remove_users_from_group( group_owner_id=the_owner["id"] if is_private else user_id, ) - # check list is correct - resp = await client.get(f"{get_group_users_url}") + # LIST: check list is correct + url = client.app.router["get_all_group_users"].url_for(gid=f"{group_id}") + resp = await client.get(f"{url}") data, error = await assert_status(resp, expected.ok) if not error: list_of_users = data # now we should have all the users in the group + the owner all_created_users = [*created_users_list, logged_user] - assert len(list_of_users) == len(all_created_users) - for actual_user in list_of_users: - expected_users_list = [ - usr for usr in all_created_users if usr["id"] == actual_user["id"] - ] - assert len(expected_users_list) == 1 - expected_user = expected_users_list[0] - expected_access_rigths = _DEFAULT_GROUP_READ_ACCESS_RIGHTS - if actual_user["id"] == logged_user["id"]: - expected_access_rigths = _DEFAULT_GROUP_OWNER_ACCESS_RIGHTS + assert len(list_of_users) == len(all_created_users) + for user in list_of_users: + expected_user: UserInfoDict = next( + u for u in all_created_users if int(u["id"]) == int(user["id"]) + ) + expected_access_rigths = ( + _DEFAULT_GROUP_OWNER_ACCESS_RIGHTS + if int(user["id"]) == int(logged_user["id"]) + else _DEFAULT_GROUP_READ_ACCESS_RIGHTS + ) _assert__group_user( expected_user, expected_access_rigths, - actual_user, + user, group_owner_id=the_owner["id"] - if actual_user.get("is_private", False) - else actual_user["id"], + if expected_user.get("is_private", False) + else user["id"], ) - all_created_users.remove(expected_users_list[0]) - # modify the user and remove them from the group + # PATCH the user and REMOVE them from the group MANAGER_ACCESS_RIGHTS: AccessRightsDict = { "read": True, "write": True, @@ -240,6 +227,7 @@ async def test_add_remove_users_from_group( user_id = created_users_list[i]["id"] is_private = created_users_list[i].get("is_private", False) + # PATCH access-rights url = client.app.router["update_group_user"].url_for( gid=f"{group_id}", uid=f"{user_id}" ) @@ -255,7 +243,7 @@ async def test_add_remove_users_from_group( group_owner_id=the_owner["id"] if is_private else user_id, ) - # check it is there + # GET: check it is there url = client.app.router["get_group_user"].url_for( gid=f"{group_id}", uid=f"{user_id}" ) @@ -269,18 +257,18 @@ async def test_add_remove_users_from_group( group_owner_id=the_owner["id"] if is_private else user_id, ) - # remove the user from the group + # REMOVE the user from the group url = client.app.router["delete_group_user"].url_for( gid=f"{group_id}", uid=f"{user_id}" ) resp = await client.delete(f"{url}") data, error = await assert_status(resp, expected.no_content) - # do it again to check it is not found anymore + # REMOVE: do it again to check it is not found anymore resp = await client.delete(f"{url}") data, error = await assert_status(resp, expected.not_found) - # check it is not there anymore + # GET check it is not there anymore url = client.app.router["get_group_user"].url_for( gid=f"{group_id}", uid=f"{user_id}" ) From d260646e355d5bf33117cc9ee67c9d2db6e38d4e Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 10 Dec 2024 14:02:23 +0100 Subject: [PATCH 67/84] cleanup tests --- .../01/groups/test_groups_handlers_users.py | 78 +++++++++---------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py index f624a765cbc..71b5766661d 100644 --- a/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py +++ b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py @@ -301,7 +301,9 @@ async def test_group_access_rights( if not data: # role cannot create a group so stop here return + assigned_group = data + group_id = assigned_group["gid"] async with AsyncExitStack() as users_stack: # 1. have 2 users @@ -310,44 +312,42 @@ async def test_group_access_rights( for _ in range(2) ] - # 2. add the users to the group + # 2. ADD the users to the group add_group_user_url = client.app.router["add_group_user"].url_for( - gid=f"{assigned_group['gid']}" - ) - assert ( - f"{add_group_user_url}" - == f"/{API_VTAG}/groups/{assigned_group['gid']}/users" + gid=f"{group_id}" ) - for i, user in enumerate(users): + assert f"{add_group_user_url}" == f"/{API_VTAG}/groups/{group_id}/users" + for user in users: resp = await client.post(f"{add_group_user_url}", json={"uid": user["id"]}) - data, error = await assert_status(resp, expected.no_content) + await assert_status(resp, expected.no_content) - # 3. user 1 shall be a manager + # 3. PATCH: user 1 shall be a manager patch_group_user_url = client.app.router["update_group_user"].url_for( - gid=f"{assigned_group['gid']}", uid=f"{users[0]['id']}" + gid=f"{group_id}", uid=f"{users[0]['id']}" ) assert ( f"{patch_group_user_url}" - == f"/{API_VTAG}/groups/{assigned_group['gid']}/users/{users[0]['id']}" + == f"/{API_VTAG}/groups/{group_id}/users/{users[0]['id']}" ) params = {"accessRights": {"read": True, "write": True, "delete": False}} resp = await client.patch(f"{patch_group_user_url}", json=params) - data, error = await assert_status(resp, expected.ok) + await assert_status(resp, expected.ok) - # 4. user 2 shall be a member + # 4. PATCH user 2 shall be a member patch_group_user_url = client.app.router["update_group_user"].url_for( - gid=f"{assigned_group['gid']}", uid=f"{users[1]['id']}" + gid=f"{group_id}", uid=f"{users[1]['id']}" ) assert ( f"{patch_group_user_url}" - == f"/{API_VTAG}/groups/{assigned_group['gid']}/users/{users[1]['id']}" + == f"/{API_VTAG}/groups/{group_id}/users/{users[1]['id']}" ) - params = {"accessRights": {"read": True, "write": False, "delete": False}} - resp = await client.patch(f"{patch_group_user_url}", json=params) - data, error = await assert_status(resp, expected.ok) + resp = await client.patch( + f"{patch_group_user_url}", + json={"accessRights": {"read": True, "write": False, "delete": False}}, + ) + await assert_status(resp, expected.ok) - # let's login as user 1 - # login + # let's LOGIN as user 1 url = client.app.router["auth_login"].url_for() resp = await client.post( f"{url}", @@ -358,28 +358,28 @@ async def test_group_access_rights( ) await assert_status(resp, expected.ok) - # check as a manager I can remove user 2 + # check as a manager I can REMOVE user 2 delete_group_user_url = client.app.router["delete_group_user"].url_for( - gid=f"{assigned_group['gid']}", uid=f"{users[1]['id']}" + gid=f"{group_id}", uid=f"{users[1]['id']}" ) assert ( f"{delete_group_user_url}" - == f"/{API_VTAG}/groups/{assigned_group['gid']}/users/{users[1]['id']}" + == f"/{API_VTAG}/groups/{group_id}/users/{users[1]['id']}" ) resp = await client.delete(f"{delete_group_user_url}") - data, error = await assert_status(resp, expected.no_content) + await assert_status(resp, expected.no_content) - # as a manager I can add user 2 again + # as a manager I can ADD user 2 again resp = await client.post(f"{add_group_user_url}", json={"uid": users[1]["id"]}) - data, error = await assert_status(resp, expected.no_content) + await assert_status(resp, expected.no_content) - # as a manager I cannot delete the group - url = client.app.router["delete_group"].url_for(gid=f"{assigned_group['gid']}") + # as a manager I cannot DELETE the group + url = client.app.router["delete_group"].url_for(gid=f"{group_id}") resp = await client.delete(f"{url}") - data, error = await assert_status(resp, status.HTTP_403_FORBIDDEN) + await assert_status(resp, status.HTTP_403_FORBIDDEN) # now log in as user 2 - # login + # LOGIN url = client.app.router["auth_login"].url_for() resp = await client.post( f"{url}", @@ -390,25 +390,25 @@ async def test_group_access_rights( ) await assert_status(resp, expected.ok) - # as a member I cannot remove user 1 + # as a member I cannot REMOVE user 1 delete_group_user_url = client.app.router["delete_group_user"].url_for( - gid=f"{assigned_group['gid']}", uid=f"{users[0]['id']}" + gid=f"{group_id}", uid=f"{users[0]['id']}" ) assert ( f"{delete_group_user_url}" - == f"/{API_VTAG}/groups/{assigned_group['gid']}/users/{users[0]['id']}" + == f"/{API_VTAG}/groups/{group_id}/users/{users[0]['id']}" ) resp = await client.delete(f"{delete_group_user_url}") - data, error = await assert_status(resp, status.HTTP_403_FORBIDDEN) + await assert_status(resp, status.HTTP_403_FORBIDDEN) - # as a member I cannot add user 1 + # as a member I cannot ADD user 1 resp = await client.post(f"{add_group_user_url}", json={"uid": users[0]["id"]}) - data, error = await assert_status(resp, status.HTTP_403_FORBIDDEN) + await assert_status(resp, status.HTTP_403_FORBIDDEN) - # as a member I cannot delete the grouop - url = client.app.router["delete_group"].url_for(gid=f"{assigned_group['gid']}") + # as a member I cannot DELETE the grouop + url = client.app.router["delete_group"].url_for(gid=f"{group_id}") resp = await client.delete(f"{url}") - data, error = await assert_status(resp, status.HTTP_403_FORBIDDEN) + await assert_status(resp, status.HTTP_403_FORBIDDEN) @pytest.mark.parametrize(*standard_role_response()) From 299f24155bae628f11a28288a93aa1c982fd3c38 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 10 Dec 2024 14:35:16 +0100 Subject: [PATCH 68/84] @odeimaiz review: adds username --- api/specs/web-server/_groups.py | 2 +- .../api_schemas_webserver/groups.py | 3 ++- .../models_library/utils/common_validators.py | 15 +++++++------ .../api/v0/openapi.yaml | 10 ++++++++- .../groups/_groups_api.py | 11 +++++++--- .../groups/_groups_db.py | 18 +++++++++++---- .../groups/_groups_handlers.py | 3 ++- .../tests/unit/isolated/test_groups_models.py | 22 +++++++++++++++++++ 8 files changed, 66 insertions(+), 18 deletions(-) diff --git a/api/specs/web-server/_groups.py b/api/specs/web-server/_groups.py index 85357f2b8c7..7e02bfeb51c 100644 --- a/api/specs/web-server/_groups.py +++ b/api/specs/web-server/_groups.py @@ -106,7 +106,7 @@ async def add_group_user( _body: GroupUserAdd, ): """ - Adds a user to an organization group + Adds a user to an organization group using their username, user ID, or email (subject to privacy settings """ 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 476ab4aeb57..51b0b71ea37 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 @@ -288,6 +288,7 @@ class GroupUserAdd(InputSchema): """ uid: UserID | None = None + user_name: Annotated[IDStr | None, Field(alias="userName")] = None email: Annotated[ LowerCaseEmailStr | None, Field( @@ -296,7 +297,7 @@ class GroupUserAdd(InputSchema): ] = None _check_uid_or_email = model_validator(mode="after")( - create__check_only_one_is_set__root_validator(["uid", "email"]) + create__check_only_one_is_set__root_validator(["uid", "email", "user_name"]) ) model_config = ConfigDict( diff --git a/packages/models-library/src/models_library/utils/common_validators.py b/packages/models-library/src/models_library/utils/common_validators.py index 23cb62739db..d008f87e8cf 100644 --- a/packages/models-library/src/models_library/utils/common_validators.py +++ b/packages/models-library/src/models_library/utils/common_validators.py @@ -87,7 +87,9 @@ def null_or_none_str_to_none_validator(value: Any): return value -def create__check_only_one_is_set__root_validator(alternative_field_names: list[str]): +def create__check_only_one_is_set__root_validator( + mutually_exclusive_field_names: list[str], +): """Ensure exactly one and only one of the alternatives is set NOTE: a field is considered here `unset` when it is `not None`. When None @@ -104,17 +106,16 @@ def create__check_only_one_is_set__root_validator(alternative_field_names: list[ """ def _validator(cls: type[BaseModel], values): - assert set(alternative_field_names).issubset(cls.model_fields) # nosec - + assert set(mutually_exclusive_field_names).issubset( # nosec + cls.model_fields + ), f"Invalid {mutually_exclusive_field_names=} passed in the factory arguments" got = { field_name: getattr(values, field_name) - for field_name in alternative_field_names + for field_name in mutually_exclusive_field_names } if not functools.reduce(operator.xor, (v is not None for v in got.values())): - msg = ( - f"Either { 'or'.join(got.keys()) } must be set, but not both. Got {got}" - ) + msg = f"Either { ' or '.join(got.keys()) } must be set, but not both. Got {got}" raise ValueError(msg) return values 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 ad0fc205f43..c5ff2184202 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 @@ -634,7 +634,8 @@ paths: tags: - groups summary: Add Group User - description: Adds a user to an organization group + description: Adds a user to an organization group using their username, user + ID, or email (subject to privacy settings operationId: add_group_user parameters: - name: gid @@ -9911,6 +9912,13 @@ components: minimum: 0 - type: 'null' title: Uid + userName: + anyOf: + - type: string + maxLength: 100 + minLength: 1 + - type: 'null' + title: Username email: anyOf: - type: string diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py index c636a91d66d..1754cba44b0 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py @@ -1,4 +1,5 @@ from aiohttp import web +from models_library.basic_types import IDStr from models_library.emails import LowerCaseEmailStr from models_library.groups import ( AccessRightsDict, @@ -236,12 +237,17 @@ async def auto_add_user_to_product_group( ) +def _only_one_true(*args): + return sum(bool(arg) for arg in args) == 1 + + async def add_user_in_group( app: web.Application, user_id: UserID, group_id: GroupID, *, new_user_id: UserID | None = None, + new_user_name: IDStr | None = None, new_user_email: EmailStr | None = None, access_rights: AccessRightsDict | None = None, ) -> None: @@ -251,9 +257,8 @@ async def add_user_in_group( UserInGroupNotFoundError GroupsException """ - - if not new_user_id and not new_user_email: - msg = "Invalid method call, missing user id or user email" + if not _only_one_true(new_user_id, new_user_name, new_user_email): + msg = "Invalid method call, required one of these: user id, username or user email, none provided" raise GroupsError(msg=msg) if new_user_email: diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py index db7981b8350..8a3208205a9 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py @@ -3,6 +3,7 @@ import sqlalchemy as sa from aiohttp import web +from models_library.basic_types import IDStr from models_library.groups import ( AccessRightsDict, Group, @@ -589,7 +590,9 @@ async def add_new_user_in_group( *, user_id: UserID, group_id: GroupID, - new_user_id: UserID, + # either user_id or user_name + new_user_id: UserID | None = None, + new_user_name: IDStr | None = None, access_rights: AccessRightsDict | None = None, ) -> None: """ @@ -602,10 +605,17 @@ async def add_new_user_in_group( ) _check_group_permissions(group, user_id, group_id, "write") + query = sa.select(sa.func.count()) + if new_user_id: + query = query.where(users.c.id == new_user_id) + elif new_user_name: + query = query.where(users.c.name == new_user_name) + else: + msg = "Either user name or id but none provided" + raise ValueError(msg) + # now check the new user exists - users_count = await conn.scalar( - sa.select(sa.func.count()).where(users.c.id == new_user_id) - ) + users_count = await conn.scalar(query) if not users_count: assert new_user_id is not None # nosec raise UserInGroupNotFoundError(uid=new_user_id, gid=group_id) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py index 235925114d9..8f79c18a639 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py @@ -163,7 +163,7 @@ async def delete_group(request: web.Request): @login_required @permission_required("groups.*") @handle_plugin_requests_exceptions -async def get_group_users(request: web.Request): +async def get_all_group_users(request: web.Request): """Gets users in organization groups""" req_ctx = GroupsRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(GroupsPathParams, request) @@ -194,6 +194,7 @@ async def add_group_user(request: web.Request): req_ctx.user_id, path_params.gid, new_user_id=added.uid, + new_user_name=added.user_name, new_user_email=added.email, ) diff --git a/services/web/server/tests/unit/isolated/test_groups_models.py b/services/web/server/tests/unit/isolated/test_groups_models.py index 1a06552e5c1..a45c6f6ffb6 100644 --- a/services/web/server/tests/unit/isolated/test_groups_models.py +++ b/services/web/server/tests/unit/isolated/test_groups_models.py @@ -1,4 +1,5 @@ import models_library.groups +import pytest import simcore_postgres_database.models.groups from faker import Faker from models_library.api_schemas_webserver._base import OutputSchema @@ -6,6 +7,7 @@ GroupCreate, GroupGet, GroupUpdate, + GroupUserAdd, GroupUserGet, ) from models_library.groups import ( @@ -17,6 +19,7 @@ OrganizationUpdate, ) from models_library.utils.enums import enum_to_dict +from pydantic import ValidationError def test_models_library_and_postgress_database_enums_are_equivalent(): @@ -100,3 +103,22 @@ def test_input_schemas_to_models(faker: Faker): domain_model = input_schema.to_model() assert isinstance(domain_model, OrganizationUpdate) assert domain_model.name == input_schema.label + + +def test_group_user_add_options(faker: Faker): + def _only_one_true(*args): + return sum(bool(arg) for arg in args) == 1 + + input_schema = GroupUserAdd(uid=faker.pyint()) + assert input_schema.uid + assert _only_one_true(input_schema.uid, input_schema.user_name, input_schema.email) + + input_schema = GroupUserAdd(userName=faker.user_name()) + assert input_schema.user_name + assert _only_one_true(input_schema.uid, input_schema.user_name, input_schema.email) + + input_schema = GroupUserAdd(email=faker.email()) + assert _only_one_true(input_schema.uid, input_schema.user_name, input_schema.email) + + with pytest.raises(ValidationError): + GroupUserAdd(userName=faker.user_name(), email=faker.email()) From 92e68b2987c80f4d7d5f472797a681822ad9bd35 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 10 Dec 2024 14:46:26 +0100 Subject: [PATCH 69/84] doc --- api/specs/web-server/_groups.py | 2 +- .../server/src/simcore_service_webserver/api/v0/openapi.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/specs/web-server/_groups.py b/api/specs/web-server/_groups.py index 7e02bfeb51c..530460c6d8c 100644 --- a/api/specs/web-server/_groups.py +++ b/api/specs/web-server/_groups.py @@ -106,7 +106,7 @@ async def add_group_user( _body: GroupUserAdd, ): """ - Adds a user to an organization group using their username, user ID, or email (subject to privacy settings + Adds a user to an organization group using their username, user ID, or email (subject to privacy settings) """ 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 c5ff2184202..bb3ddf22091 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 @@ -635,7 +635,7 @@ paths: - groups summary: Add Group User description: Adds a user to an organization group using their username, user - ID, or email (subject to privacy settings + ID, or email (subject to privacy settings) operationId: add_group_user parameters: - name: gid From b0202061671697c87c6374f3e78d3fb230748fb1 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 10 Dec 2024 15:23:03 +0100 Subject: [PATCH 70/84] @sanderegg review:drop organization and renames --- .../simcore_webserver_groups_fixtures.py | 10 +++++----- .../simcore_service_webserver/groups/_groups_api.py | 8 ++++---- .../src/simcore_service_webserver/groups/_groups_db.py | 8 ++++++-- .../groups/_groups_handlers.py | 10 +++++----- .../tests/integration/01/test_garbage_collection.py | 4 ++-- .../with_dbs/01/groups/test_groups_handlers_users.py | 8 ++++---- 6 files changed, 26 insertions(+), 22 deletions(-) diff --git a/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py b/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py index 1a2fcd10f05..b1704ebcd9f 100644 --- a/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py +++ b/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py @@ -21,8 +21,8 @@ from pytest_simcore.helpers.webserver_login import NewUser, UserInfoDict from simcore_service_webserver.groups._groups_api import ( add_user_in_group, - create_organization, - delete_organization, + create_standard_group, + delete_group, list_user_groups_with_read_access, ) @@ -34,7 +34,7 @@ def _groupget_model_dump(group, access_rights) -> dict[str, Any]: async def _create_organization( app: web.Application, user_id: UserID, new_group: dict ) -> dict[str, Any]: - group, access_rights = await create_organization( + group, access_rights = await create_standard_group( app, user_id=user_id, create=OrganizationCreate.model_validate(new_group), @@ -107,10 +107,10 @@ async def standard_groups_owner( yield owner_user # clean groups - await delete_organization( + await delete_group( client.app, user_id=owner_user["id"], group_id=sparc_group["gid"] ) - await delete_organization( + await delete_group( client.app, user_id=owner_user["id"], group_id=team_black_group["gid"] ) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py index 1754cba44b0..ab00da1a9b7 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py @@ -91,7 +91,7 @@ async def get_product_group_for_user( # -async def create_organization( +async def create_standard_group( app: web.Application, *, user_id: UserID, @@ -101,7 +101,7 @@ async def create_organization( raises GroupNotFoundError raises UserInsufficientRightsError """ - return await _groups_db.create_user_group( + return await _groups_db.create_standard_group( app, user_id=user_id, create=create, @@ -123,7 +123,7 @@ async def get_organization( return await _groups_db.get_user_group(app, user_id=user_id, group_9d=group_id) -async def update_organization( +async def update_group( app: web.Application, *, user_id: UserID, @@ -144,7 +144,7 @@ async def update_organization( ) -async def delete_organization( +async def delete_group( app: web.Application, *, user_id: UserID, group_id: GroupID ) -> None: """ diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py index 8a3208205a9..a8d65bb874b 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py @@ -15,6 +15,7 @@ ) from models_library.users import GroupID, UserID from simcore_postgres_database.errors import UniqueViolation +from simcore_postgres_database.models.groups import GroupType from simcore_postgres_database.utils_products import execute_get_or_create_product_group from simcore_postgres_database.utils_repos import ( pass_or_acquire_connection, @@ -247,7 +248,7 @@ async def get_product_group_for_user( assert set(OrganizationCreate.model_fields).issubset({c.name for c in groups.columns}) -async def create_user_group( +async def create_standard_group( app: web.Application, connection: AsyncConnection | None = None, *, @@ -265,7 +266,10 @@ async def create_user_group( result = await conn.stream( # pylint: disable=no-value-for-parameter groups.insert() - .values(**create.model_dump(mode="json", exclude_unset=True)) + .values( + **create.model_dump(mode="json", exclude_unset=True), + type=GroupType.STANDARD, + ) .returning(*_GROUP_COLUMNS) ) row = await result.fetchone() diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py index 8f79c18a639..2f979fb5436 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py @@ -102,12 +102,12 @@ async def get_group(request: web.Request): @permission_required("groups.*") @handle_plugin_requests_exceptions async def create_group(request: web.Request): - """Creates organization groups""" + """Creates standard groups""" req_ctx = GroupsRequestContext.model_validate(request) create = await parse_request_body_as(GroupCreate, request) - group, access_rights = await _groups_api.create_organization( + group, access_rights = await _groups_api.create_standard_group( request.app, user_id=req_ctx.user_id, create=create.to_model(), @@ -122,12 +122,12 @@ async def create_group(request: web.Request): @permission_required("groups.*") @handle_plugin_requests_exceptions async def update_group(request: web.Request): - """Updates organization groups""" + """Updates groups metadata""" req_ctx = GroupsRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(GroupsPathParams, request) update: GroupUpdate = await parse_request_body_as(GroupUpdate, request) - group, access_rights = await _groups_api.update_organization( + group, access_rights = await _groups_api.update_group( request.app, user_id=req_ctx.user_id, group_id=path_params.gid, @@ -147,7 +147,7 @@ async def delete_group(request: web.Request): req_ctx = GroupsRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(GroupsPathParams, request) - await _groups_api.delete_organization( + await _groups_api.delete_group( request.app, user_id=req_ctx.user_id, group_id=path_params.gid ) diff --git a/services/web/server/tests/integration/01/test_garbage_collection.py b/services/web/server/tests/integration/01/test_garbage_collection.py index 10f6db8fae4..4b3a141a0e8 100644 --- a/services/web/server/tests/integration/01/test_garbage_collection.py +++ b/services/web/server/tests/integration/01/test_garbage_collection.py @@ -36,7 +36,7 @@ from simcore_service_webserver.director_v2.plugin import setup_director_v2 from simcore_service_webserver.garbage_collector import _core as gc_core from simcore_service_webserver.garbage_collector.plugin import setup_garbage_collector -from simcore_service_webserver.groups._groups_api import create_organization +from simcore_service_webserver.groups._groups_api import create_standard_group from simcore_service_webserver.groups.api import add_user_in_group from simcore_service_webserver.login.plugin import setup_login from simcore_service_webserver.projects._crud_api_delete import get_scheduled_tasks @@ -282,7 +282,7 @@ async def get_group(client: TestClient, user: dict): """Creates a group for a given user""" assert client.app - return await create_organization( + return await create_standard_group( app=client.app, user_id=user["id"], create=OrganizationCreate.model_validate( diff --git a/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py index 71b5766661d..a3bb26b2b41 100644 --- a/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py +++ b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py @@ -22,8 +22,8 @@ from simcore_postgres_database.models.users import UserRole from simcore_service_webserver._meta import API_VTAG from simcore_service_webserver.groups._groups_api import ( - create_organization, - delete_organization, + create_standard_group, + delete_group, ) from simcore_service_webserver.groups._groups_db import ( _DEFAULT_GROUP_OWNER_ACCESS_RIGHTS, @@ -460,7 +460,7 @@ async def group_where_logged_user_is_the_owner( logged_user: UserInfoDict, ) -> AsyncIterator[Group]: assert client.app - group, _ = await create_organization( + group, _ = await create_standard_group( app=client.app, user_id=logged_user["id"], create=OrganizationCreate.model_validate( @@ -474,7 +474,7 @@ async def group_where_logged_user_is_the_owner( yield group - await delete_organization(client.app, user_id=logged_user["id"], group_id=group.gid) + await delete_group(client.app, user_id=logged_user["id"], group_id=group.gid) @pytest.mark.acceptance_test( From 37d35dba5891d74a22deaee73356d53b570a6c40 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 10 Dec 2024 15:24:59 +0100 Subject: [PATCH 71/84] @sanderegg review:drop organization and renames --- .../api_schemas_webserver/groups.py | 12 ++--- .../src/models_library/groups.py | 4 +- .../simcore_webserver_groups_fixtures.py | 10 ++-- .../groups/_groups_api.py | 50 ++++++++++--------- .../groups/_groups_db.py | 28 ++++++----- .../groups/_groups_handlers.py | 20 ++++---- .../integration/01/test_garbage_collection.py | 4 +- .../tests/unit/isolated/test_groups_models.py | 8 +-- .../01/groups/test_groups_handlers_users.py | 10 ++-- 9 files changed, 76 insertions(+), 70 deletions(-) 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 51b0b71ea37..e9f81ef759b 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 @@ -20,8 +20,8 @@ AccessRightsDict, Group, GroupMember, - OrganizationCreate, - OrganizationUpdate, + StandardGroupCreate, + StandardGroupUpdate, ) from ..users import UserID from ..utils.common_validators import create__check_only_one_is_set__root_validator @@ -147,12 +147,12 @@ class GroupCreate(InputSchema): description: str thumbnail: AnyUrl | None = None - def to_model(self) -> OrganizationCreate: + def to_model(self) -> StandardGroupCreate: data = _rename_keys( self.model_dump(mode="json", exclude_unset=True), name_map={"label": "name"}, ) - return OrganizationCreate(**data) + return StandardGroupCreate(**data) class GroupUpdate(InputSchema): @@ -160,12 +160,12 @@ class GroupUpdate(InputSchema): description: str | None = None thumbnail: AnyUrl | None = None - def to_model(self) -> OrganizationUpdate: + def to_model(self) -> StandardGroupUpdate: data = _rename_keys( self.model_dump(mode="json", exclude_unset=True), name_map={"label": "name"}, ) - return OrganizationUpdate(**data) + return StandardGroupUpdate(**data) class MyGroupsGet(OutputSchema): diff --git a/packages/models-library/src/models_library/groups.py b/packages/models-library/src/models_library/groups.py index e1ea3accc98..b9aeaba8967 100644 --- a/packages/models-library/src/models_library/groups.py +++ b/packages/models-library/src/models_library/groups.py @@ -78,13 +78,13 @@ class GroupMember(BaseModel): model_config = ConfigDict(from_attributes=True) -class OrganizationCreate(BaseModel): +class StandardGroupCreate(BaseModel): name: str description: str | None = None thumbnail: str | None = None -class OrganizationUpdate(BaseModel): +class StandardGroupUpdate(BaseModel): name: str | None = None description: str | None = None thumbnail: str | None = None diff --git a/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py b/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py index b1704ebcd9f..a922403fe4e 100644 --- a/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py +++ b/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py @@ -16,13 +16,13 @@ from aiohttp import web from aiohttp.test_utils import TestClient from models_library.api_schemas_webserver.groups import GroupGet -from models_library.groups import GroupsByTypeTuple, OrganizationCreate +from models_library.groups import GroupsByTypeTuple, StandardGroupCreate from models_library.users import UserID from pytest_simcore.helpers.webserver_login import NewUser, UserInfoDict from simcore_service_webserver.groups._groups_api import ( add_user_in_group, create_standard_group, - delete_group, + delete_standard_group, list_user_groups_with_read_access, ) @@ -37,7 +37,7 @@ async def _create_organization( group, access_rights = await create_standard_group( app, user_id=user_id, - create=OrganizationCreate.model_validate(new_group), + create=StandardGroupCreate.model_validate(new_group), ) return _groupget_model_dump(group=group, access_rights=access_rights) @@ -107,10 +107,10 @@ async def standard_groups_owner( yield owner_user # clean groups - await delete_group( + await delete_standard_group( client.app, user_id=owner_user["id"], group_id=sparc_group["gid"] ) - await delete_group( + await delete_standard_group( client.app, user_id=owner_user["id"], group_id=team_black_group["gid"] ) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py index ab00da1a9b7..35aa0e415d6 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py @@ -6,8 +6,8 @@ Group, GroupMember, GroupsByTypeTuple, - OrganizationCreate, - OrganizationUpdate, + StandardGroupCreate, + StandardGroupUpdate, ) from models_library.products import ProductName from models_library.users import GroupID, UserID @@ -87,7 +87,7 @@ async def get_product_group_for_user( # -# ORGANIZATIONS CRUD operations +# CRUD operations on groups linked to a user # @@ -95,11 +95,12 @@ async def create_standard_group( app: web.Application, *, user_id: UserID, - create: OrganizationCreate, + create: StandardGroupCreate, ) -> tuple[Group, AccessRightsDict]: - """ + """NOTE: creation/update and deletion restricted to STANDARD groups + raises GroupNotFoundError - raises UserInsufficientRightsError + raises UserInsufficientRightsError: needs WRITE access """ return await _groups_db.create_standard_group( app, @@ -108,35 +109,34 @@ async def create_standard_group( ) -async def get_organization( +async def get_associated_group( app: web.Application, *, user_id: UserID, group_id: GroupID, ) -> tuple[Group, AccessRightsDict]: """ - Gets group gid if user associated to it and has read access raises GroupNotFoundError - raises UserInsufficientRightsError + raises UserInsufficientRightsError: needs READ access """ - return await _groups_db.get_user_group(app, user_id=user_id, group_9d=group_id) + return await _groups_db.get_user_group(app, user_id=user_id, group_id=group_id) -async def update_group( +async def update_standard_group( app: web.Application, *, user_id: UserID, group_id: GroupID, - update: OrganizationUpdate, + update: StandardGroupUpdate, ) -> tuple[Group, AccessRightsDict]: - """ + """NOTE: creation/update and deletion restricted to STANDARD groups raises GroupNotFoundError - raises UserInsufficientRightsError + raises UserInsufficientRightsError: needs WRITE access """ - return await _groups_db.update_user_group( + return await _groups_db.update_standard_group( app, user_id=user_id, group_id=group_id, @@ -144,29 +144,31 @@ async def update_group( ) -async def delete_group( +async def delete_standard_group( app: web.Application, *, user_id: UserID, group_id: GroupID ) -> None: - """ + """NOTE: creation/update and deletion restricted to STANDARD groups raises GroupNotFoundError - raises UserInsufficientRightsError + raises UserInsufficientRightsError: needs DELETE access """ - return await _groups_db.delete_user_group(app, user_id=user_id, group_id=group_id) + return await _groups_db.delete_standard_group( + app, user_id=user_id, group_id=group_id + ) # -# ORGANIZATION MEMBERS +# GROUP MEMBERS (= a user with some access-rights to a group) # -async def list_users_in_group( +async def list_group_members( app: web.Application, user_id: UserID, group_id: GroupID ) -> list[GroupMember]: return await _groups_db.list_users_in_group(app, user_id=user_id, group_id=group_id) -async def get_user_in_group( +async def get_group_member( app: web.Application, user_id: UserID, group_id: GroupID, @@ -181,7 +183,7 @@ async def get_user_in_group( ) -async def update_user_in_group( +async def update_group_member( app: web.Application, user_id: UserID, group_id: GroupID, @@ -197,7 +199,7 @@ async def update_user_in_group( ) -async def delete_user_in_group( +async def delete_group_member( app: web.Application, user_id: UserID, group_id: GroupID, diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py index a8d65bb874b..4477880140e 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py @@ -10,8 +10,8 @@ GroupInfoTuple, GroupMember, GroupsByTypeTuple, - OrganizationCreate, - OrganizationUpdate, + StandardGroupCreate, + StandardGroupUpdate, ) from models_library.users import GroupID, UserID from simcore_postgres_database.errors import UniqueViolation @@ -208,7 +208,7 @@ async def get_user_group( connection: AsyncConnection | None = None, *, user_id: UserID, - group_9d: GroupID, + group_id: GroupID, ) -> tuple[Group, AccessRightsDict]: """ Gets group gid if user associated to it and has read access @@ -218,9 +218,9 @@ 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_9d + conn, user_id=user_id, gid=group_id ) - _check_group_permissions(row, user_id, group_9d, "read") + _check_group_permissions(row, user_id, group_id, "read") group, access_rights = _to_group_info_tuple(row) return group, access_rights @@ -245,7 +245,7 @@ async def get_product_group_for_user( return group, access_rights -assert set(OrganizationCreate.model_fields).issubset({c.name for c in groups.columns}) +assert set(StandardGroupCreate.model_fields).issubset({c.name for c in groups.columns}) async def create_standard_group( @@ -253,7 +253,7 @@ async def create_standard_group( connection: AsyncConnection | None = None, *, user_id: UserID, - create: OrganizationCreate, + create: StandardGroupCreate, ) -> tuple[Group, AccessRightsDict]: async with transaction_context(get_asyncpg_engine(app), connection) as conn: @@ -288,16 +288,16 @@ async def create_standard_group( return group, deepcopy(_DEFAULT_GROUP_OWNER_ACCESS_RIGHTS) -assert set(OrganizationUpdate.model_fields).issubset({c.name for c in groups.columns}) +assert set(StandardGroupUpdate.model_fields).issubset({c.name for c in groups.columns}) -async def update_user_group( +async def update_standard_group( app: web.Application, connection: AsyncConnection | None = None, *, user_id: UserID, group_id: GroupID, - update: OrganizationUpdate, + update: StandardGroupUpdate, ) -> tuple[Group, AccessRightsDict]: values = update.model_dump(mode="json", exclude_unset=True) @@ -314,7 +314,7 @@ async def update_user_group( # pylint: disable=no-value-for-parameter groups.update() .values(**values) - .where(groups.c.gid == row.gid) + .where((groups.c.gid == row.gid) & (groups.c.type == GroupType.STANDARD)) .returning(*_GROUP_COLUMNS) ) row = await result.fetchone() @@ -324,7 +324,7 @@ async def update_user_group( return group, access_rights -async def delete_user_group( +async def delete_standard_group( app: web.Application, connection: AsyncConnection | None = None, *, @@ -339,7 +339,9 @@ async def delete_user_group( await conn.execute( # pylint: disable=no-value-for-parameter - groups.delete().where(groups.c.gid == group.gid) + groups.delete().where( + (groups.c.gid == group.gid) & (groups.c.type == GroupType.STANDARD) + ) ) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py index 2f979fb5436..05b769d5ea0 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py @@ -90,7 +90,7 @@ async def get_group(request: web.Request): req_ctx = GroupsRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(GroupsPathParams, request) - group, access_rights = await _groups_api.get_organization( + group, access_rights = await _groups_api.get_associated_group( request.app, user_id=req_ctx.user_id, group_id=path_params.gid ) @@ -102,7 +102,7 @@ async def get_group(request: web.Request): @permission_required("groups.*") @handle_plugin_requests_exceptions async def create_group(request: web.Request): - """Creates standard groups""" + """Creates a standard group""" req_ctx = GroupsRequestContext.model_validate(request) create = await parse_request_body_as(GroupCreate, request) @@ -122,12 +122,12 @@ async def create_group(request: web.Request): @permission_required("groups.*") @handle_plugin_requests_exceptions async def update_group(request: web.Request): - """Updates groups metadata""" + """Updates metadata of a standard group""" req_ctx = GroupsRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(GroupsPathParams, request) update: GroupUpdate = await parse_request_body_as(GroupUpdate, request) - group, access_rights = await _groups_api.update_group( + group, access_rights = await _groups_api.update_standard_group( request.app, user_id=req_ctx.user_id, group_id=path_params.gid, @@ -143,11 +143,11 @@ async def update_group(request: web.Request): @permission_required("groups.*") @handle_plugin_requests_exceptions async def delete_group(request: web.Request): - """Deletes organization groups""" + """Deletes a standard group""" req_ctx = GroupsRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(GroupsPathParams, request) - await _groups_api.delete_group( + await _groups_api.delete_standard_group( request.app, user_id=req_ctx.user_id, group_id=path_params.gid ) @@ -168,7 +168,7 @@ async def get_all_group_users(request: web.Request): req_ctx = GroupsRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(GroupsPathParams, request) - users_in_group = await _groups_api.list_users_in_group( + users_in_group = await _groups_api.list_group_members( request.app, req_ctx.user_id, path_params.gid ) @@ -212,7 +212,7 @@ async def get_group_user(request: web.Request): req_ctx = GroupsRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(GroupsUsersPathParams, request) - user = await _groups_api.get_user_in_group( + user = await _groups_api.get_group_member( request.app, req_ctx.user_id, path_params.gid, path_params.uid ) @@ -228,7 +228,7 @@ async def update_group_user(request: web.Request): path_params = parse_request_path_parameters_as(GroupsUsersPathParams, request) update: GroupUserUpdate = await parse_request_body_as(GroupUserUpdate, request) - user = await _groups_api.update_user_in_group( + user = await _groups_api.update_group_member( request.app, user_id=req_ctx.user_id, group_id=path_params.gid, @@ -246,7 +246,7 @@ async def update_group_user(request: web.Request): async def delete_group_user(request: web.Request): req_ctx = GroupsRequestContext.model_validate(request) path_params = parse_request_path_parameters_as(GroupsUsersPathParams, request) - await _groups_api.delete_user_in_group( + await _groups_api.delete_group_member( request.app, req_ctx.user_id, path_params.gid, path_params.uid ) diff --git a/services/web/server/tests/integration/01/test_garbage_collection.py b/services/web/server/tests/integration/01/test_garbage_collection.py index 4b3a141a0e8..ca7a5599319 100644 --- a/services/web/server/tests/integration/01/test_garbage_collection.py +++ b/services/web/server/tests/integration/01/test_garbage_collection.py @@ -21,7 +21,7 @@ from aiohttp import web from aiohttp.test_utils import TestClient from aioresponses import aioresponses -from models_library.groups import EVERYONE_GROUP_ID, OrganizationCreate +from models_library.groups import EVERYONE_GROUP_ID, StandardGroupCreate from models_library.projects_state import RunningState from pytest_mock import MockerFixture from pytest_simcore.helpers.webserver_login import UserInfoDict, log_client_in @@ -285,7 +285,7 @@ async def get_group(client: TestClient, user: dict): return await create_standard_group( app=client.app, user_id=user["id"], - create=OrganizationCreate.model_validate( + create=StandardGroupCreate.model_validate( {"name": uuid4(), "description": uuid4(), "thumbnail": None} ), ) diff --git a/services/web/server/tests/unit/isolated/test_groups_models.py b/services/web/server/tests/unit/isolated/test_groups_models.py index a45c6f6ffb6..9813ca6009c 100644 --- a/services/web/server/tests/unit/isolated/test_groups_models.py +++ b/services/web/server/tests/unit/isolated/test_groups_models.py @@ -15,8 +15,8 @@ Group, GroupMember, GroupTypeInModel, - OrganizationCreate, - OrganizationUpdate, + StandardGroupCreate, + StandardGroupUpdate, ) from models_library.utils.enums import enum_to_dict from pydantic import ValidationError @@ -95,13 +95,13 @@ def test_input_schemas_to_models(faker: Faker): label=faker.word(), description=faker.sentence(), thumbnail=faker.url() ) domain_model = input_schema.to_model() - assert isinstance(domain_model, OrganizationCreate) + assert isinstance(domain_model, StandardGroupCreate) assert domain_model.name == input_schema.label # input : scheam -> model input_schema = GroupUpdate(label=faker.word()) domain_model = input_schema.to_model() - assert isinstance(domain_model, OrganizationUpdate) + assert isinstance(domain_model, StandardGroupUpdate) assert domain_model.name == input_schema.label diff --git a/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py index a3bb26b2b41..6f54ef11217 100644 --- a/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py +++ b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py @@ -11,7 +11,7 @@ from aiohttp.test_utils import TestClient from faker import Faker from models_library.api_schemas_webserver.groups import GroupGet, GroupUserGet -from models_library.groups import AccessRightsDict, Group, OrganizationCreate +from models_library.groups import AccessRightsDict, Group, StandardGroupCreate from pytest_simcore.helpers.assert_checks import assert_status from pytest_simcore.helpers.webserver_login import LoggedUser, NewUser, UserInfoDict from pytest_simcore.helpers.webserver_parametrizations import ( @@ -23,7 +23,7 @@ from simcore_service_webserver._meta import API_VTAG from simcore_service_webserver.groups._groups_api import ( create_standard_group, - delete_group, + delete_standard_group, ) from simcore_service_webserver.groups._groups_db import ( _DEFAULT_GROUP_OWNER_ACCESS_RIGHTS, @@ -463,7 +463,7 @@ async def group_where_logged_user_is_the_owner( group, _ = await create_standard_group( app=client.app, user_id=logged_user["id"], - create=OrganizationCreate.model_validate( + create=StandardGroupCreate.model_validate( { "name": f"this is user {logged_user['id']} group", "description": f"user {logged_user['email']} is the owner of that one", @@ -474,7 +474,9 @@ async def group_where_logged_user_is_the_owner( yield group - await delete_group(client.app, user_id=logged_user["id"], group_id=group.gid) + await delete_standard_group( + client.app, user_id=logged_user["id"], group_id=group.gid + ) @pytest.mark.acceptance_test( From 0272554848f6c3c6ca868b26c40e3e3a87c8b890 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 10 Dec 2024 15:57:33 +0100 Subject: [PATCH 72/84] fixes autoinclusion --- .../models_library/api_schemas_webserver/groups.py | 14 ++++++++++++-- .../models-library/src/models_library/groups.py | 8 ++++++++ .../simcore_webserver_groups_fixtures.py | 4 ++-- .../01/groups/test_groups_handlers_users.py | 3 +++ 4 files changed, 25 insertions(+), 4 deletions(-) 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 e9f81ef759b..9d5ec4fd30b 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 @@ -149,7 +149,12 @@ class GroupCreate(InputSchema): def to_model(self) -> StandardGroupCreate: data = _rename_keys( - self.model_dump(mode="json", exclude_unset=True), + self.model_dump( + mode="json", + # NOTE: intentionally inclusion_rules are not exposed to the REST api + include={"label", "description", "thumbnail"}, + exclude_unset=True, + ), name_map={"label": "name"}, ) return StandardGroupCreate(**data) @@ -162,7 +167,12 @@ class GroupUpdate(InputSchema): def to_model(self) -> StandardGroupUpdate: data = _rename_keys( - self.model_dump(mode="json", exclude_unset=True), + self.model_dump( + mode="json", + # NOTE: intentionally inclusion_rules are not exposed to the REST api + include={"label", "description", "thumbnail"}, + exclude_unset=True, + ), name_map={"label": "name"}, ) return StandardGroupUpdate(**data) diff --git a/packages/models-library/src/models_library/groups.py b/packages/models-library/src/models_library/groups.py index b9aeaba8967..e79928574a6 100644 --- a/packages/models-library/src/models_library/groups.py +++ b/packages/models-library/src/models_library/groups.py @@ -82,12 +82,20 @@ class StandardGroupCreate(BaseModel): name: str description: str | None = None thumbnail: str | None = None + inclusion_rules: Annotated[ + dict[str, str], + Field( + default_factory=dict, + description="Maps user's column and regular expression", + ), + ] = DEFAULT_FACTORY class StandardGroupUpdate(BaseModel): name: str | None = None description: str | None = None thumbnail: str | None = None + inclusion_rules: dict[str, str] | None = None class GroupAtDB(Group): diff --git a/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py b/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py index a922403fe4e..fb4e16469bb 100644 --- a/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py +++ b/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py @@ -74,7 +74,7 @@ async def standard_groups_owner( "name": "SPARC", "description": "Stimulating Peripheral Activity to Relieve Conditions", "thumbnail": "https://commonfund.nih.gov/sites/default/files/sparc-image-homepage500px.png", - "inclusionRules": {"email": r"@(sparc)+\.(io|com)$"}, + "inclusion_rules": {"email": r"@(sparc)+\.(io|com)$"}, }, ) team_black_group = await _create_organization( @@ -84,7 +84,7 @@ async def standard_groups_owner( "name": "team Black", "description": "THE incredible black team", "thumbnail": None, - "inclusionRules": {"email": r"@(black)+\.(io|com)$"}, + "inclusion_rules": {"email": r"@(black)+\.(io|com)$"}, }, ) diff --git a/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py index 6f54ef11217..97ebd6e2b51 100644 --- a/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py +++ b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_users.py @@ -418,9 +418,12 @@ async def test_add_user_gets_added_to_group( user_role: UserRole, expected: ExpectedResponse, ): + assert client.app async with AsyncExitStack() as users_stack: for email in ( + # SEE StandardGroupCreate.inclusion_rules in + # packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py "good@sparc.io", "bad@bad.com", "bad@osparc.com", From b836ab0de8644aeb2350fffb84a0950b9eed2330 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 10 Dec 2024 16:02:44 +0100 Subject: [PATCH 73/84] @sanderegg review: deprecation --- .../src/simcore_postgres_database/utils_products.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/postgres-database/src/simcore_postgres_database/utils_products.py b/packages/postgres-database/src/simcore_postgres_database/utils_products.py index 56eb5005161..33e877c21d0 100644 --- a/packages/postgres-database/src/simcore_postgres_database/utils_products.py +++ b/packages/postgres-database/src/simcore_postgres_database/utils_products.py @@ -2,6 +2,8 @@ """ +import warnings + import sqlalchemy as sa from ._protocols import AiopgConnection, DBConnection @@ -76,6 +78,13 @@ async def get_or_create_product_group( """ Returns group_id of a product. Creates it if undefined """ + warnings.warn( + f"{__name__}.get_or_create_product_group uses aiopg which has been deprecated in this repo. Please use the asyncpg equivalent version instead" + "See https://github.com/ITISFoundation/osparc-simcore/issues/4529", + DeprecationWarning, + stacklevel=1, + ) + async with connection.begin(): group_id = await execute_get_or_create_product_group( connection, product_name=product_name From eb6071d824fb010f05258c4b6794f68bdc9661c2 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 10 Dec 2024 16:10:23 +0100 Subject: [PATCH 74/84] @odeimaiz review: deprecated inclusion rules --- .../src/models_library/api_schemas_webserver/groups.py | 6 ++---- .../src/simcore_service_webserver/api/v0/openapi.yaml | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) 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 9d5ec4fd30b..d595447c3d3 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 @@ -67,8 +67,8 @@ class GroupGet(OutputSchema): dict[str, str], Field( default_factory=dict, - description="Maps user's column and regular expression", alias="inclusionRules", + deprecated=True, ), ] = DEFAULT_FACTORY @@ -84,9 +84,8 @@ def from_model(cls, group: Group, access_rights: AccessRightsDict) -> Self: "name", "description", "thumbnail", - "inclusion_rules", }, - exclude={"access_rights"}, + exclude={"access_rights", "inclusion_rules"}, exclude_unset=True, by_alias=False, ), @@ -125,7 +124,6 @@ def from_model(cls, group: Group, access_rights: AccessRightsDict) -> Self: "label": "SPARCi", "description": "Stimulating Peripheral Activity to Relieve Conditions", "thumbnail": "https://placekitten.com/15/15", - "inclusionRules": {"email": r"@(sparc)+\.(io|com|us)$"}, "accessRights": {"read": True, "write": True, "delete": True}, }, ] 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 bb3ddf22091..9a92419e514 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 @@ -9874,7 +9874,7 @@ components: type: string type: object title: Inclusionrules - description: Maps user's column and regular expression + deprecated: true type: object required: - gid From 401438ed398b6281dd731e4bc90b77d235195cfe Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 10 Dec 2024 16:31:15 +0100 Subject: [PATCH 75/84] TODO --- .../groups/_groups_api.py | 15 ++------- .../groups/_groups_db.py | 33 +++++++++++++++---- 2 files changed, 29 insertions(+), 19 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py index 35aa0e415d6..0096f3f52a4 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py @@ -36,21 +36,10 @@ async def get_group_from_gid(app: web.Application, group_id: GroupID) -> Group | async def list_user_groups_ids_with_read_access( app: web.Application, *, user_id: UserID ) -> list[GroupID]: - # TODO: Room for optimization. For the moment we reuse existing db functions - groups_by_type = await _groups_db.get_all_user_groups_with_read_access( + + return await _groups_db.get_ids_of_all_user_groups_with_read_access( app, user_id=user_id ) - assert groups_by_type.primary # nosec - - groups_ids = [groups_by_type.primary[0].gid] - - # NOTE: that product-groups will not be listed here - groups_ids += [g[0].gid for g in groups_by_type.standard] - - assert groups_by_type.everyone # nosec - groups_ids.append(groups_by_type.everyone[0].gid) - - return groups_ids async def list_user_groups_with_read_access( diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py index 4477880140e..bcbabbd61b1 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py @@ -139,6 +139,15 @@ async def get_group_from_gid( # +def _query_user_groups_with_read_access(query, user_id: UserID): + return query.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.access_rights["read"].is_(True)) + ) + + async def get_all_user_groups_with_read_access( app: web.Application, connection: AsyncConnection | None = None, @@ -153,12 +162,8 @@ async def get_all_user_groups_with_read_access( standard_groups: list[GroupInfoTuple] = [] everyone_group: GroupInfoTuple | None = None - query = ( - sa.select(groups, 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) + query = _query_user_groups_with_read_access( + sa.select(groups, user_to_groups.c.access_rights), user_id=user_id ) async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: @@ -183,6 +188,22 @@ async def get_all_user_groups_with_read_access( ) +async def get_ids_of_all_user_groups_with_read_access( + app: web.Application, + connection: AsyncConnection | None = None, + *, + user_id: UserID, +) -> list[GroupID]: + + query = _query_user_groups_with_read_access( + sa.select(groups.c.gid, user_to_groups.c.access_rights), user_id=user_id + ) + + async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: + result = await conn.stream(query) + return [row.gid async for row in result] + + async def get_all_user_groups( app: web.Application, connection: AsyncConnection | None = None, From 19a7c86bd663c646ca08a605c5ff1ead1906ba84 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 10 Dec 2024 16:36:25 +0100 Subject: [PATCH 76/84] TODO --- .../groups/_groups_api.py | 20 +++++++--------- .../groups/_groups_db.py | 24 +++++++++++++++++-- 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py index 0096f3f52a4..27b80baf821 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py @@ -33,13 +33,6 @@ async def get_group_from_gid(app: web.Application, group_id: GroupID) -> Group | # # USER GROUPS: groups a user belongs to # -async def list_user_groups_ids_with_read_access( - app: web.Application, *, user_id: UserID -) -> list[GroupID]: - - return await _groups_db.get_ids_of_all_user_groups_with_read_access( - app, user_id=user_id - ) async def list_user_groups_with_read_access( @@ -51,16 +44,21 @@ async def list_user_groups_with_read_access( # NOTE: Careful! It seems we are filtering out groups, such as Product Groups, # because they do not have read access. I believe this was done because the # frontend did not want to display them. - return await _groups_db.get_all_user_groups_with_read_access(app, user_id=user_id) +async def list_user_groups_ids_with_read_access( + app: web.Application, *, user_id: UserID +) -> list[GroupID]: + return await _groups_db.get_ids_of_all_user_groups_with_read_access( + app, user_id=user_id + ) + + async def list_all_user_groups_ids( app: web.Application, *, user_id: UserID ) -> list[GroupID]: - # TODO: Room for optimization. For the moment we reuse existing db functions - user_groups = await _groups_db.get_all_user_groups(app, user_id=user_id) - return [g.gid for g in user_groups] + return await _groups_db.get_ids_of_all_user_groups(app, user_id=user_id) async def get_product_group_for_user( diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py index bcbabbd61b1..226ada5284e 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py @@ -194,7 +194,7 @@ async def get_ids_of_all_user_groups_with_read_access( *, user_id: UserID, ) -> list[GroupID]: - + # thin version of `get_all_user_groups_with_read_access` query = _query_user_groups_with_read_access( sa.select(groups.c.gid, user_to_groups.c.access_rights), user_id=user_id ) @@ -215,7 +215,7 @@ async def get_all_user_groups( """ async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: result = await conn.stream( - sa.select(_GROUP_COLUMNS) + sa.select(*_GROUP_COLUMNS) .select_from( user_to_groups.join(groups, user_to_groups.c.gid == groups.c.gid), ) @@ -224,6 +224,26 @@ async def get_all_user_groups( return [Group.model_validate(row) async for row in result] +async def get_ids_of_all_user_groups( + app: web.Application, + connection: AsyncConnection | None = None, + *, + user_id: UserID, +) -> list[GroupID]: + # thin version of `get_all_user_groups` + async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: + result = await conn.stream( + sa.select( + groups.c.gid, + ) + .select_from( + user_to_groups.join(groups, user_to_groups.c.gid == groups.c.gid), + ) + .where(user_to_groups.c.uid == user_id) + ) + return [row.id async for row in result] + + async def get_user_group( app: web.Application, connection: AsyncConnection | None = None, From 63aefa76cc04aa3fe6a2f2fec5c11f846c79d837 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 10 Dec 2024 16:44:15 +0100 Subject: [PATCH 77/84] cleanup --- .../groups/_groups_db.py | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py index 226ada5284e..b1ebe513410 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py @@ -139,12 +139,16 @@ async def get_group_from_gid( # -def _query_user_groups_with_read_access(query, user_id: UserID): - return query.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.access_rights["read"].is_(True)) +def _list_user_groups_with_read_access_query(*group_selection, user_id: UserID): + return ( + sa.select(*group_selection, 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.access_rights["read"].is_(True)) + ) ) @@ -162,9 +166,7 @@ async def get_all_user_groups_with_read_access( standard_groups: list[GroupInfoTuple] = [] everyone_group: GroupInfoTuple | None = None - query = _query_user_groups_with_read_access( - sa.select(groups, user_to_groups.c.access_rights), user_id=user_id - ) + query = _list_user_groups_with_read_access_query(groups, user_id=user_id) async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: result = await conn.stream(query) @@ -195,9 +197,8 @@ async def get_ids_of_all_user_groups_with_read_access( user_id: UserID, ) -> list[GroupID]: # thin version of `get_all_user_groups_with_read_access` - query = _query_user_groups_with_read_access( - sa.select(groups.c.gid, user_to_groups.c.access_rights), user_id=user_id - ) + + query = _list_user_groups_with_read_access_query(groups.c.gid, user_id=user_id) async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn: result = await conn.stream(query) From b50842beafec49506030dabbf2384cbf70efe05d Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 10 Dec 2024 16:55:08 +0100 Subject: [PATCH 78/84] migration --- ...3c3ac9_set_privacy_hide_email_to_false_.py | 34 +++++++++++++++++++ .../simcore_postgres_database/models/users.py | 2 +- .../groups/_groups_api.py | 2 +- 3 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 packages/postgres-database/src/simcore_postgres_database/migration/versions/5e27063c3ac9_set_privacy_hide_email_to_false_.py diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/5e27063c3ac9_set_privacy_hide_email_to_false_.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/5e27063c3ac9_set_privacy_hide_email_to_false_.py new file mode 100644 index 00000000000..2381193baeb --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/5e27063c3ac9_set_privacy_hide_email_to_false_.py @@ -0,0 +1,34 @@ +"""set privacy_hide_email to false temporarily + +Revision ID: 5e27063c3ac9 +Revises: 4d007819e61a +Create Date: 2024-12-10 15:50:48.024204+00:00 + +""" +from alembic import op +from sqlalchemy.sql import expression + +# revision identifiers, used by Alembic. +revision = "5e27063c3ac9" +down_revision = "4d007819e61a" +branch_labels = None +depends_on = None + + +def upgrade(): + # 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") + + +def downgrade(): + + # Revert the 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") diff --git a/packages/postgres-database/src/simcore_postgres_database/models/users.py b/packages/postgres-database/src/simcore_postgres_database/models/users.py index d42568d772f..bdff1293211 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/users.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/users.py @@ -161,7 +161,7 @@ class UserStatus(str, Enum): "privacy_hide_email", sa.Boolean, nullable=False, - server_default=expression.true(), + server_default=expression.false(), doc="If true, it hides users.email to others", ), # diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py index 27b80baf821..ad76c9ba3d9 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py @@ -102,7 +102,7 @@ async def get_associated_group( user_id: UserID, group_id: GroupID, ) -> tuple[Group, AccessRightsDict]: - """ + """NOTE: here it can also be a non-standard group raises GroupNotFoundError raises UserInsufficientRightsError: needs READ access From 0009f9553c3b63460f2c5b1b4adfdc8daf9b229a Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 10 Dec 2024 17:04:08 +0100 Subject: [PATCH 79/84] fix query --- .../server/src/simcore_service_webserver/groups/_groups_db.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py index b1ebe513410..9935ded1d90 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py @@ -147,7 +147,7 @@ def _list_user_groups_with_read_access_query(*group_selection, user_id: UserID): ) .where( (user_to_groups.c.uid == user_id) - & (user_to_groups.c.access_rights["read"].is_(True)) + & (user_to_groups.c.access_rights["read"].astext == "true") ) ) From 438c174c5c3d178e403b452b91252631dbb3b8f3 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 10 Dec 2024 17:41:42 +0100 Subject: [PATCH 80/84] fix tests --- .../server/tests/integration/01/test_garbage_collection.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/services/web/server/tests/integration/01/test_garbage_collection.py b/services/web/server/tests/integration/01/test_garbage_collection.py index ca7a5599319..9a442b279c6 100644 --- a/services/web/server/tests/integration/01/test_garbage_collection.py +++ b/services/web/server/tests/integration/01/test_garbage_collection.py @@ -286,7 +286,11 @@ async def get_group(client: TestClient, user: dict): app=client.app, user_id=user["id"], create=StandardGroupCreate.model_validate( - {"name": uuid4(), "description": uuid4(), "thumbnail": None} + { + "name": f"name-{uuid4()}", + "description": f"desc-{uuid4()}", + "thumbnail": None, + } ), ) From dd89a3535fbd9b5a5e31668505ead741dc6f03f7 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 10 Dec 2024 17:49:18 +0100 Subject: [PATCH 81/84] fixes tsts --- .../simcore_webserver_groups_fixtures.py | 8 +++++--- .../groups/_groups_api.py | 20 ++++++++++--------- .../groups/_groups_handlers.py | 6 +++--- .../integration/01/test_garbage_collection.py | 5 +++-- 4 files changed, 22 insertions(+), 17 deletions(-) diff --git a/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py b/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py index fb4e16469bb..0c79aba5622 100644 --- a/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py +++ b/packages/pytest-simcore/src/pytest_simcore/simcore_webserver_groups_fixtures.py @@ -28,7 +28,9 @@ def _groupget_model_dump(group, access_rights) -> dict[str, Any]: - return GroupGet.from_model(group, access_rights).model_dump(mode="json") + return GroupGet.from_model(group, access_rights).model_dump( + mode="json", by_alias=True + ) async def _create_organization( @@ -93,7 +95,7 @@ async def standard_groups_owner( app=client.app, user_id=owner_user["id"], group_id=sparc_group["gid"], - new_user_id=logged_user["id"], + new_by_user_id=logged_user["id"], ) # adds logged_user to team-black group @@ -101,7 +103,7 @@ async def standard_groups_owner( app=client.app, user_id=owner_user["id"], group_id=team_black_group["gid"], - new_user_id=logged_user["id"], + new_by_user_id=logged_user["id"], ) yield owner_user diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py index ad76c9ba3d9..9b9e712df54 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_api.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_api.py @@ -235,9 +235,11 @@ async def add_user_in_group( user_id: UserID, group_id: GroupID, *, - new_user_id: UserID | None = None, - new_user_name: IDStr | None = None, - new_user_email: EmailStr | None = None, + # identifies + new_by_user_id: UserID | None = None, + new_by_user_name: IDStr | None = None, + new_by_user_email: EmailStr | None = None, + # payload access_rights: AccessRightsDict | None = None, ) -> None: """Adds new_user (either by id or email) in group (with gid) owned by user_id @@ -246,17 +248,17 @@ async def add_user_in_group( UserInGroupNotFoundError GroupsException """ - if not _only_one_true(new_user_id, new_user_name, new_user_email): + if not _only_one_true(new_by_user_id, new_by_user_name, new_by_user_email): msg = "Invalid method call, required one of these: user id, username or user email, none provided" raise GroupsError(msg=msg) - if new_user_email: + if new_by_user_email: user = await _groups_db.get_user_from_email( - app, email=new_user_email, caller_user_id=user_id + app, email=new_by_user_email, caller_user_id=user_id ) - new_user_id = user.id + new_by_user_id = user.id - if not new_user_id: + if not new_by_user_id: msg = "Missing new user in arguments" raise GroupsError(msg=msg) @@ -264,6 +266,6 @@ async def add_user_in_group( app, user_id=user_id, group_id=group_id, - new_user_id=new_user_id, + new_user_id=new_by_user_id, access_rights=access_rights, ) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py index 05b769d5ea0..46131510489 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_handlers.py @@ -193,9 +193,9 @@ async def add_group_user(request: web.Request): request.app, req_ctx.user_id, path_params.gid, - new_user_id=added.uid, - new_user_name=added.user_name, - new_user_email=added.email, + new_by_user_id=added.uid, + new_by_user_name=added.user_name, + new_by_user_email=added.email, ) return web.json_response(status=status.HTTP_204_NO_CONTENT) diff --git a/services/web/server/tests/integration/01/test_garbage_collection.py b/services/web/server/tests/integration/01/test_garbage_collection.py index 9a442b279c6..f373c302df4 100644 --- a/services/web/server/tests/integration/01/test_garbage_collection.py +++ b/services/web/server/tests/integration/01/test_garbage_collection.py @@ -282,7 +282,7 @@ async def get_group(client: TestClient, user: dict): """Creates a group for a given user""" assert client.app - return await create_standard_group( + group, _ = await create_standard_group( app=client.app, user_id=user["id"], create=StandardGroupCreate.model_validate( @@ -293,6 +293,7 @@ async def get_group(client: TestClient, user: dict): } ), ) + return group.model_dump(mode="json") async def invite_user_to_group(client: TestClient, owner, invitee, group): @@ -303,7 +304,7 @@ async def invite_user_to_group(client: TestClient, owner, invitee, group): client.app, owner["id"], group["gid"], - new_user_id=invitee["id"], + new_by_user_id=invitee["id"], ) From d1fd1d0e20e21b85c43deb41db2325c663707c98 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 10 Dec 2024 17:54:56 +0100 Subject: [PATCH 82/84] fixes tsts --- .../server/src/simcore_service_webserver/groups/_groups_db.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py index 9935ded1d90..b070f7b1dac 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py @@ -130,7 +130,7 @@ async def get_group_from_gid( row = await conn.stream(groups.select().where(groups.c.gid == group_id)) result = await row.first() if result: - return Group.model_validate(result) + return Group.model_validate(result, from_attributes=True) return None From cee4f33a6c4181a4e1b5722db6dbaf0e0e5825cf Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 10 Dec 2024 18:06:15 +0100 Subject: [PATCH 83/84] fixes tsts --- services/web/server/src/simcore_service_webserver/users/api.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/services/web/server/src/simcore_service_webserver/users/api.py b/services/web/server/src/simcore_service_webserver/users/api.py index ff9ade31ac7..623d4f44396 100644 --- a/services/web/server/src/simcore_service_webserver/users/api.py +++ b/services/web/server/src/simcore_service_webserver/users/api.py @@ -52,19 +52,20 @@ "description": "description", "thumbnail": "thumbnail", "accessRights": "access_rights", - "inclusionRules": "inclusion_rules", } def _convert_groups_db_to_schema( db_row: RowProxy, *, prefix: str | None = "", **kwargs ) -> dict: + # NOTE: Deprecated. has to be replaced with converted_dict = { k: db_row[f"{prefix}{v}"] for k, v in _GROUPS_SCHEMA_TO_DB.items() if f"{prefix}{v}" in db_row } converted_dict.update(**kwargs) + converted_dict["inclusionRules"] = {} return converted_dict From 6d9e3fb1a4c8b993e89a8387820ccd41d567e29e Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 10 Dec 2024 19:07:54 +0100 Subject: [PATCH 84/84] fixes tests --- .../groups/_groups_db.py | 2 +- .../src/simcore_service_webserver/tree.md | 562 ++++++++++++++++++ .../01/groups/test_groups_handlers_crud.py | 8 +- 3 files changed, 568 insertions(+), 4 deletions(-) create mode 100644 services/web/server/src/simcore_service_webserver/tree.md diff --git a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py index b070f7b1dac..570375f3646 100644 --- a/services/web/server/src/simcore_service_webserver/groups/_groups_db.py +++ b/services/web/server/src/simcore_service_webserver/groups/_groups_db.py @@ -242,7 +242,7 @@ async def get_ids_of_all_user_groups( ) .where(user_to_groups.c.uid == user_id) ) - return [row.id async for row in result] + return [row.gid async for row in result] async def get_user_group( diff --git a/services/web/server/src/simcore_service_webserver/tree.md b/services/web/server/src/simcore_service_webserver/tree.md new file mode 100644 index 00000000000..0117a6c851e --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/tree.md @@ -0,0 +1,562 @@ +This is a tree view of my app. It is built in python's aiohttp. + + +├── activity +│   ├── _api.py +│   ├── _handlers.py +│   ├── plugin.py +│   └── settings.py +├── announcements +│   ├── _api.py +│   ├── _handlers.py +│   ├── _models.py +│   ├── plugin.py +│   └── _redis.py +├── api_keys +│   ├── api.py +│   ├── errors.py +│   ├── _exceptions_handlers.py +│   ├── _models.py +│   ├── plugin.py +│   ├── _repository.py +│   ├── _rest.py +│   ├── _rpc.py +│   └── _service.py +├── application.py +├── application_settings.py +├── application_settings_utils.py +├── catalog +│   ├── _api.py +│   ├── _api_units.py +│   ├── client.py +│   ├── _constants.py +│   ├── exceptions.py +│   ├── _handlers_errors.py +│   ├── _handlers.py +│   ├── licenses +│   │   ├── api.py +│   │   ├── errors.py +│   │   ├── _exceptions_handlers.py +│   │   ├── _licensed_items_api.py +│   │   ├── _licensed_items_db.py +│   │   ├── _licensed_items_handlers.py +│   │   ├── _models.py +│   │   └── plugin.py +│   ├── _models.py +│   ├── plugin.py +│   ├── settings.py +│   └── _tags_handlers.py +├── cli.py +├── _constants.py +├── db +│   ├── _aiopg.py +│   ├── _asyncpg.py +│   ├── base_repository.py +│   ├── models.py +│   ├── plugin.py +│   └── settings.py +├── db_listener +│   ├── _db_comp_tasks_listening_task.py +│   ├── plugin.py +│   └── _utils.py +├── diagnostics +│   ├── _handlers.py +│   ├── _healthcheck.py +│   ├── _monitoring.py +│   ├── plugin.py +│   └── settings.py +├── director_v2 +│   ├── _abc.py +│   ├── api.py +│   ├── _api_utils.py +│   ├── _core_base.py +│   ├── _core_computations.py +│   ├── _core_dynamic_services.py +│   ├── _core_utils.py +│   ├── exceptions.py +│   ├── _handlers.py +│   ├── plugin.py +│   └── settings.py +├── dynamic_scheduler +│   ├── api.py +│   ├── plugin.py +│   └── settings.py +├── email +│   ├── _core.py +│   ├── _handlers.py +│   ├── plugin.py +│   ├── settings.py +│   └── utils.py +├── errors.py +├── exception_handling +│   ├── _base.py +│   └── _factory.py +├── exporter +│   ├── exceptions.py +│   ├── _formatter +│   │   ├── archive.py +│   │   ├── _sds.py +│   │   ├── template_json.py +│   │   └── xlsx +│   │   ├── code_description.py +│   │   ├── core +│   │   │   ├── styling_components.py +│   │   │   └── xlsx_base.py +│   │   ├── dataset_description.py +│   │   ├── manifest.py +│   │   ├── utils.py +│   │   └── writer.py +│   ├── _handlers.py +│   ├── plugin.py +│   ├── settings.py +│   └── utils.py +├── folders +│   ├── api.py +│   ├── errors.py +│   ├── _exceptions_handlers.py +│   ├── _folders_api.py +│   ├── _folders_db.py +│   ├── _folders_handlers.py +│   ├── _models.py +│   ├── plugin.py +│   ├── _trash_api.py +│   ├── _trash_handlers.py +│   ├── _workspaces_api.py +│   └── _workspaces_handlers.py +├── garbage_collector +│   ├── _core_disconnected.py +│   ├── _core_guests.py +│   ├── _core_orphans.py +│   ├── _core.py +│   ├── _core_utils.py +│   ├── plugin.py +│   ├── settings.py +│   ├── _tasks_api_keys.py +│   ├── _tasks_core.py +│   ├── _tasks_trash.py +│   └── _tasks_users.py +├── groups +│   ├── api.py +│   ├── _classifiers_api.py +│   ├── _classifiers_handlers.py +│   ├── _common +│   │   ├── exceptions_handlers.py +│   │   └── schemas.py +│   ├── exceptions.py +│   ├── _groups_api.py +│   ├── _groups_db.py +│   ├── _groups_handlers.py +│   └── plugin.py +├── invitations +│   ├── api.py +│   ├── _client.py +│   ├── _core.py +│   ├── errors.py +│   ├── plugin.py +│   └── settings.py +├── login +│   ├── _2fa_api.py +│   ├── _2fa_handlers.py +│   ├── _auth_api.py +│   ├── _auth_handlers.py +│   ├── cli.py +│   ├── _confirmation.py +│   ├── _constants.py +│   ├── decorators.py +│   ├── errors.py +│   ├── handlers_change.py +│   ├── handlers_confirmation.py +│   ├── handlers_registration.py +│   ├── _models.py +│   ├── plugin.py +│   ├── _registration_api.py +│   ├── _registration_handlers.py +│   ├── _registration.py +│   ├── _security.py +│   ├── settings.py +│   ├── _sql.py +│   ├── storage.py +│   ├── utils_email.py +│   └── utils.py +├── log.py +├── long_running_tasks.py +├── __main__.py +├── meta_modeling +│   ├── _function_nodes.py +│   ├── _handlers.py +│   ├── _iterations.py +│   ├── plugin.py +│   ├── _projects.py +│   ├── _results.py +│   └── _version_control.py +├── _meta.py +├── models.py +├── notifications +│   ├── plugin.py +│   ├── project_logs.py +│   ├── _rabbitmq_consumers_common.py +│   ├── _rabbitmq_exclusive_queue_consumers.py +│   ├── _rabbitmq_nonexclusive_queue_consumers.py +│   └── wallet_osparc_credits.py +├── payments +│   ├── api.py +│   ├── _autorecharge_api.py +│   ├── _autorecharge_db.py +│   ├── errors.py +│   ├── _events.py +│   ├── _methods_api.py +│   ├── _methods_db.py +│   ├── _onetime_api.py +│   ├── _onetime_db.py +│   ├── plugin.py +│   ├── _rpc_invoice.py +│   ├── _rpc.py +│   ├── settings.py +│   ├── _socketio.py +│   └── _tasks.py +├── products +│   ├── _api.py +│   ├── api.py +│   ├── _db.py +│   ├── errors.py +│   ├── _events.py +│   ├── _handlers.py +│   ├── _invitations_handlers.py +│   ├── _middlewares.py +│   ├── _model.py +│   ├── plugin.py +│   └── _rpc.py +├── projects +│   ├── _access_rights_api.py +│   ├── _access_rights_db.py +│   ├── api.py +│   ├── _comments_api.py +│   ├── _comments_db.py +│   ├── _comments_handlers.py +│   ├── _common_models.py +│   ├── _crud_api_create.py +│   ├── _crud_api_delete.py +│   ├── _crud_api_read.py +│   ├── _crud_handlers_models.py +│   ├── _crud_handlers.py +│   ├── db.py +│   ├── _db_utils.py +│   ├── exceptions.py +│   ├── _folders_api.py +│   ├── _folders_db.py +│   ├── _folders_handlers.py +│   ├── _groups_api.py +│   ├── _groups_db.py +│   ├── _groups_handlers.py +│   ├── lock.py +│   ├── _metadata_api.py +│   ├── _metadata_db.py +│   ├── _metadata_handlers.py +│   ├── models.py +│   ├── _nodes_api.py +│   ├── _nodes_handlers.py +│   ├── _nodes_utils.py +│   ├── nodes_utils.py +│   ├── _observer.py +│   ├── _permalink_api.py +│   ├── plugin.py +│   ├── _ports_api.py +│   ├── _ports_handlers.py +│   ├── _projects_access.py +│   ├── projects_api.py +│   ├── _projects_db.py +│   ├── _projects_nodes_pricing_unit_handlers.py +│   ├── settings.py +│   ├── _states_handlers.py +│   ├── _tags_api.py +│   ├── _tags_handlers.py +│   ├── _trash_api.py +│   ├── _trash_handlers.py +│   ├── utils.py +│   ├── _wallets_api.py +│   ├── _wallets_handlers.py +│   ├── _workspaces_api.py +│   └── _workspaces_handlers.py +├── publications +│   ├── _handlers.py +│   └── plugin.py +├── rabbitmq.py +├── rabbitmq_settings.py +├── redis.py +├── resource_manager +│   ├── _constants.py +│   ├── plugin.py +│   ├── registry.py +│   ├── settings.py +│   └── user_sessions.py +├── _resources.py +├── resource_usage +│   ├── api.py +│   ├── _client.py +│   ├── _constants.py +│   ├── errors.py +│   ├── _observer.py +│   ├── plugin.pyf +│   ├── _pricing_plans_admin_api.py +│   ├── _pricing_plans_admin_handlers.py +│   ├── _pricing_plans_api.py +│   ├── _pricing_plans_handlers.py +│   ├── _service_runs_api.py +│   ├── _service_runs_handlers.py +│   ├── settings.py +│   └── _utils.py +├── rest +│   ├── _handlers.py +│   ├── healthcheck.py +│   ├── plugin.py +│   ├── settings.py +│   └── _utils.py +├── scicrunch +│   ├── db.py +│   ├── errors.py +│   ├── models.py +│   ├── plugin.py +│   ├── _resolver.py +│   ├── _rest.py +│   ├── service_client.py +│   └── settings.py +├── security +│   ├── api.py +│   ├── _authz_access_model.py +│   ├── _authz_access_roles.py +│   ├── _authz_db.py +│   ├── _authz_policy.py +│   ├── _constants.py +│   ├── decorators.py +│   ├── _identity_api.py +│   ├── _identity_policy.py +│   └── plugin.py +├── session +│   ├── access_policies.py +│   ├── api.py +│   ├── _cookie_storage.py +│   ├── errors.py +│   ├── plugin.py +│   └── settings.py +├── socketio +│   ├── _handlers.py +│   ├── messages.py +│   ├── models.py +│   ├── _observer.py +│   ├── plugin.py +│   ├── server.py +│   └── _utils.py +├── statics +│   ├── _constants.py +│   ├── _events.py +│   ├── _handlers.py +│   ├── plugin.py +│   └── settings.py +├── storage +│   ├── api.py +│   ├── _handlers.py +│   ├── plugin.py +│   ├── schemas.py +│   └── settings.py +├── studies_dispatcher +│   ├── _catalog.py +│   ├── _constants.py +│   ├── _core.py +│   ├── _errors.py +│   ├── _models.py +│   ├── plugin.py +│   ├── _projects_permalinks.py +│   ├── _projects.py +│   ├── _redirects_handlers.py +│   ├── _rest_handlers.py +│   ├── settings.py +│   ├── _studies_access.py +│   └── _users.py +├── tags +│   ├── _api.py +│   ├── _handlers.py +│   ├── plugin.py +│   └── schemas.py +├── tracing.py +├── users +│   ├── _api.py +│   ├── api.py +│   ├── _constants.py +│   ├── _db.py +│   ├── exceptions.py +│   ├── _handlers.py +│   ├── _models.py +│   ├── _notifications_handlers.py +│   ├── _notifications.py +│   ├── plugin.py +│   ├── _preferences_api.py +│   ├── preferences_api.py +│   ├── _preferences_db.py +│   ├── _preferences_handlers.py +│   ├── _preferences_models.py +│   ├── _schemas.py +│   ├── schemas.py +│   ├── settings.py +│   ├── _tokens_handlers.py +│   └── _tokens.py +├── utils_aiohttp.py +├── utils.py +├── utils_rate_limiting.py +├── version_control +│   ├── _core.py +│   ├── db.py +│   ├── errors.py +│   ├── _handlers_base.py +│   ├── _handlers.py +│   ├── models.py +│   ├── plugin.py +│   ├── vc_changes.py +│   └── vc_tags.py +├── wallets +│   ├── _api.py +│   ├── api.py +│   ├── _constants.py +│   ├── _db.py +│   ├── errors.py +│   ├── _events.py +│   ├── _groups_api.py +│   ├── _groups_db.py +│   ├── _groups_handlers.py +│   ├── _handlers.py +│   ├── _payments_handlers.py +│   └── plugin.py +└── workspaces + ├── api.py + ├── errors.py + ├── _exceptions_handlers.py + ├── _groups_api.py + ├── _groups_db.py + ├── _groups_handlers.py + ├── _models.py + ├── plugin.py + ├── _trash_api.py + ├── _trash_handlers.py + ├── _workspaces_api.py + ├── _workspaces_db.py + └── _workspaces_handlers.py + + + + + +The top folders represent plugins that could be interprested as different domains with small compling between each other + +Here are some conventions + +- `plugin` has a setup function to setup the app (e.g. add routes, setup events etc ). Classic `setup_xxx(app)` for aiohttp +- `settings` includes pydantic settings classes specific to the domain +- `exceptions` or `errors` include only exceptions classes + - `_exceptions_handlers` are utils to handle exceptions +- `models` correspond to domain models, i.e. not part of any of the controllers interfaces. Those are denoted `scheme`. + +Then + +- `_handlers` (or _rest) represent the rest handlers (i.e. controller layer) +- `_rpc` contains handlers (server side) to an RPC interface (i.e. controller layer) +- `_api` (or `_service`) represent the business logic of this domain (i.e. service layer) + - the shared service layer for inter-domain logic is called `api` (i.e. without `_`) +- `_db` (or `_repository`) represents the repository layer + + +Could you please apply the rules on this structure and come up with a new tree that follows: +- keeps the domain-drive modular organization +- every domain implements controller-service-repository (CSR) +- highligh the shared service layer for inter-domain logic + + +As an output just recreate the tree adding some comments on it (e.g. with #) but no further explanatio is needed + + + +```plaintext +├── activity +│   ├── controllers +│   │   ├── rest_handlers.py # Rest API handlers (controller layer) +│   │   ├── rpc_handlers.py # RPC handlers (controller layer) +│   ├── services +│   │   ├── domain_service.py # Business logic for activity domain (service layer) +│   ├── repositories +│   │   ├── activity_repository.py # Data access logic (repository layer) +│   ├── plugin.py # Setup function for activity domain +│   ├── settings.py # Domain-specific settings +│   ├── exceptions.py # Domain-specific exceptions +├── announcements +│   ├── controllers +│   │   ├── rest_handlers.py # Rest API handlers (controller layer) +│   │   ├── rpc_handlers.py # RPC handlers (controller layer) +│   ├── services +│   │   ├── domain_service.py # Business logic for announcements domain (service layer) +│   ├── repositories +│   │   ├── announcements_repository.py # Data access logic (repository layer) +│   ├── plugin.py # Setup function for announcements domain +│   ├── settings.py # Domain-specific settings +│   ├── exceptions.py # Domain-specific exceptions +├── api_keys +│   ├── controllers +│   │   ├── rest_handlers.py # Rest API handlers (controller layer) +│   │   ├── rpc_handlers.py # RPC handlers (controller layer) +│   ├── services +│   │   ├── domain_service.py # Business logic for api_keys domain (service layer) +│   ├── repositories +│   │   ├── api_keys_repository.py # Data access logic (repository layer) +│   ├── plugin.py # Setup function for api_keys domain +│   ├── settings.py # Domain-specific settings +│   ├── exceptions.py # Domain-specific exceptions +├── shared_services +│   ├── inter_domain_service.py # Shared service layer for inter-domain logic +├── db +│   ├── connection.py # Database connection logic +│   ├── base_repository.py # Base repository logic +│   ├── plugin.py # Setup function for database +│   ├── settings.py # Database-specific settings +├── catalog +│   ├── controllers +│   │   ├── rest_handlers.py # Rest API handlers (controller layer) +│   │   ├── rpc_handlers.py # RPC handlers (controller layer) +│   ├── services +│   │   ├── domain_service.py # Business logic for catalog domain (service layer) +│   ├── repositories +│   │   ├── catalog_repository.py # Data access logic (repository layer) +│   ├── plugin.py # Setup function for catalog domain +│   ├── settings.py # Domain-specific settings +│   ├── exceptions.py # Domain-specific exceptions +├── users +│   ├── controllers +│   │   ├── rest_handlers.py # Rest API handlers (controller layer) +│   │   ├── rpc_handlers.py # RPC handlers (controller layer) +│   ├── services +│   │   ├── domain_service.py # Business logic for users domain (service layer) +│   ├── repositories +│   │   ├── users_repository.py # Data access logic (repository layer) +│   ├── plugin.py # Setup function for users domain +│   ├── settings.py # Domain-specific settings +│   ├── exceptions.py # Domain-specific exceptions +├── projects +│   ├── controllers +│   │   ├── rest_handlers.py # Rest API handlers (controller layer) +│   │   ├── rpc_handlers.py # RPC handlers (controller layer) +│   ├── services +│   │   ├── domain_service.py # Business logic for projects domain (service layer) +│   ├── repositories +│   │   ├── projects_repository.py # Data access logic (repository layer) +│   ├── plugin.py # Setup function for projects domain +│   ├── settings.py # Domain-specific settings +│   ├── exceptions.py # Domain-specific exceptions +├── shared +│   ├── models +│   │   ├── user.py # Shared user model +│   │   ├── project.py # Shared project model +│   ├── schemas +│   │   ├── user_schema.py # Shared user schemas +│   │   ├── project_schema.py # Shared project schemas +│   ├── utils +│   │   ├── logger.py # Shared logging logic +│   │   ├── validators.py # Shared validation logic +├── application.py # Main application initialization +└── cli.py # Command-line interface logic +``` diff --git a/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_crud.py b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_crud.py index 3bcd99593aa..5adaf33d9af 100644 --- a/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_crud.py +++ b/services/web/server/tests/unit/with_dbs/01/groups/test_groups_handlers_crud.py @@ -71,15 +71,17 @@ async def test_list_user_groups_and_try_modify_organizations( my_groups = MyGroupsGet.model_validate(data) assert not error - assert my_groups.me.model_dump() == primary_group - assert my_groups.all.model_dump() == all_group + assert my_groups.me.model_dump(by_alias=True) == primary_group + assert my_groups.all.model_dump(by_alias=True) == all_group assert my_groups.organizations assert len(my_groups.organizations) == len(standard_groups) by_gid = operator.itemgetter("gid") assert sorted( - TypeAdapter(list[GroupGet]).dump_python(my_groups.organizations, mode="json"), + TypeAdapter(list[GroupGet]).dump_python( + my_groups.organizations, mode="json", by_alias=True + ), key=by_gid, ) == sorted(standard_groups, key=by_gid)