From 5750bbd514a19da3695b995c39a43be79daa4de2 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 4 Dec 2024 16:02:04 +0100 Subject: [PATCH 01/36] return username in profile --- .../simcore_service_webserver/users/api.py | 2 ++ .../users/schemas.py | 11 +++++++++- .../tests/unit/isolated/test_users_models.py | 20 +++++++++++++------ 3 files changed, 26 insertions(+), 7 deletions(-) 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 50dfdc4e12d..023697e368a 100644 --- a/services/web/server/src/simcore_service_webserver/users/api.py +++ b/services/web/server/src/simcore_service_webserver/users/api.py @@ -77,6 +77,7 @@ async def get_user_profile( if not user_profile: user_profile = { "id": row.users_id, + "user_name": row.name, "first_name": row.users_first_name, "last_name": row.users_last_name, "login": row.users_email, @@ -128,6 +129,7 @@ async def get_user_profile( return ProfileGet( id=user_profile["id"], + user_name=user_profile["user_name"], first_name=user_profile["first_name"], last_name=user_profile["last_name"], login=user_profile["login"], diff --git a/services/web/server/src/simcore_service_webserver/users/schemas.py b/services/web/server/src/simcore_service_webserver/users/schemas.py index e2da8da8ed7..9c4859ee106 100644 --- a/services/web/server/src/simcore_service_webserver/users/schemas.py +++ b/services/web/server/src/simcore_service_webserver/users/schemas.py @@ -1,10 +1,11 @@ from datetime import date -from typing import Literal +from typing import Annotated, Literal from uuid import UUID from models_library.api_schemas_webserver._base import OutputSchema from models_library.api_schemas_webserver.groups import MyGroupsGet from models_library.api_schemas_webserver.users_preferences import AggregatedPreferences +from models_library.basic_types import IDStr from models_library.emails import LowerCaseEmailStr from models_library.users import FirstNameStr, LastNameStr, UserID from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator @@ -62,17 +63,23 @@ class ProfileUpdate(BaseModel): class ProfileGet(BaseModel): id: UserID + user_name: Annotated[ + IDStr, Field(description="Unique username identifier", alias="userName") + ] first_name: FirstNameStr | None = None last_name: LastNameStr | None = None login: LowerCaseEmailStr + role: Literal["ANONYMOUS", "GUEST", "USER", "TESTER", "PRODUCT_OWNER", "ADMIN"] groups: MyGroupsGet | None = None gravatar_id: str | None = None + expiration_date: date | None = Field( default=None, description="If user has a trial account, it sets the expiration date, otherwise None", alias="expirationDate", ) + preferences: AggregatedPreferences model_config = ConfigDict( @@ -84,6 +91,7 @@ class ProfileGet(BaseModel): { "id": 1, "login": "bla@foo.com", + "userName": "bla123", "role": "Admin", "gravatar_id": "205e460b479e2e5b48aec07710c08d50", "preferences": {}, @@ -91,6 +99,7 @@ class ProfileGet(BaseModel): { "id": 42, "login": "bla@foo.com", + "userName": "bla123", "role": UserRole.ADMIN.value, "expirationDate": "2022-09-14", "preferences": {}, 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 a5670b5054e..859ae235337 100644 --- a/services/web/server/tests/unit/isolated/test_users_models.py +++ b/services/web/server/tests/unit/isolated/test_users_models.py @@ -42,9 +42,12 @@ def test_user_models_examples( def test_profile_get_expiration_date(faker: Faker): fake_expiration = datetime.now(UTC) + fake_profile: dict[str, Any] = faker.simple_profile() + profile = ProfileGet( - id=1, - login=faker.email(), + id=faker.pyint(), + user_name=fake_profile["username"], + login=fake_profile["mail"], role=UserRole.ADMIN, expiration_date=fake_expiration.date(), preferences={}, @@ -58,11 +61,15 @@ def test_profile_get_expiration_date(faker: Faker): def test_auto_compute_gravatar(faker: Faker): + fake_profile: dict[str, Any] = faker.simple_profile() + first_name, last_name = fake_profile["name"].rsplit(maxsplit=1) + profile = ProfileGet( id=faker.pyint(), - first_name=faker.first_name(), - last_name=faker.last_name(), - login=faker.email(), + user_name=fake_profile["username"], + first_name=first_name, + last_name=last_name, + login=fake_profile["mail"], role="USER", preferences={}, ) @@ -81,7 +88,7 @@ def test_auto_compute_gravatar(faker: Faker): @pytest.mark.parametrize("user_role", [u.name for u in UserRole]) def test_profile_get_role(user_role: str): - for example in ProfileGet.model_config["json_schema_extra"]["examples"]: + for example in ProfileGet.model_json_schema()["examples"]: data = deepcopy(example) data["role"] = user_role m1 = ProfileGet(**data) @@ -95,6 +102,7 @@ def test_parsing_output_of_get_user_profile(): result_from_db_query_and_composition = { "id": 1, "login": "PtN5Ab0uv@guest-at-osparc.io", + "userName": "PtN5Ab0uv", "first_name": "PtN5Ab0uv", "last_name": "", "role": "Guest", From 1233bd154dad71541bb29b26ef29f87ff8e209fe Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 4 Dec 2024 16:13:30 +0100 Subject: [PATCH 02/36] updates OAS --- .../src/simcore_service_webserver/api/v0/openapi.yaml | 7 +++++++ 1 file changed, 7 insertions(+) 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 485fb15c931..e1f57583388 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 @@ -11520,6 +11520,12 @@ components: exclusiveMinimum: true title: Id minimum: 0 + userName: + type: string + maxLength: 100 + minLength: 1 + title: Username + description: Unique username identifier first_name: anyOf: - type: string @@ -11571,6 +11577,7 @@ components: type: object required: - id + - userName - login - role - preferences From 8b7e0b44525aa7e2188d027ff22cb72ee7036947 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 4 Dec 2024 16:13:58 +0100 Subject: [PATCH 03/36] =?UTF-8?q?services/webserver=20api=20version:=200.4?= =?UTF-8?q?7.0=20=E2=86=92=200.48.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 421ab545d9a..a758a09aae5 100644 --- a/services/web/server/VERSION +++ b/services/web/server/VERSION @@ -1 +1 @@ -0.47.0 +0.48.0 diff --git a/services/web/server/setup.cfg b/services/web/server/setup.cfg index 628685e0ad5..0b6157ef959 100644 --- a/services/web/server/setup.cfg +++ b/services/web/server/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.47.0 +current_version = 0.48.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 e1f57583388..799022ae764 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.47.0 + version: 0.48.0 servers: - url: '' description: webserver From 17bed9779a053cccf4062678b1f6a0c19bf40e3e Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 4 Dec 2024 16:23:20 +0100 Subject: [PATCH 04/36] cleanup --- .../users/schemas.py | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/users/schemas.py b/services/web/server/src/simcore_service_webserver/users/schemas.py index 9c4859ee106..be2a5b7b039 100644 --- a/services/web/server/src/simcore_service_webserver/users/schemas.py +++ b/services/web/server/src/simcore_service_webserver/users/schemas.py @@ -47,20 +47,6 @@ class TokenCreate(ThirdPartyToken): # -class ProfileUpdate(BaseModel): - first_name: FirstNameStr | None = None - last_name: LastNameStr | None = None - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "first_name": "Pedro", - "last_name": "Crespo", - } - } - ) - - class ProfileGet(BaseModel): id: UserID user_name: Annotated[ @@ -127,6 +113,20 @@ def _to_upper_string(cls, v): return v +class ProfileUpdate(BaseModel): + first_name: FirstNameStr | None = None + last_name: LastNameStr | None = None + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "first_name": "Pedro", + "last_name": "Crespo", + } + } + ) + + # # Permissions # From 6337748b54ba128bfccbbf94e0930bb68b978f19 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 4 Dec 2024 17:36:54 +0100 Subject: [PATCH 05/36] cleanup tests --- .../simcore_service_webserver/users/api.py | 2 +- .../tests/unit/with_dbs/03/test_users.py | 153 ++++++++++-------- 2 files changed, 90 insertions(+), 65 deletions(-) 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 023697e368a..9fc6198191d 100644 --- a/services/web/server/src/simcore_service_webserver/users/api.py +++ b/services/web/server/src/simcore_service_webserver/users/api.py @@ -77,7 +77,7 @@ async def get_user_profile( if not user_profile: user_profile = { "id": row.users_id, - "user_name": row.name, + "user_name": row.users_name, "first_name": row.users_first_name, "last_name": row.users_last_name, "login": row.users_email, 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 391053b40ac..43f50f60077 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 @@ -59,16 +59,55 @@ def app_environment( "user_role,expected", [ (UserRole.ANONYMOUS, status.HTTP_401_UNAUTHORIZED), - (UserRole.GUEST, status.HTTP_200_OK), - (UserRole.USER, status.HTTP_200_OK), - (UserRole.TESTER, status.HTTP_200_OK), + *((r, status.HTTP_200_OK) for r in UserRole if r >= UserRole.GUEST), ], ) -async def test_get_profile( +async def test_access_rights_on_get_profile( + user_role: UserRole, + logged_user: UserInfoDict, + client: TestClient, + expected: HTTPStatus, +): + assert client.app + + url = client.app.router["get_my_profile"].url_for() + assert url.path == "/v0/me" + + resp = await client.get(f"{url}") + await assert_status(resp, expected) + + +@pytest.mark.parametrize( + "user_role,expected", + [ + (UserRole.ANONYMOUS, status.HTTP_401_UNAUTHORIZED), + (UserRole.GUEST, status.HTTP_403_FORBIDDEN), + *((r, status.HTTP_204_NO_CONTENT) for r in UserRole if r >= UserRole.USER), + ], +) +async def test_access_update_profile( logged_user: UserInfoDict, client: TestClient, user_role: UserRole, expected: HTTPStatus, +): + assert client.app + + url = client.app.router["update_my_profile"].url_for() + assert url.path == "/v0/me" + + resp = await client.put(f"{url}", json={"last_name": "Foo"}) + await assert_status(resp, expected) + + +@pytest.mark.parametrize( + "user_role", + [UserRole.USER], +) +async def test_get_profile( + logged_user: UserInfoDict, + client: TestClient, + user_role: UserRole, primary_group: dict[str, Any], standard_groups: list[dict[str, Any]], all_group: dict[str, str], @@ -78,84 +117,70 @@ async def test_get_profile( url = client.app.router["get_my_profile"].url_for() assert url.path == "/v0/me" - resp = await client.get(url.path) - data, error = await assert_status(resp, expected) + resp = await client.get(f"{url}") + data, error = await assert_status(resp, status.HTTP_200_OK) - # check enveloped - e = Envelope[ProfileGet].model_validate(await resp.json()) - assert e.error == error - assert ( - e.data.model_dump(**RESPONSE_MODEL_POLICY, mode="json") == data - if e.data - else e.data == data - ) + resp_model = Envelope[ProfileGet].model_validate(await resp.json()) - if not error: - profile = ProfileGet.model_validate(data) - - product_group = { - "accessRights": {"delete": False, "read": False, "write": False}, - "description": "osparc product group", - "gid": 2, - "inclusionRules": {}, - "label": "osparc", - "thumbnail": None, - } - - assert profile.login == logged_user["email"] - assert profile.gravatar_id - assert profile.first_name == logged_user.get("first_name", None) - assert profile.last_name == logged_user.get("last_name", None) - assert profile.role == user_role.name - assert profile.groups - - got_profile_groups = profile.groups.model_dump( - **RESPONSE_MODEL_POLICY, mode="json" - ) - assert got_profile_groups["me"] == primary_group - assert got_profile_groups["all"] == all_group + assert resp_model.data.model_dump(**RESPONSE_MODEL_POLICY, mode="json") == data + assert resp_model.error is None - sorted_by_group_id = functools.partial(sorted, key=lambda d: d["gid"]) - assert sorted_by_group_id( - got_profile_groups["organizations"] - ) == sorted_by_group_id([*standard_groups, product_group]) + profile = resp_model.data - assert profile.preferences == await get_frontend_user_preferences_aggregation( - client.app, user_id=logged_user["id"], product_name="osparc" - ) + product_group = { + "accessRights": {"delete": False, "read": False, "write": False}, + "description": "osparc product group", + "gid": 2, + "inclusionRules": {}, + "label": "osparc", + "thumbnail": None, + } + + assert profile.login == logged_user["email"] + assert profile.gravatar_id + assert profile.first_name == logged_user.get("first_name", None) + assert profile.last_name == logged_user.get("last_name", None) + assert profile.role == user_role.name + assert profile.groups + + got_profile_groups = profile.groups.model_dump(**RESPONSE_MODEL_POLICY, mode="json") + assert got_profile_groups["me"] == primary_group + assert got_profile_groups["all"] == all_group + + sorted_by_group_id = functools.partial(sorted, key=lambda d: d["gid"]) + assert sorted_by_group_id( + got_profile_groups["organizations"] + ) == sorted_by_group_id([*standard_groups, product_group]) + + assert profile.preferences == await get_frontend_user_preferences_aggregation( + client.app, user_id=logged_user["id"], product_name="osparc" + ) @pytest.mark.parametrize( - "user_role,expected", - [ - (UserRole.ANONYMOUS, status.HTTP_401_UNAUTHORIZED), - (UserRole.GUEST, status.HTTP_403_FORBIDDEN), - (UserRole.USER, status.HTTP_204_NO_CONTENT), - (UserRole.TESTER, status.HTTP_204_NO_CONTENT), - ], + "user_role", + [UserRole.USER], ) async def test_update_profile( logged_user: UserInfoDict, client: TestClient, user_role: UserRole, - expected: HTTPStatus, ): assert client.app url = client.app.router["update_my_profile"].url_for() assert url.path == "/v0/me" + resp = await client.put(f"{url}", json={"last_name": "Foo"}) + _, error = await assert_status(resp, status.HTTP_204_NO_CONTENT) - resp = await client.put(url.path, json={"last_name": "Foo"}) - _, error = await assert_status(resp, expected) - - if not error: - resp = await client.get(f"{url}") - data, _ = await assert_status(resp, status.HTTP_200_OK) + assert not error + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) - # This is a PUT! i.e. full replace of profile variable fields! - assert data["first_name"] == ProfileUpdate.model_fields["first_name"].default - assert data["last_name"] == "Foo" - assert data["role"] == user_role.name + # This is a PUT! i.e. full replace of profile variable fields! + assert data["first_name"] == ProfileUpdate.model_fields["first_name"].default + assert data["last_name"] == "Foo" + assert data["role"] == user_role.name @pytest.fixture @@ -219,7 +244,7 @@ async def test_get_profile_with_failing_db_connection( (UserRole.PRODUCT_OWNER, status.HTTP_200_OK), ], ) -async def test_only_product_owners_can_access_users_api( +async def test_access_rights_on_search_users_only_product_owners_can_access( client: TestClient, logged_user: UserInfoDict, expected: HTTPStatus, From 2eb0d9143cbc097e05c30c664571528026dff149 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 4 Dec 2024 17:55:39 +0100 Subject: [PATCH 06/36] patch --- api/specs/web-server/_users.py | 2 +- .../client/source/class/osparc/desktop/account/ProfilePage.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/specs/web-server/_users.py b/api/specs/web-server/_users.py index f1204f15f35..da4f3a95209 100644 --- a/api/specs/web-server/_users.py +++ b/api/specs/web-server/_users.py @@ -41,7 +41,7 @@ async def get_my_profile(): ... -@router.put( +@router.patch( "/me", status_code=status.HTTP_204_NO_CONTENT, ) diff --git a/services/static-webserver/client/source/class/osparc/desktop/account/ProfilePage.js b/services/static-webserver/client/source/class/osparc/desktop/account/ProfilePage.js index 465e4f0f391..a4f9b773ea3 100644 --- a/services/static-webserver/client/source/class/osparc/desktop/account/ProfilePage.js +++ b/services/static-webserver/client/source/class/osparc/desktop/account/ProfilePage.js @@ -156,7 +156,7 @@ qx.Class.define("osparc.desktop.account.ProfilePage", { if (this.__userProfileData["first_name"] !== model.getFirstName() || this.__userProfileData["last_name"] !== model.getLastName()) { if (namesValidator.validate()) { - const profileReq = new osparc.io.request.ApiRequest("/me", "PUT"); + const profileReq = new osparc.io.request.ApiRequest("/me", "PATCH"); profileReq.setRequestData({ "first_name": model.getFirstName(), "last_name": model.getLastName() From 78b93ec8f2bbe053fc85e005cc5a407bb129ea70 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 4 Dec 2024 18:16:12 +0100 Subject: [PATCH 07/36] new table --- .../590cbd7e27bf_users_privacy_table.py | 42 +++++++++++++++++++ .../models/users_privacy.py | 36 ++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 packages/postgres-database/src/simcore_postgres_database/migration/versions/590cbd7e27bf_users_privacy_table.py create mode 100644 packages/postgres-database/src/simcore_postgres_database/models/users_privacy.py diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/590cbd7e27bf_users_privacy_table.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/590cbd7e27bf_users_privacy_table.py new file mode 100644 index 00000000000..a13a675598c --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/590cbd7e27bf_users_privacy_table.py @@ -0,0 +1,42 @@ +"""users privacy table + +Revision ID: 590cbd7e27bf +Revises: e05bdc5b3c7b +Create Date: 2024-12-04 17:15:55.093862+00:00 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "590cbd7e27bf" +down_revision = "e05bdc5b3c7b" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "users_privacy", + sa.Column("user_id", sa.Integer(), nullable=True), + sa.Column( + "hide_email", sa.Boolean(), server_default=sa.text("true"), nullable=False + ), + sa.Column( + "hide_fullname", + sa.Boolean(), + server_default=sa.text("true"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["user_id"], ["users.id"], onupdate="CASCADE", ondelete="CASCADE" + ), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("users_privacy") + # ### end Alembic commands ### diff --git a/packages/postgres-database/src/simcore_postgres_database/models/users_privacy.py b/packages/postgres-database/src/simcore_postgres_database/models/users_privacy.py new file mode 100644 index 00000000000..01285c1517e --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/models/users_privacy.py @@ -0,0 +1,36 @@ +import sqlalchemy as sa +from sqlalchemy.sql import expression + +from ._common import RefActions +from .base import metadata +from .users import users + +users_privacy = sa.Table( + "users_privacy", + metadata, + sa.Column( + "user_id", + sa.Integer, + sa.ForeignKey( + users.c.id, + onupdate=RefActions.CASCADE, + ondelete=RefActions.CASCADE, + ), + nullable=True, + ), + # Hide info + sa.Column( + "hide_email", + sa.Boolean, + nullable=False, + server_default=expression.true(), + doc="If true, it hides users.email", + ), + sa.Column( + "hide_fullname", + sa.Boolean, + nullable=False, + server_default=expression.true(), + doc="If true, it hides users.first_name, users.last_name", + ), +) From 23aa9c4c104fd1a5f863f81b6c48dbe9ec16619b Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Wed, 4 Dec 2024 18:16:41 +0100 Subject: [PATCH 08/36] adding privacy --- .../src/simcore_service_webserver/users/_models.py | 14 ++++++++++++++ .../src/simcore_service_webserver/users/api.py | 4 +++- .../src/simcore_service_webserver/users/schemas.py | 6 ++++++ 3 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 services/web/server/src/simcore_service_webserver/users/_models.py diff --git a/services/web/server/src/simcore_service_webserver/users/_models.py b/services/web/server/src/simcore_service_webserver/users/_models.py new file mode 100644 index 00000000000..656bbb587a8 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/users/_models.py @@ -0,0 +1,14 @@ +from pydantic import BaseModel + + +# +# REST models +# +class ProfilePrivacyGet(BaseModel): + hide_fullname: bool + hide_email: bool + + +class ProfilePrivacyUpdate(BaseModel): + hide_fullname: bool | None = None + hide_email: bool | None = None 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 9fc6198191d..7ab28bafcf0 100644 --- a/services/web/server/src/simcore_service_webserver/users/api.py +++ b/services/web/server/src/simcore_service_webserver/users/api.py @@ -65,7 +65,9 @@ async def get_user_profile( sa.join( users, sa.join( - user_to_groups, groups, user_to_groups.c.gid == groups.c.gid + user_to_groups, + groups, + user_to_groups.c.gid == groups.c.gid, ), users.c.id == user_to_groups.c.uid, ) diff --git a/services/web/server/src/simcore_service_webserver/users/schemas.py b/services/web/server/src/simcore_service_webserver/users/schemas.py index be2a5b7b039..f7decdd9d7d 100644 --- a/services/web/server/src/simcore_service_webserver/users/schemas.py +++ b/services/web/server/src/simcore_service_webserver/users/schemas.py @@ -12,6 +12,7 @@ from simcore_postgres_database.models.users import UserRole from ..utils import gravatar_hash +from ._models import ProfilePrivacyGet, ProfilePrivacyUpdate # @@ -66,6 +67,7 @@ class ProfileGet(BaseModel): alias="expirationDate", ) + privacy: ProfilePrivacyGet = ProfilePrivacyGet(hide_fullname=True, hide_email=True) preferences: AggregatedPreferences model_config = ConfigDict( @@ -74,6 +76,7 @@ class ProfileGet(BaseModel): populate_by_name=True, json_schema_extra={ "examples": [ + # 1. with gravatar { "id": 1, "login": "bla@foo.com", @@ -82,6 +85,7 @@ class ProfileGet(BaseModel): "gravatar_id": "205e460b479e2e5b48aec07710c08d50", "preferences": {}, }, + # 2. with expiration date { "id": 42, "login": "bla@foo.com", @@ -117,6 +121,8 @@ class ProfileUpdate(BaseModel): first_name: FirstNameStr | None = None last_name: LastNameStr | None = None + privacy: ProfilePrivacyUpdate | None = None + model_config = ConfigDict( json_schema_extra={ "example": { From 6d54ef3b954f9f352346222b83c74e5fc01a63f1 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 5 Dec 2024 10:48:50 +0100 Subject: [PATCH 09/36] patch --- .../src/simcore_service_api_server/services/webserver.py | 2 +- .../server/src/simcore_service_webserver/users/_handlers.py | 2 +- .../web/server/src/simcore_service_webserver/users/api.py | 5 +---- services/web/server/tests/unit/with_dbs/03/test_users.py | 4 ++-- 4 files changed, 5 insertions(+), 8 deletions(-) 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 cba31689654..26313d03230 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 @@ -251,7 +251,7 @@ async def get_me(self) -> Profile: @_exception_mapper(_PROFILE_STATUS_MAP) async def update_me(self, *, profile_update: ProfileUpdate) -> Profile: - response = await self.client.put( + response = await self.client.patch( "/me", json=profile_update.model_dump(exclude_none=True), cookies=self.session_cookies, 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 4d69e9ffaab..f03afa53046 100644 --- a/services/web/server/src/simcore_service_webserver/users/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/users/_handlers.py @@ -75,7 +75,7 @@ async def get_my_profile(request: web.Request) -> web.Response: return envelope_json_response(profile) -@routes.put(f"/{API_VTAG}/me", name="update_my_profile") +@routes.patch(f"/{API_VTAG}/me", name="update_my_profile") @login_required @permission_required("user.profile.update") @_handle_users_exceptions 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 7ab28bafcf0..00fd775de65 100644 --- a/services/web/server/src/simcore_service_webserver/users/api.py +++ b/services/web/server/src/simcore_service_webserver/users/api.py @@ -164,10 +164,7 @@ async def update_user_profile( async with get_database_engine(app).acquire() as conn: to_update = update.model_dump( - include={ - "first_name", - "last_name", - }, + include={"first_name", "last_name"}, exclude_unset=as_patch, ) resp = await conn.execute( 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 43f50f60077..b06b887a6b1 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 @@ -96,7 +96,7 @@ async def test_access_update_profile( url = client.app.router["update_my_profile"].url_for() assert url.path == "/v0/me" - resp = await client.put(f"{url}", json={"last_name": "Foo"}) + resp = await client.patch(f"{url}", json={"last_name": "Foo"}) await assert_status(resp, expected) @@ -170,7 +170,7 @@ async def test_update_profile( url = client.app.router["update_my_profile"].url_for() assert url.path == "/v0/me" - resp = await client.put(f"{url}", json={"last_name": "Foo"}) + resp = await client.patch(f"{url}", json={"last_name": "Foo"}) _, error = await assert_status(resp, status.HTTP_204_NO_CONTENT) assert not error From be44ee4c6fad6c02545087ec7711cb5c9affa35e Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 5 Dec 2024 14:35:58 +0100 Subject: [PATCH 10/36] move privacy cols to user --- .../590cbd7e27bf_users_privacy_table.py | 42 ------------- .../simcore_postgres_database/models/users.py | 62 ++++++++++++++----- .../models/users_privacy.py | 36 ----------- 3 files changed, 48 insertions(+), 92 deletions(-) delete mode 100644 packages/postgres-database/src/simcore_postgres_database/migration/versions/590cbd7e27bf_users_privacy_table.py delete mode 100644 packages/postgres-database/src/simcore_postgres_database/models/users_privacy.py diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/590cbd7e27bf_users_privacy_table.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/590cbd7e27bf_users_privacy_table.py deleted file mode 100644 index a13a675598c..00000000000 --- a/packages/postgres-database/src/simcore_postgres_database/migration/versions/590cbd7e27bf_users_privacy_table.py +++ /dev/null @@ -1,42 +0,0 @@ -"""users privacy table - -Revision ID: 590cbd7e27bf -Revises: e05bdc5b3c7b -Create Date: 2024-12-04 17:15:55.093862+00:00 - -""" -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision = "590cbd7e27bf" -down_revision = "e05bdc5b3c7b" -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table( - "users_privacy", - sa.Column("user_id", sa.Integer(), nullable=True), - sa.Column( - "hide_email", sa.Boolean(), server_default=sa.text("true"), nullable=False - ), - sa.Column( - "hide_fullname", - sa.Boolean(), - server_default=sa.text("true"), - nullable=False, - ), - sa.ForeignKeyConstraint( - ["user_id"], ["users.id"], onupdate="CASCADE", ondelete="CASCADE" - ), - ) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table("users_privacy") - # ### end Alembic commands ### 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 61b8c321130..d42568d772f 100644 --- a/packages/postgres-database/src/simcore_postgres_database/models/users.py +++ b/packages/postgres-database/src/simcore_postgres_database/models/users.py @@ -2,6 +2,7 @@ from functools import total_ordering import sqlalchemy as sa +from sqlalchemy.sql import expression from ._common import RefActions from .base import metadata @@ -67,6 +68,9 @@ class UserStatus(str, Enum): users = sa.Table( "users", metadata, + # + # User Identifiers ------------------ + # sa.Column( "id", sa.BigInteger(), @@ -77,8 +81,23 @@ class UserStatus(str, Enum): "name", sa.String(), nullable=False, - doc="username is a unique short user friendly identifier e.g. pcrespov, sanderegg, GitHK, ...", + doc="username is a unique short user friendly identifier e.g. pcrespov, sanderegg, GitHK, ..." + "This identifier **is public**.", ), + sa.Column( + "primary_gid", + sa.BigInteger(), + sa.ForeignKey( + "groups.gid", + name="fk_users_gid_groups", + onupdate=RefActions.CASCADE, + ondelete=RefActions.RESTRICT, + ), + doc="User's group ID", + ), + # + # User Information ------------------ + # sa.Column( "first_name", sa.String(), @@ -102,37 +121,52 @@ class UserStatus(str, Enum): doc="Confirmed user phone used e.g. to send a code for a two-factor-authentication." "NOTE: new policy (NK) is that the same phone can be reused therefore it does not has to be unique", ), + # + # User Secrets ------------------ + # sa.Column( "password_hash", sa.String(), nullable=False, doc="Hashed password", ), - sa.Column( - "primary_gid", - sa.BigInteger(), - sa.ForeignKey( - "groups.gid", - name="fk_users_gid_groups", - onupdate=RefActions.CASCADE, - ondelete=RefActions.RESTRICT, - ), - doc="User's group ID", - ), + # + # User Account ------------------ + # sa.Column( "status", sa.Enum(UserStatus), nullable=False, default=UserStatus.CONFIRMATION_PENDING, - doc="Status of the user account. SEE UserStatus", + doc="Current status of the user's account", ), sa.Column( "role", sa.Enum(UserRole), nullable=False, default=UserRole.USER, - doc="Use for role-base authorization", + doc="Used for role-base authorization", + ), + # + # User Privacy Rules ------------------ + # + sa.Column( + "privacy_hide_fullname", + sa.Boolean, + nullable=False, + server_default=expression.true(), + doc="If true, it hides users.first_name, users.last_name to others", + ), + sa.Column( + "privacy_hide_email", + sa.Boolean, + nullable=False, + server_default=expression.true(), + doc="If true, it hides users.email to others", ), + # + # Timestamps --------------- + # sa.Column( "created_at", sa.DateTime(), diff --git a/packages/postgres-database/src/simcore_postgres_database/models/users_privacy.py b/packages/postgres-database/src/simcore_postgres_database/models/users_privacy.py deleted file mode 100644 index 01285c1517e..00000000000 --- a/packages/postgres-database/src/simcore_postgres_database/models/users_privacy.py +++ /dev/null @@ -1,36 +0,0 @@ -import sqlalchemy as sa -from sqlalchemy.sql import expression - -from ._common import RefActions -from .base import metadata -from .users import users - -users_privacy = sa.Table( - "users_privacy", - metadata, - sa.Column( - "user_id", - sa.Integer, - sa.ForeignKey( - users.c.id, - onupdate=RefActions.CASCADE, - ondelete=RefActions.CASCADE, - ), - nullable=True, - ), - # Hide info - sa.Column( - "hide_email", - sa.Boolean, - nullable=False, - server_default=expression.true(), - doc="If true, it hides users.email", - ), - sa.Column( - "hide_fullname", - sa.Boolean, - nullable=False, - server_default=expression.true(), - doc="If true, it hides users.first_name, users.last_name", - ), -) From ad92c5a1d2fa6ac2829ac51d0603706d4dea7c28 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 5 Dec 2024 14:52:39 +0100 Subject: [PATCH 11/36] updates users plugin --- .../users/_handlers.py | 2 +- .../simcore_service_webserver/users/api.py | 41 ++++++++----------- .../users/schemas.py | 17 +++++++- 3 files changed, 33 insertions(+), 27 deletions(-) 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 f03afa53046..e9cb4ad0b9b 100644 --- a/services/web/server/src/simcore_service_webserver/users/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/users/_handlers.py @@ -83,7 +83,7 @@ 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) await api.update_user_profile( - request.app, req_ctx.user_id, profile_update, as_patch=False + request.app, user_id=req_ctx.user_id, update=profile_update ) return web.json_response(status=status.HTTP_204_NO_CONTENT) 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 00fd775de65..c6adc5330b0 100644 --- a/services/web/server/src/simcore_service_webserver/users/api.py +++ b/services/web/server/src/simcore_service_webserver/users/api.py @@ -17,12 +17,13 @@ from models_library.products import ProductName from models_library.users import GroupID, UserID from pydantic import EmailStr, TypeAdapter, ValidationError -from simcore_postgres_database.models.users import UserRole +from simcore_postgres_database.models.groups import GroupType, groups, user_to_groups +from simcore_postgres_database.models.users import UserRole, users from simcore_postgres_database.utils_groups_extra_properties import ( GroupExtraPropertiesNotFoundError, ) +from simcore_service_webserver.users._models import ProfilePrivacyGet -from ..db.models import GroupType, groups, user_to_groups, users from ..db.plugin import get_database_engine from ..groups.models import convert_groups_db_to_schema from ..login.storage import AsyncpgStorage, get_plugin_storage @@ -59,17 +60,12 @@ async def get_user_profile( async with engine.acquire() as conn: row: RowProxy + async for row in conn.execute( sa.select(users, groups, user_to_groups.c.access_rights) .select_from( - sa.join( - users, - sa.join( - user_to_groups, - groups, - user_to_groups.c.gid == groups.c.gid, - ), - users.c.id == user_to_groups.c.uid, + users.join(user_to_groups, users.c.id == user_to_groups.c.uid).join( + groups, user_to_groups.c.gid == groups.c.gid ) ) .where(users.c.id == user_id) @@ -84,6 +80,8 @@ async def get_user_profile( "last_name": row.users_last_name, "login": row.users_email, "role": row.users_role, + "privacy_hide_fullname": row.users_privacy_hide_fullname, + "privacy_hide_email": row.users_privacy_hide_email, "expiration_date": ( row.users_expires_at.date() if row.users_expires_at else None ), @@ -141,6 +139,10 @@ async def get_user_profile( "organizations": user_standard_groups, "all": all_group, }, + privacy=ProfilePrivacyGet( + hide_email=user_profile["privacy_hide_email"], + hide_fullname=user_profile["privacy_hide_fullname"], + ), preferences=preferences, **optional, ) @@ -148,29 +150,20 @@ async def get_user_profile( async def update_user_profile( app: web.Application, + *, user_id: UserID, update: ProfileUpdate, - *, - as_patch: bool = True, ) -> None: """ - Keyword Arguments: - as_patch -- set False if PUT and True if PATCH (default: {True}) - Raises: UserNotFoundError """ user_id = _parse_as_user(user_id) - async with get_database_engine(app).acquire() as conn: - to_update = update.model_dump( - include={"first_name", "last_name"}, - exclude_unset=as_patch, - ) - resp = await conn.execute( - users.update().where(users.c.id == user_id).values(**to_update) - ) - assert resp.rowcount == 1 # nosec + if updated_values := update.to_db(): + query = users.update().where(users.c.id == user_id).values(**updated_values) + resp = await conn.execute(query) + assert resp.rowcount == 1 # nosec async def get_user_role(app: web.Application, user_id: UserID) -> UserRole: diff --git a/services/web/server/src/simcore_service_webserver/users/schemas.py b/services/web/server/src/simcore_service_webserver/users/schemas.py index f7decdd9d7d..54ca357a1cc 100644 --- a/services/web/server/src/simcore_service_webserver/users/schemas.py +++ b/services/web/server/src/simcore_service_webserver/users/schemas.py @@ -1,5 +1,5 @@ from datetime import date -from typing import Annotated, Literal +from typing import Annotated, Any, Literal from uuid import UUID from models_library.api_schemas_webserver._base import OutputSchema @@ -67,7 +67,7 @@ class ProfileGet(BaseModel): alias="expirationDate", ) - privacy: ProfilePrivacyGet = ProfilePrivacyGet(hide_fullname=True, hide_email=True) + privacy: ProfilePrivacyGet preferences: AggregatedPreferences model_config = ConfigDict( @@ -132,6 +132,19 @@ class ProfileUpdate(BaseModel): } ) + def to_db(self) -> dict[str, Any]: + values = self.model_dump( + include={"first_name", "last_name"}, + exclude_unset=True, + ) + if self.privacy: + values.update( + self.privacy.model_dump( + include={"hide_fullname", "hide_email"}, exclude_unset=True + ) + ) + return values + # # Permissions From 06f13b86e61fbfba5066f010fa17d949869f9ca8 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 5 Dec 2024 14:57:52 +0100 Subject: [PATCH 12/36] drafts tests --- .../tests/unit/with_dbs/03/test_users.py | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) 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 b06b887a6b1..27bdf48f8c8 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 @@ -183,6 +183,46 @@ async def test_update_profile( assert data["role"] == user_role.name +@pytest.mark.parametrize( + "user_role", + [UserRole.USER], +) +async def test_profile_workflow( + logged_user: UserInfoDict, + client: TestClient, + user_role: UserRole, +): + assert client.app + + 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) + + url = client.app.router["update_my_profile"].url_for() + resp = await client.patch( + f"{url}", + json={ + "first_name": "Odei", + "privacy": {"hide_fullname": False}, + }, + ) + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + 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) + + assert updated_profile.first_name != my_profile.first_name + assert updated_profile.last_name == my_profile.last_name + assert updated_profile.login == my_profile.login + + assert updated_profile.privacy != my_profile.privacy + assert updated_profile.privacy.hide_email == my_profile.privacy.hide_email + assert updated_profile.privacy.hide_fullname != my_profile.privacy.hide_fullname + + @pytest.fixture def mock_failing_database_connection(mocker: Mock) -> MagicMock: """ From 7aebbbffd142cacfc6089ce03477d8163fd2a2c2 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 5 Dec 2024 14:59:29 +0100 Subject: [PATCH 13/36] migration --- .../cc0e49c38d02_new_user_privacy_columns.py | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 packages/postgres-database/src/simcore_postgres_database/migration/versions/cc0e49c38d02_new_user_privacy_columns.py diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/cc0e49c38d02_new_user_privacy_columns.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/cc0e49c38d02_new_user_privacy_columns.py new file mode 100644 index 00000000000..746bfe766e7 --- /dev/null +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/cc0e49c38d02_new_user_privacy_columns.py @@ -0,0 +1,45 @@ +"""new user privacy columns + +Revision ID: cc0e49c38d02 +Revises: e05bdc5b3c7b +Create Date: 2024-12-05 13:59:09.509554+00:00 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "cc0e49c38d02" +down_revision = "e05bdc5b3c7b" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "users", + sa.Column( + "privacy_hide_fullname", + sa.Boolean(), + server_default=sa.text("true"), + nullable=False, + ), + ) + op.add_column( + "users", + sa.Column( + "privacy_hide_email", + sa.Boolean(), + server_default=sa.text("true"), + nullable=False, + ), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("users", "privacy_hide_email") + op.drop_column("users", "privacy_hide_fullname") + # ### end Alembic commands ### From e4594d8207d09eb7f5230787fec2a57c4a6e4f65 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 5 Dec 2024 15:21:14 +0100 Subject: [PATCH 14/36] test pss --- .../simcore_service_webserver/users/api.py | 24 +++++++++++++++---- .../users/schemas.py | 15 +----------- 2 files changed, 20 insertions(+), 19 deletions(-) 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 c6adc5330b0..c18b3bbe5fd 100644 --- a/services/web/server/src/simcore_service_webserver/users/api.py +++ b/services/web/server/src/simcore_service_webserver/users/api.py @@ -140,8 +140,8 @@ async def get_user_profile( "all": all_group, }, privacy=ProfilePrivacyGet( - hide_email=user_profile["privacy_hide_email"], hide_fullname=user_profile["privacy_hide_fullname"], + hide_email=user_profile["privacy_hide_email"], ), preferences=preferences, **optional, @@ -160,10 +160,24 @@ async def update_user_profile( """ user_id = _parse_as_user(user_id) async with get_database_engine(app).acquire() as conn: - if updated_values := update.to_db(): - query = users.update().where(users.c.id == user_id).values(**updated_values) - resp = await conn.execute(query) - assert resp.rowcount == 1 # nosec + updated_values = update.model_dump( + include={ + "first_name": True, + "last_name": True, + "privacy": { + "hide_email", + "hide_fullname", + }, + }, + exclude_unset=True, + ) + # flatten dict + if privacy := updated_values.pop("privacy", None): + updated_values |= {f"privacy_{k}": v for k, v in privacy.items()} + + query = users.update().where(users.c.id == user_id).values(**updated_values) + resp = await conn.execute(query) + assert resp.rowcount == 1 # nosec async def get_user_role(app: web.Application, user_id: UserID) -> UserRole: diff --git a/services/web/server/src/simcore_service_webserver/users/schemas.py b/services/web/server/src/simcore_service_webserver/users/schemas.py index 54ca357a1cc..0a9d5f1beaa 100644 --- a/services/web/server/src/simcore_service_webserver/users/schemas.py +++ b/services/web/server/src/simcore_service_webserver/users/schemas.py @@ -1,5 +1,5 @@ from datetime import date -from typing import Annotated, Any, Literal +from typing import Annotated, Literal from uuid import UUID from models_library.api_schemas_webserver._base import OutputSchema @@ -132,19 +132,6 @@ class ProfileUpdate(BaseModel): } ) - def to_db(self) -> dict[str, Any]: - values = self.model_dump( - include={"first_name", "last_name"}, - exclude_unset=True, - ) - if self.privacy: - values.update( - self.privacy.model_dump( - include={"hide_fullname", "hide_email"}, exclude_unset=True - ) - ) - return values - # # Permissions From 9ffb6fb402048099984024b05344f09e9032bf95 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 5 Dec 2024 15:27:38 +0100 Subject: [PATCH 15/36] warning --- .../src/simcore_service_webserver/application_settings.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/application_settings.py b/services/web/server/src/simcore_service_webserver/application_settings.py index b15cd73e1c8..07941e1e92c 100644 --- a/services/web/server/src/simcore_service_webserver/application_settings.py +++ b/services/web/server/src/simcore_service_webserver/application_settings.py @@ -117,13 +117,12 @@ class ApplicationSettings(BaseCustomSettings, MixinLoggingSettings): WEBSERVER_LOGLEVEL: Annotated[ LogLevel, Field( - default=LogLevel.WARNING.value, validation_alias=AliasChoices( "WEBSERVER_LOGLEVEL", "LOG_LEVEL", "LOGLEVEL" ), # NOTE: suffix '_LOGLEVEL' is used overall ), - ] + ] = LogLevel.WARNING WEBSERVER_LOG_FORMAT_LOCAL_DEV_ENABLED: bool = Field( default=False, validation_alias=AliasChoices( From f8a019d47d1ab11ecf2c02776f65474f59d0d7c8 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 5 Dec 2024 15:29:36 +0100 Subject: [PATCH 16/36] fixes migration --- ...mns.py => 38c9ac332c58_new_user_privacy_columns.py} | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) rename packages/postgres-database/src/simcore_postgres_database/migration/versions/{cc0e49c38d02_new_user_privacy_columns.py => 38c9ac332c58_new_user_privacy_columns.py} (85%) diff --git a/packages/postgres-database/src/simcore_postgres_database/migration/versions/cc0e49c38d02_new_user_privacy_columns.py b/packages/postgres-database/src/simcore_postgres_database/migration/versions/38c9ac332c58_new_user_privacy_columns.py similarity index 85% rename from packages/postgres-database/src/simcore_postgres_database/migration/versions/cc0e49c38d02_new_user_privacy_columns.py rename to packages/postgres-database/src/simcore_postgres_database/migration/versions/38c9ac332c58_new_user_privacy_columns.py index 746bfe766e7..4d3e141e769 100644 --- a/packages/postgres-database/src/simcore_postgres_database/migration/versions/cc0e49c38d02_new_user_privacy_columns.py +++ b/packages/postgres-database/src/simcore_postgres_database/migration/versions/38c9ac332c58_new_user_privacy_columns.py @@ -1,16 +1,16 @@ """new user privacy columns -Revision ID: cc0e49c38d02 -Revises: e05bdc5b3c7b -Create Date: 2024-12-05 13:59:09.509554+00:00 +Revision ID: 38c9ac332c58 +Revises: e5555076ef50 +Create Date: 2024-12-05 14:29:27.739650+00:00 """ import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. -revision = "cc0e49c38d02" -down_revision = "e05bdc5b3c7b" +revision = "38c9ac332c58" +down_revision = "e5555076ef50" branch_labels = None depends_on = None From fb809841ee9eca4f06107977571200790b9d9878 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 5 Dec 2024 15:45:07 +0100 Subject: [PATCH 17/36] updates tests --- .../tests/unit/with_dbs/03/test_users.py | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) 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 27bdf48f8c8..7f202dd7156 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 @@ -38,7 +38,7 @@ PreUserProfile, UserProfile, ) -from simcore_service_webserver.users.schemas import ProfileGet, ProfileUpdate +from simcore_service_webserver.users.schemas import ProfileGet @pytest.fixture @@ -168,19 +168,34 @@ async def test_update_profile( ): assert client.app + resp = await client.get("/v0/me") + data, _ = await assert_status(resp, status.HTTP_200_OK) + + assert data["role"] == user_role.name + before = deepcopy(data) + url = client.app.router["update_my_profile"].url_for() assert url.path == "/v0/me" - resp = await client.patch(f"{url}", json={"last_name": "Foo"}) + resp = await client.patch( + f"{url}", + json={ + "last_name": "Foo", + }, + ) _, error = await assert_status(resp, status.HTTP_204_NO_CONTENT) assert not error - resp = await client.get(f"{url}") + + resp = await client.get("/v0/me") data, _ = await assert_status(resp, status.HTTP_200_OK) - # This is a PUT! i.e. full replace of profile variable fields! - assert data["first_name"] == ProfileUpdate.model_fields["first_name"].default assert data["last_name"] == "Foo" - assert data["role"] == user_role.name + + def _copy(data: dict, exclude: set) -> dict: + return {k: v for k, v in data.items() if k not in exclude} + + exclude = {"last_name"} + assert _copy(data, exclude) == _copy(before, exclude) @pytest.mark.parametrize( From 245d8412bd5803a0a9dc3ce714c0ced72ec6a537 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 5 Dec 2024 15:53:02 +0100 Subject: [PATCH 18/36] udpates OAS --- .../api/v0/openapi.yaml | 38 ++++++++++++++++++- 1 file changed, 36 insertions(+), 2 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 799022ae764..1cd87c30ce4 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 @@ -1087,7 +1087,7 @@ paths: application/json: schema: $ref: '#/components/schemas/Envelope_ProfileGet_' - put: + patch: tags: - user summary: Update My Profile @@ -4540,7 +4540,7 @@ paths: '403': description: ProjectInvalidRightsError '404': - description: ProjectNotFoundError, UserDefaultWalletNotFoundError + description: UserDefaultWalletNotFoundError, ProjectNotFoundError '409': description: ProjectTooManyProjectOpenedError '422': @@ -11569,6 +11569,8 @@ components: 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' @@ -11580,8 +11582,36 @@ components: - userName - login - role + - privacy - preferences title: ProfileGet + ProfilePrivacyGet: + properties: + hide_fullname: + type: boolean + title: Hide Fullname + hide_email: + type: boolean + title: Hide Email + type: object + required: + - hide_fullname + - hide_email + title: ProfilePrivacyGet + ProfilePrivacyUpdate: + properties: + hide_fullname: + anyOf: + - type: boolean + - type: 'null' + title: Hide Fullname + hide_email: + anyOf: + - type: boolean + - type: 'null' + title: Hide Email + type: object + title: ProfilePrivacyUpdate ProfileUpdate: properties: first_name: @@ -11596,6 +11626,10 @@ components: maxLength: 255 - type: 'null' title: Last Name + privacy: + anyOf: + - $ref: '#/components/schemas/ProfilePrivacyUpdate' + - type: 'null' type: object title: ProfileUpdate example: From 51fe8e31e944a65ada19e7ccee4e771fd86754bb Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 5 Dec 2024 16:34:38 +0100 Subject: [PATCH 19/36] examples --- .../users/schemas.py | 37 +++++-------------- .../tests/unit/with_dbs/03/test_users.py | 1 - 2 files changed, 10 insertions(+), 28 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/users/schemas.py b/services/web/server/src/simcore_service_webserver/users/schemas.py index 0a9d5f1beaa..bf21b655d39 100644 --- a/services/web/server/src/simcore_service_webserver/users/schemas.py +++ b/services/web/server/src/simcore_service_webserver/users/schemas.py @@ -8,10 +8,9 @@ from models_library.basic_types import IDStr from models_library.emails import LowerCaseEmailStr from models_library.users import FirstNameStr, LastNameStr, UserID -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator from simcore_postgres_database.models.users import UserRole -from ..utils import gravatar_hash from ._models import ProfilePrivacyGet, ProfilePrivacyUpdate @@ -59,13 +58,15 @@ class ProfileGet(BaseModel): role: Literal["ANONYMOUS", "GUEST", "USER", "TESTER", "PRODUCT_OWNER", "ADMIN"] groups: MyGroupsGet | None = None - gravatar_id: str | None = None + gravatar_id: Annotated[str | None, Field(deprecated=True)] = None - expiration_date: date | None = Field( - default=None, - description="If user has a trial account, it sets the expiration date, otherwise None", - alias="expirationDate", - ) + expiration_date: Annotated[ + date | None, + Field( + description="If user has a trial account, it sets the expiration date, otherwise None", + alias="expirationDate", + ), + ] = None privacy: ProfilePrivacyGet preferences: AggregatedPreferences @@ -76,16 +77,6 @@ class ProfileGet(BaseModel): populate_by_name=True, json_schema_extra={ "examples": [ - # 1. with gravatar - { - "id": 1, - "login": "bla@foo.com", - "userName": "bla123", - "role": "Admin", - "gravatar_id": "205e460b479e2e5b48aec07710c08d50", - "preferences": {}, - }, - # 2. with expiration date { "id": 42, "login": "bla@foo.com", @@ -93,20 +84,12 @@ class ProfileGet(BaseModel): "role": UserRole.ADMIN.value, "expirationDate": "2022-09-14", "preferences": {}, + "privacy": {"hide_fullname": 0, "hide_email": 1}, }, ] }, ) - @model_validator(mode="before") - @classmethod - def _auto_generate_gravatar(cls, values): - gravatar_id = values.get("gravatar_id") - email = values.get("login") - if not gravatar_id and email: - values["gravatar_id"] = gravatar_hash(email) - return values - @field_validator("role", mode="before") @classmethod def _to_upper_string(cls, v): 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 7f202dd7156..1397ecd27ba 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 @@ -137,7 +137,6 @@ async def test_get_profile( } assert profile.login == logged_user["email"] - assert profile.gravatar_id assert profile.first_name == logged_user.get("first_name", None) assert profile.last_name == logged_user.get("last_name", None) assert profile.role == user_role.name From 279a1c489fffe9827c3de4b6117f2ed9107214a5 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 5 Dec 2024 16:36:00 +0100 Subject: [PATCH 20/36] cleanup --- .../server/src/simcore_service_webserver/users/schemas.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/users/schemas.py b/services/web/server/src/simcore_service_webserver/users/schemas.py index bf21b655d39..354ebe6c49b 100644 --- a/services/web/server/src/simcore_service_webserver/users/schemas.py +++ b/services/web/server/src/simcore_service_webserver/users/schemas.py @@ -80,9 +80,9 @@ class ProfileGet(BaseModel): { "id": 42, "login": "bla@foo.com", - "userName": "bla123", - "role": UserRole.ADMIN.value, - "expirationDate": "2022-09-14", + "userName": "bla42", + "role": "admin", # pre + "expirationDate": "2022-09-14", # optional "preferences": {}, "privacy": {"hide_fullname": 0, "hide_email": 1}, }, From baeba07cc85153f80dbbcbc78a69bbe3542fe99d Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 5 Dec 2024 17:14:58 +0100 Subject: [PATCH 21/36] updates OAS --- .../src/simcore_service_webserver/api/v0/openapi.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) 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 1cd87c30ce4..f620894fb2c 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 @@ -11561,6 +11561,7 @@ components: - type: string - type: 'null' title: Gravatar Id + deprecated: true expirationDate: anyOf: - type: string @@ -11626,6 +11627,13 @@ components: maxLength: 255 - type: 'null' title: Last Name + user_name: + anyOf: + - type: string + maxLength: 100 + minLength: 1 + - type: 'null' + title: User Name privacy: anyOf: - $ref: '#/components/schemas/ProfilePrivacyUpdate' From 982fb6fe43815d723be37b43ac848de2a5cdef63 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 5 Dec 2024 17:16:33 +0100 Subject: [PATCH 22/36] update username --- .../users/_handlers.py | 1 + .../simcore_service_webserver/users/api.py | 1 + .../users/schemas.py | 43 +++++++++++++++++- .../tests/unit/with_dbs/03/test_users.py | 44 ++++++++++++++++++- 4 files changed, 87 insertions(+), 2 deletions(-) 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 e9cb4ad0b9b..8edad858c72 100644 --- a/services/web/server/src/simcore_service_webserver/users/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/users/_handlers.py @@ -82,6 +82,7 @@ async def get_my_profile(request: web.Request) -> web.Response: 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) + 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 c18b3bbe5fd..0680209c43a 100644 --- a/services/web/server/src/simcore_service_webserver/users/api.py +++ b/services/web/server/src/simcore_service_webserver/users/api.py @@ -164,6 +164,7 @@ async def update_user_profile( include={ "first_name": True, "last_name": True, + "user_name": True, "privacy": { "hide_email", "hide_fullname", diff --git a/services/web/server/src/simcore_service_webserver/users/schemas.py b/services/web/server/src/simcore_service_webserver/users/schemas.py index 354ebe6c49b..c4251fb6ff2 100644 --- a/services/web/server/src/simcore_service_webserver/users/schemas.py +++ b/services/web/server/src/simcore_service_webserver/users/schemas.py @@ -1,3 +1,4 @@ +import re from datetime import date from typing import Annotated, Literal from uuid import UUID @@ -103,7 +104,7 @@ def _to_upper_string(cls, v): class ProfileUpdate(BaseModel): first_name: FirstNameStr | None = None last_name: LastNameStr | None = None - + user_name: IDStr | None = None privacy: ProfilePrivacyUpdate | None = None model_config = ConfigDict( @@ -115,6 +116,46 @@ class ProfileUpdate(BaseModel): } ) + @field_validator("user_name") + @classmethod + def _validate_user_name(cls, value): + # Ensure valid characters (alphanumeric + . _ -) + if not re.match(r"^[a-zA-Z][a-zA-Z0-9_]*$", value): + msg = "Username must start with a letter and can only contain letters, numbers and '_'." + raise ValueError(msg) + + # Ensure no consecutive special characters + if re.search(r"[_]{2,}", value): + msg = "Username cannot contain consecutive special characters like '__'." + raise ValueError(msg) + + # Ensure it doesn't end with a special character + if value[-1] in {".", "_", "-"}: + msg = "Username cannot end with a special character." + raise ValueError(msg) + + # Check reserved words (example list; extend as needed) + reserved_words = { + "admin", + "root", + "system", + "null", + "undefined", + "support", + "moderator", + } + if value.lower() in reserved_words: + msg = f"Username '{value}' is reserved and cannot be used." + raise ValueError(msg) + + # Ensure no offensive content + offensive_terms = {"badword1", "badword2"} + if any(term in value.lower() for term in offensive_terms): + msg = "Username contains prohibited or offensive content" + raise ValueError(msg) + + return value + # # Permissions 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 1397ecd27ba..b863fec83e5 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 @@ -217,7 +217,8 @@ async def test_profile_workflow( resp = await client.patch( f"{url}", json={ - "first_name": "Odei", + "firstName": "Odei", + "userName": "odei123", "privacy": {"hide_fullname": False}, }, ) @@ -232,11 +233,52 @@ async def test_profile_workflow( assert updated_profile.last_name == my_profile.last_name assert updated_profile.login == my_profile.login + assert updated_profile.user_name != my_profile.user_name + assert updated_profile.user_name == "odei123" + assert updated_profile.privacy != my_profile.privacy assert updated_profile.privacy.hide_email == my_profile.privacy.hide_email assert updated_profile.privacy.hide_fullname != my_profile.privacy.hide_fullname +@pytest.mark.parametrize( + "user_role", + [UserRole.USER], +) +async def test_update_wrong_user_name( + logged_user: UserInfoDict, + client: TestClient, + user_role: UserRole, +): + assert client.app + + # update with INVALID username + url = client.app.router["update_my_profile"].url_for() + + for invalid_username in ("_foo", "superadmin", "foo..-123"): + resp = await client.patch( + f"{url}", + json={ + "userName": invalid_username, + }, + ) + await assert_status(resp, status.HTTP_422_UNPROCESSABLE_ENTITY) + + # update with SAME username (i.e. existing) + url = client.app.router["get_my_profile"].url_for() + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + + url = client.app.router["update_my_profile"].url_for() + resp = await client.patch( + f"{url}", + json={ + "userName": data["userName"], + }, + ) + await assert_status(resp, status.HTTP_409_CONFLICT) + + @pytest.fixture def mock_failing_database_connection(mocker: Mock) -> MagicMock: """ From bfa425d2a9eaf1332068d98709eb3d9a08aa4be9 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 5 Dec 2024 17:51:20 +0100 Subject: [PATCH 23/36] tests models --- .../tests/unit/isolated/test_users_models.py | 48 +++++++++++-------- 1 file changed, 28 insertions(+), 20 deletions(-) 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 859ae235337..4c28d40e666 100644 --- a/services/web/server/tests/unit/isolated/test_users_models.py +++ b/services/web/server/tests/unit/isolated/test_users_models.py @@ -1,3 +1,8 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments + from copy import deepcopy from datetime import UTC, datetime from pprint import pformat @@ -10,6 +15,7 @@ from pydantic import BaseModel from servicelib.rest_constants import RESPONSE_MODEL_POLICY from simcore_postgres_database.models.users import UserRole +from simcore_service_webserver.users._models import ProfilePrivacyGet from simcore_service_webserver.users.schemas import ProfileGet, ThirdPartyToken @@ -39,45 +45,46 @@ def test_user_models_examples( assert model_array_enveloped.error is None -def test_profile_get_expiration_date(faker: Faker): - fake_expiration = datetime.now(UTC) - +@pytest.fixture +def fake_profile_get(faker: Faker) -> ProfileGet: fake_profile: dict[str, Any] = faker.simple_profile() + first, last = fake_profile["name"].rsplit(maxsplit=1) - profile = ProfileGet( + return ProfileGet( id=faker.pyint(), + first_name=first, + last_name=last, user_name=fake_profile["username"], login=fake_profile["mail"], - role=UserRole.ADMIN, - expiration_date=fake_expiration.date(), + role="USER", + privacy=ProfilePrivacyGet(hide_fullname=True, hide_email=True), preferences={}, ) + +def test_profile_get_expiration_date(fake_profile_get: ProfileGet): + fake_expiration = datetime.now(UTC) + + profile = fake_profile_get.model_copy( + update={"expiration_date": fake_expiration.date()} + ) + assert fake_expiration.date() == profile.expiration_date body = jsonable_encoder(profile.model_dump(exclude_unset=True, by_alias=True)) assert body["expirationDate"] == fake_expiration.date().isoformat() -def test_auto_compute_gravatar(faker: Faker): +def test_auto_compute_gravatar__deprecated(fake_profile_get: ProfileGet): - fake_profile: dict[str, Any] = faker.simple_profile() - first_name, last_name = fake_profile["name"].rsplit(maxsplit=1) - - profile = ProfileGet( - id=faker.pyint(), - user_name=fake_profile["username"], - first_name=first_name, - last_name=last_name, - login=fake_profile["mail"], - role="USER", - preferences={}, - ) + profile = fake_profile_get.model_copy() envelope = Envelope[Any](data=profile) data = envelope.model_dump(**RESPONSE_MODEL_POLICY)["data"] - assert data["gravatar_id"] + assert ( + "gravatar_id" not in data + ), f"{ProfileGet.model_fields['gravatar_id'].deprecated=}" assert data["id"] == profile.id assert data["first_name"] == profile.first_name assert data["last_name"] == profile.last_name @@ -107,6 +114,7 @@ def test_parsing_output_of_get_user_profile(): "last_name": "", "role": "Guest", "gravatar_id": "9d5e02c75fcd4bce1c8861f219f7f8a5", + "privacy": {"hide_email": True, "hide_fullname": False}, "groups": { "me": { "gid": 2, From f15d8fcdf119289a105add57998d48133e021976 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 5 Dec 2024 18:40:50 +0100 Subject: [PATCH 24/36] model conversion ready --- .../users/_models.py | 49 ++++++++++++++++++- .../simcore_service_webserver/users/api.py | 25 +++------- .../users/schemas.py | 3 +- .../tests/unit/isolated/test_users_models.py | 30 +++++++++++- 4 files changed, 84 insertions(+), 23 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/users/_models.py b/services/web/server/src/simcore_service_webserver/users/_models.py index 656bbb587a8..8ebfbc650c2 100644 --- a/services/web/server/src/simcore_service_webserver/users/_models.py +++ b/services/web/server/src/simcore_service_webserver/users/_models.py @@ -1,4 +1,6 @@ -from pydantic import BaseModel +from typing import Annotated, Any, Self + +from pydantic import BaseModel, ConfigDict, Field # @@ -12,3 +14,48 @@ class ProfilePrivacyGet(BaseModel): class ProfilePrivacyUpdate(BaseModel): hide_fullname: bool | None = None hide_email: bool | None = None + + +# +# DB models +# + + +def flatten_dict(d: dict, parent_key="", sep="_"): + items = [] + for key, value in d.items(): + new_key = f"{parent_key}{sep}{key}" if parent_key else key + if isinstance(value, dict): + # Recursively process nested dictionaries + items.extend(flatten_dict(value, new_key, sep=sep).items()) + else: + items.append((new_key, value)) + return dict(items) + + +class ToUserUpdateDB(BaseModel): + """ + Maps ProfileUpdate api model into UserDB db model + """ + + # NOTE: field names are UserDB columns + # NOTE: aliases are ProfileUpdate field names + + name: Annotated[str | None, Field(alias="user_name")] = None + first_name: str | None = None + last_name: str | None = None + + privacy_hide_fullname: bool | None = None + privacy_hide_email: bool | None = None + + model_config = ConfigDict(extra="forbid") + + @classmethod + def from_api(cls, profile_update) -> Self: + # The mapping of embed fields to flatten keys is done here + return cls.model_validate( + flatten_dict(profile_update.model_dump(exclude_unset=True, by_alias=False)) + ) + + def to_columns(self) -> dict[str, Any]: + return self.model_dump(exclude_unset=True, by_alias=False) 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 0680209c43a..92df4333c1d 100644 --- a/services/web/server/src/simcore_service_webserver/users/api.py +++ b/services/web/server/src/simcore_service_webserver/users/api.py @@ -30,6 +30,7 @@ from ..security.api import clean_auth_policy_cache from . import _db from ._api import get_user_credentials, get_user_invoice_address, set_user_as_deleted +from ._models import ToUserUpdateDB from ._preferences_api import get_frontend_user_preferences_aggregation from .exceptions import MissingGroupExtraPropertiesForProductError, UserNotFoundError from .schemas import ProfileGet, ProfileUpdate @@ -159,26 +160,12 @@ async def update_user_profile( UserNotFoundError """ user_id = _parse_as_user(user_id) - async with get_database_engine(app).acquire() as conn: - updated_values = update.model_dump( - include={ - "first_name": True, - "last_name": True, - "user_name": True, - "privacy": { - "hide_email", - "hide_fullname", - }, - }, - exclude_unset=True, - ) - # flatten dict - if privacy := updated_values.pop("privacy", None): - updated_values |= {f"privacy_{k}": v for k, v in privacy.items()} - query = users.update().where(users.c.id == user_id).values(**updated_values) - resp = await conn.execute(query) - assert resp.rowcount == 1 # nosec + if updated_values := ToUserUpdateDB.from_api(update).to_columns(): + async with get_database_engine(app).acquire() as conn: + query = users.update().where(users.c.id == user_id).values(**updated_values) + resp = await conn.execute(query) + assert resp.rowcount == 1 # nosec async def get_user_role(app: web.Application, user_id: UserID) -> UserRole: diff --git a/services/web/server/src/simcore_service_webserver/users/schemas.py b/services/web/server/src/simcore_service_webserver/users/schemas.py index c4251fb6ff2..71fc532589c 100644 --- a/services/web/server/src/simcore_service_webserver/users/schemas.py +++ b/services/web/server/src/simcore_service_webserver/users/schemas.py @@ -104,7 +104,8 @@ def _to_upper_string(cls, v): class ProfileUpdate(BaseModel): first_name: FirstNameStr | None = None last_name: LastNameStr | None = None - user_name: IDStr | None = None + user_name: Annotated[IDStr | None, Field(alias="userName")] = None + privacy: ProfilePrivacyUpdate | None = None model_config = ConfigDict( 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 4c28d40e666..29bc82f4a20 100644 --- a/services/web/server/tests/unit/isolated/test_users_models.py +++ b/services/web/server/tests/unit/isolated/test_users_models.py @@ -15,8 +15,12 @@ from pydantic import BaseModel from servicelib.rest_constants import RESPONSE_MODEL_POLICY from simcore_postgres_database.models.users import UserRole -from simcore_service_webserver.users._models import ProfilePrivacyGet -from simcore_service_webserver.users.schemas import ProfileGet, ThirdPartyToken +from simcore_service_webserver.users._models import ProfilePrivacyGet, ToUserUpdateDB +from simcore_service_webserver.users.schemas import ( + ProfileGet, + ProfileUpdate, + ThirdPartyToken, +) @pytest.mark.parametrize( @@ -152,3 +156,25 @@ def test_parsing_output_of_get_user_profile(): profile = ProfileGet.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( + # input in rest + { + "first_name": "foo", + "userName": "foo1234", + "privacy": {"hide_fullname": False}, + } + ) + + # to db + profile_update_db = ToUserUpdateDB.from_api(profile_update) + + # expected + assert profile_update_db.to_columns() == { + "first_name": "foo", + "name": "foo1234", + "privacy_hide_fullname": False, + } From abb4aa42e12dfefc11f440ad373dbf9c7f8aee5f Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 5 Dec 2024 19:00:26 +0100 Subject: [PATCH 25/36] cleanup --- .../users/schemas.py | 21 +++----- .../tests/unit/with_dbs/03/test_users.py | 50 +++++++++---------- 2 files changed, 32 insertions(+), 39 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/users/schemas.py b/services/web/server/src/simcore_service_webserver/users/schemas.py index 71fc532589c..ccbf7c08888 100644 --- a/services/web/server/src/simcore_service_webserver/users/schemas.py +++ b/services/web/server/src/simcore_service_webserver/users/schemas.py @@ -119,20 +119,20 @@ class ProfileUpdate(BaseModel): @field_validator("user_name") @classmethod - def _validate_user_name(cls, value): + def _validate_user_name(cls, value: str): # Ensure valid characters (alphanumeric + . _ -) if not re.match(r"^[a-zA-Z][a-zA-Z0-9_]*$", value): - msg = "Username must start with a letter and can only contain letters, numbers and '_'." + msg = f"Username '{value}' must start with a letter and can only contain letters, numbers and '_'." raise ValueError(msg) # Ensure no consecutive special characters if re.search(r"[_]{2,}", value): - msg = "Username cannot contain consecutive special characters like '__'." + msg = f"Username '{value}' cannot contain consecutive special characters like '__'." raise ValueError(msg) # Ensure it doesn't end with a special character - if value[-1] in {".", "_", "-"}: - msg = "Username cannot end with a special character." + if {value[0], value[-1]}.intersection({"_"}): + msg = f"Username '{value}' cannot end or start with a special character." raise ValueError(msg) # Check reserved words (example list; extend as needed) @@ -144,15 +144,10 @@ def _validate_user_name(cls, value): "undefined", "support", "moderator", + # NOTE: add here extra via env vars } - if value.lower() in reserved_words: - msg = f"Username '{value}' is reserved and cannot be used." - raise ValueError(msg) - - # Ensure no offensive content - offensive_terms = {"badword1", "badword2"} - if any(term in value.lower() for term in offensive_terms): - msg = "Username contains prohibited or offensive content" + if any(w in value.lower() for w in reserved_words): + msg = f"Username '{value}' cannot be used." raise ValueError(msg) return value 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 b863fec83e5..473b6c19cd7 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 @@ -100,10 +100,7 @@ async def test_access_update_profile( await assert_status(resp, expected) -@pytest.mark.parametrize( - "user_role", - [UserRole.USER], -) +@pytest.mark.parametrize("user_role", [UserRole.USER]) async def test_get_profile( logged_user: UserInfoDict, client: TestClient, @@ -156,10 +153,7 @@ async def test_get_profile( ) -@pytest.mark.parametrize( - "user_role", - [UserRole.USER], -) +@pytest.mark.parametrize("user_role", [UserRole.USER]) async def test_update_profile( logged_user: UserInfoDict, client: TestClient, @@ -197,10 +191,7 @@ def _copy(data: dict, exclude: set) -> dict: assert _copy(data, exclude) == _copy(before, exclude) -@pytest.mark.parametrize( - "user_role", - [UserRole.USER], -) +@pytest.mark.parametrize("user_role", [UserRole.USER]) async def test_profile_workflow( logged_user: UserInfoDict, client: TestClient, @@ -217,7 +208,7 @@ async def test_profile_workflow( resp = await client.patch( f"{url}", json={ - "firstName": "Odei", + "first_name": "Odei", # NOTE: still not camecase! "userName": "odei123", "privacy": {"hide_fullname": False}, }, @@ -241,34 +232,41 @@ async def test_profile_workflow( assert updated_profile.privacy.hide_fullname != my_profile.privacy.hide_fullname -@pytest.mark.parametrize( - "user_role", - [UserRole.USER], -) +@pytest.mark.parametrize("user_role", [UserRole.USER]) +@pytest.mark.parametrize("invalid_username", ["", "_foo", "superadmin", "foo..-123"]) async def test_update_wrong_user_name( logged_user: UserInfoDict, client: TestClient, user_role: UserRole, + invalid_username: str, ): assert client.app - # update with INVALID username url = client.app.router["update_my_profile"].url_for() + resp = await client.patch( + f"{url}", + json={ + "userName": invalid_username, + }, + ) + await assert_status(resp, status.HTTP_422_UNPROCESSABLE_ENTITY) - for invalid_username in ("_foo", "superadmin", "foo..-123"): - resp = await client.patch( - f"{url}", - json={ - "userName": invalid_username, - }, - ) - await assert_status(resp, status.HTTP_422_UNPROCESSABLE_ENTITY) + +@pytest.mark.parametrize("user_role", [UserRole.USER]) +async def test_update_existing_user_name( + logged_user: UserInfoDict, + client: TestClient, + user_role: UserRole, +): + assert client.app # update with SAME username (i.e. existing) url = client.app.router["get_my_profile"].url_for() resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) + assert data["userName"] == logged_user["name"] + url = client.app.router["update_my_profile"].url_for() resp = await client.patch( f"{url}", From 1ab8b22342f405e954dfb175faf7ec4be8b03361 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 5 Dec 2024 19:20:13 +0100 Subject: [PATCH 26/36] handles errors --- .../users/_handlers.py | 5 +++++ .../simcore_service_webserver/users/api.py | 22 +++++++++++++++---- .../users/exceptions.py | 4 ++++ .../users/schemas.py | 8 +++---- .../tests/unit/with_dbs/03/test_users.py | 6 ++++- 5 files changed, 36 insertions(+), 9 deletions(-) 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 8edad858c72..3f52f82ea19 100644 --- a/services/web/server/src/simcore_service_webserver/users/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/users/_handlers.py @@ -25,6 +25,7 @@ from .exceptions import ( AlreadyPreRegisteredError, MissingGroupExtraPropertiesForProductError, + UserNameDuplicateError, UserNotFoundError, ) from .schemas import ProfileGet, ProfileUpdate @@ -48,6 +49,10 @@ async def wrapper(request: web.Request) -> web.StreamResponse: except UserNotFoundError as exc: raise web.HTTPNotFound(reason=f"{exc}") from exc + + except UserNameDuplicateError as exc: + raise web.HTTPConflict(reason=f"{exc}") from exc + except MissingGroupExtraPropertiesForProductError as exc: error_code = exc.error_code() user_error_msg = FMSG_MISSING_CONFIG_WITH_OEC.format(error_code=error_code) 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 92df4333c1d..c479491310c 100644 --- a/services/web/server/src/simcore_service_webserver/users/api.py +++ b/services/web/server/src/simcore_service_webserver/users/api.py @@ -9,6 +9,7 @@ from collections import deque from typing import Any, NamedTuple, TypedDict +import simcore_postgres_database.errors as db_errors import sqlalchemy as sa from aiohttp import web from aiopg.sa.engine import Engine @@ -32,7 +33,11 @@ from ._api import get_user_credentials, get_user_invoice_address, set_user_as_deleted from ._models import ToUserUpdateDB from ._preferences_api import get_frontend_user_preferences_aggregation -from .exceptions import MissingGroupExtraPropertiesForProductError, UserNotFoundError +from .exceptions import ( + MissingGroupExtraPropertiesForProductError, + UserNameDuplicateError, + UserNotFoundError, +) from .schemas import ProfileGet, ProfileUpdate _logger = logging.getLogger(__name__) @@ -42,7 +47,7 @@ def _parse_as_user(user_id: Any) -> UserID: try: return TypeAdapter(UserID).validate_python(user_id) except ValidationError as err: - raise UserNotFoundError(uid=user_id) from err + raise UserNotFoundError(uid=user_id, user_id=user_id) from err async def get_user_profile( @@ -158,14 +163,23 @@ async def update_user_profile( """ Raises: UserNotFoundError + UserNameAlreadyExistsError """ user_id = _parse_as_user(user_id) if updated_values := ToUserUpdateDB.from_api(update).to_columns(): async with get_database_engine(app).acquire() as conn: query = users.update().where(users.c.id == user_id).values(**updated_values) - resp = await conn.execute(query) - assert resp.rowcount == 1 # nosec + + try: + + resp = await conn.execute(query) + assert resp.rowcount == 1 # nosec + + except db_errors.UniqueViolation as err: + raise UserNameDuplicateError( + user_name=updated_values.get("name") + ) from err async def get_user_role(app: web.Application, user_id: UserID) -> UserRole: 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 39791ea39fe..653cfeca719 100644 --- a/services/web/server/src/simcore_service_webserver/users/exceptions.py +++ b/services/web/server/src/simcore_service_webserver/users/exceptions.py @@ -21,6 +21,10 @@ def __init__(self, *, uid: int | None = None, email: str | None = None, **ctx: A self.email = email +class UserNameDuplicateError(UsersBaseError): + msg_template = "Username {user_name} is already in use. Violates unique constraint" + + class TokenNotFoundError(UsersBaseError): msg_template = "Token for service {service_id} not found" diff --git a/services/web/server/src/simcore_service_webserver/users/schemas.py b/services/web/server/src/simcore_service_webserver/users/schemas.py index ccbf7c08888..7ff1b7b7c4b 100644 --- a/services/web/server/src/simcore_service_webserver/users/schemas.py +++ b/services/web/server/src/simcore_service_webserver/users/schemas.py @@ -121,17 +121,17 @@ class ProfileUpdate(BaseModel): @classmethod def _validate_user_name(cls, value: str): # Ensure valid characters (alphanumeric + . _ -) - if not re.match(r"^[a-zA-Z][a-zA-Z0-9_]*$", value): - msg = f"Username '{value}' must start with a letter and can only contain letters, numbers and '_'." + if not re.match(r"^[a-zA-Z][a-zA-Z0-9._-]*$", value): + msg = f"Username '{value}' must start with a letter and can only contain letters, numbers and '_', '.' or '-'." raise ValueError(msg) # Ensure no consecutive special characters - if re.search(r"[_]{2,}", value): + if re.search(r"[_.-]{2,}", value): msg = f"Username '{value}' cannot contain consecutive special characters like '__'." raise ValueError(msg) # Ensure it doesn't end with a special character - if {value[0], value[-1]}.intersection({"_"}): + if {value[0], value[-1]}.intersection({"_", "-", "."}): msg = f"Username '{value}' cannot end or start with a special character." raise ValueError(msg) 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 473b6c19cd7..31f088a2aca 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 @@ -254,12 +254,16 @@ async def test_update_wrong_user_name( @pytest.mark.parametrize("user_role", [UserRole.USER]) async def test_update_existing_user_name( + user: UserInfoDict, logged_user: UserInfoDict, client: TestClient, user_role: UserRole, ): assert client.app + other_username = user["name"] + assert other_username != logged_user["name"] + # update with SAME username (i.e. existing) url = client.app.router["get_my_profile"].url_for() resp = await client.get(f"{url}") @@ -271,7 +275,7 @@ async def test_update_existing_user_name( resp = await client.patch( f"{url}", json={ - "userName": data["userName"], + "userName": other_username, }, ) await assert_status(resp, status.HTTP_409_CONFLICT) From 174e9c348d51ffa464bbf1488d3e0e7c799d78d1 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 5 Dec 2024 19:32:57 +0100 Subject: [PATCH 27/36] changes --- .github/PULL_REQUEST_TEMPLATE.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index e6f34058bd7..c142fd7d463 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -22,6 +22,9 @@ or from https://gitmoji.dev/ ## What do these changes do? + ## Related issue/s @@ -31,9 +34,6 @@ or from https://gitmoji.dev/ - resolves ITISFoundation/osparc-issues#428 - fixes #26 - - If openapi changes are provided, optionally point to the swagger editor with new changes - Example [openapi.json specs](https://editor.swagger.io/?url=https://raw.githubusercontent.com//osparc-simcore/is1133/create-api-for-creation-of-pricing-plan/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml) --> From c66c5204ae67d0dfc9d43db86182a6fc218e0e15 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 5 Dec 2024 21:57:19 +0100 Subject: [PATCH 28/36] fixes tests --- .../04/studies_dispatcher/test_studies_dispatcher_handlers.py | 1 - .../test_studies_dispatcher_studies_access.py | 2 -- 2 files changed, 3 deletions(-) diff --git a/services/web/server/tests/unit/with_dbs/04/studies_dispatcher/test_studies_dispatcher_handlers.py b/services/web/server/tests/unit/with_dbs/04/studies_dispatcher/test_studies_dispatcher_handlers.py index af534e9a481..6f8853337ee 100644 --- a/services/web/server/tests/unit/with_dbs/04/studies_dispatcher/test_studies_dispatcher_handlers.py +++ b/services/web/server/tests/unit/with_dbs/04/studies_dispatcher/test_studies_dispatcher_handlers.py @@ -424,7 +424,6 @@ async def test_dispatch_study_anonymously( data, _ = await assert_status(response, status.HTTP_200_OK) assert data["login"].endswith("guest-at-osparc.io") - assert data["gravatar_id"] assert data["role"].upper() == UserRole.GUEST.name # guest user only a copy of the template project diff --git a/services/web/server/tests/unit/with_dbs/04/studies_dispatcher/test_studies_dispatcher_studies_access.py b/services/web/server/tests/unit/with_dbs/04/studies_dispatcher/test_studies_dispatcher_studies_access.py index 3cb82c2bf20..cff892d7f00 100644 --- a/services/web/server/tests/unit/with_dbs/04/studies_dispatcher/test_studies_dispatcher_studies_access.py +++ b/services/web/server/tests/unit/with_dbs/04/studies_dispatcher/test_studies_dispatcher_studies_access.py @@ -282,7 +282,6 @@ async def test_access_study_anonymously( data, _ = await assert_status(resp, status.HTTP_200_OK) assert data["login"].endswith("guest-at-osparc.io") - assert data["gravatar_id"] assert data["role"].upper() == UserRole.GUEST.name # guest user only a copy of the template project @@ -448,7 +447,6 @@ async def _test_guest_user_workflow(request_index): data, _ = await assert_status(resp, status.HTTP_200_OK) assert data["login"].endswith("guest-at-osparc.io") - assert data["gravatar_id"] assert data["role"].upper() == UserRole.GUEST.name # guest user only a copy of the template project From 4ed3e047dc4434b12bdf1fdef2316af93b5ba4b5 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 5 Dec 2024 22:03:25 +0100 Subject: [PATCH 29/36] moves package to models library --- .../api_schemas_webserver/users.py | 130 ++++++++++++++++++ .../users/_handlers.py | 2 +- .../users/_models.py | 18 +-- .../simcore_service_webserver/users/api.py | 9 +- .../users/schemas.py | 123 +---------------- .../tests/unit/isolated/test_users_models.py | 19 +-- 6 files changed, 150 insertions(+), 151 deletions(-) create mode 100644 packages/models-library/src/models_library/api_schemas_webserver/users.py 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 new file mode 100644 index 00000000000..ae7b9f89504 --- /dev/null +++ b/packages/models-library/src/models_library/api_schemas_webserver/users.py @@ -0,0 +1,130 @@ +import re +from datetime import date +from enum import Enum +from typing import Annotated, Literal + +from models_library.api_schemas_webserver.groups import MyGroupsGet +from models_library.api_schemas_webserver.users_preferences import AggregatedPreferences +from models_library.basic_types import IDStr +from models_library.emails import LowerCaseEmailStr +from models_library.users import FirstNameStr, LastNameStr, UserID +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from ._base import InputSchema, OutputSchema + + +class ProfilePrivacyGet(OutputSchema): + hide_fullname: bool + hide_email: bool + + +class ProfilePrivacyUpdate(InputSchema): + hide_fullname: bool | None = None + hide_email: bool | None = None + + +class ProfileGet(BaseModel): + # WARNING: do not use InputSchema until front-end is updated! + id: UserID + user_name: Annotated[ + IDStr, Field(description="Unique username identifier", alias="userName") + ] + first_name: FirstNameStr | None = None + last_name: LastNameStr | None = None + login: LowerCaseEmailStr + + role: Literal["ANONYMOUS", "GUEST", "USER", "TESTER", "PRODUCT_OWNER", "ADMIN"] + groups: MyGroupsGet | None = None + gravatar_id: Annotated[str | None, Field(deprecated=True)] = None + + expiration_date: Annotated[ + date | None, + Field( + description="If user has a trial account, it sets the expiration date, otherwise None", + alias="expirationDate", + ), + ] = None + + privacy: ProfilePrivacyGet + preferences: AggregatedPreferences + + model_config = ConfigDict( + # NOTE: old models have an hybrid between snake and camel cases! + # Should be unified at some point + populate_by_name=True, + json_schema_extra={ + "examples": [ + { + "id": 42, + "login": "bla@foo.com", + "userName": "bla42", + "role": "admin", # pre + "expirationDate": "2022-09-14", # optional + "preferences": {}, + "privacy": {"hide_fullname": 0, "hide_email": 1}, + }, + ] + }, + ) + + @field_validator("role", mode="before") + @classmethod + def _to_upper_string(cls, v): + if isinstance(v, str): + return v.upper() + if isinstance(v, Enum): + return v.name.upper() + return v + + +class ProfileUpdate(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 + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "first_name": "Pedro", + "last_name": "Crespo", + } + } + ) + + @field_validator("user_name") + @classmethod + def _validate_user_name(cls, value: str): + # Ensure valid characters (alphanumeric + . _ -) + if not re.match(r"^[a-zA-Z][a-zA-Z0-9._-]*$", value): + msg = f"Username '{value}' must start with a letter and can only contain letters, numbers and '_', '.' or '-'." + raise ValueError(msg) + + # Ensure no consecutive special characters + if re.search(r"[_.-]{2,}", value): + msg = f"Username '{value}' cannot contain consecutive special characters like '__'." + raise ValueError(msg) + + # Ensure it doesn't end with a special character + if {value[0], value[-1]}.intersection({"_", "-", "."}): + msg = f"Username '{value}' cannot end or start with a special character." + raise ValueError(msg) + + # Check reserved words (example list; extend as needed) + reserved_words = { + "admin", + "root", + "system", + "null", + "undefined", + "support", + "moderator", + # NOTE: add here extra via env vars + } + if any(w in value.lower() for w in reserved_words): + msg = f"Username '{value}' cannot be used." + raise ValueError(msg) + + return value 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 3f52f82ea19..b9d9ae0bec8 100644 --- a/services/web/server/src/simcore_service_webserver/users/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/users/_handlers.py @@ -2,6 +2,7 @@ import logging from aiohttp import web +from models_library.api_schemas_webserver.users import ProfileGet, ProfileUpdate from models_library.users import UserID from pydantic import BaseModel, Field from servicelib.aiohttp import status @@ -28,7 +29,6 @@ UserNameDuplicateError, UserNotFoundError, ) -from .schemas import ProfileGet, ProfileUpdate _logger = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/users/_models.py b/services/web/server/src/simcore_service_webserver/users/_models.py index 8ebfbc650c2..cd9de6a873c 100644 --- a/services/web/server/src/simcore_service_webserver/users/_models.py +++ b/services/web/server/src/simcore_service_webserver/users/_models.py @@ -2,20 +2,6 @@ from pydantic import BaseModel, ConfigDict, Field - -# -# REST models -# -class ProfilePrivacyGet(BaseModel): - hide_fullname: bool - hide_email: bool - - -class ProfilePrivacyUpdate(BaseModel): - hide_fullname: bool | None = None - hide_email: bool | None = None - - # # DB models # @@ -35,7 +21,7 @@ def flatten_dict(d: dict, parent_key="", sep="_"): class ToUserUpdateDB(BaseModel): """ - Maps ProfileUpdate api model into UserDB db model + Maps ProfileUpdate api-model into UserUpdate db-model """ # NOTE: field names are UserDB columns @@ -57,5 +43,5 @@ def from_api(cls, profile_update) -> Self: flatten_dict(profile_update.model_dump(exclude_unset=True, by_alias=False)) ) - def to_columns(self) -> dict[str, Any]: + def to_db(self) -> dict[str, Any]: return self.model_dump(exclude_unset=True, by_alias=False) 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 c479491310c..7fc2c138204 100644 --- a/services/web/server/src/simcore_service_webserver/users/api.py +++ b/services/web/server/src/simcore_service_webserver/users/api.py @@ -14,6 +14,11 @@ from aiohttp import web from aiopg.sa.engine import Engine from aiopg.sa.result import RowProxy +from models_library.api_schemas_webserver.users import ( + ProfileGet, + ProfilePrivacyGet, + ProfileUpdate, +) from models_library.basic_types import IDStr from models_library.products import ProductName from models_library.users import GroupID, UserID @@ -23,7 +28,6 @@ from simcore_postgres_database.utils_groups_extra_properties import ( GroupExtraPropertiesNotFoundError, ) -from simcore_service_webserver.users._models import ProfilePrivacyGet from ..db.plugin import get_database_engine from ..groups.models import convert_groups_db_to_schema @@ -38,7 +42,6 @@ UserNameDuplicateError, UserNotFoundError, ) -from .schemas import ProfileGet, ProfileUpdate _logger = logging.getLogger(__name__) @@ -167,7 +170,7 @@ async def update_user_profile( """ user_id = _parse_as_user(user_id) - if updated_values := ToUserUpdateDB.from_api(update).to_columns(): + if updated_values := ToUserUpdateDB.from_api(update).to_db(): async with get_database_engine(app).acquire() as conn: query = users.update().where(users.c.id == user_id).values(**updated_values) diff --git a/services/web/server/src/simcore_service_webserver/users/schemas.py b/services/web/server/src/simcore_service_webserver/users/schemas.py index 7ff1b7b7c4b..8ad46a5c317 100644 --- a/services/web/server/src/simcore_service_webserver/users/schemas.py +++ b/services/web/server/src/simcore_service_webserver/users/schemas.py @@ -1,18 +1,7 @@ -import re -from datetime import date -from typing import Annotated, Literal from uuid import UUID from models_library.api_schemas_webserver._base import OutputSchema -from models_library.api_schemas_webserver.groups import MyGroupsGet -from models_library.api_schemas_webserver.users_preferences import AggregatedPreferences -from models_library.basic_types import IDStr -from models_library.emails import LowerCaseEmailStr -from models_library.users import FirstNameStr, LastNameStr, UserID -from pydantic import BaseModel, ConfigDict, Field, field_validator -from simcore_postgres_database.models.users import UserRole - -from ._models import ProfilePrivacyGet, ProfilePrivacyUpdate +from pydantic import BaseModel, ConfigDict, Field # @@ -43,116 +32,6 @@ class TokenCreate(ThirdPartyToken): ... -# -# PROFILE resource -# - - -class ProfileGet(BaseModel): - id: UserID - user_name: Annotated[ - IDStr, Field(description="Unique username identifier", alias="userName") - ] - first_name: FirstNameStr | None = None - last_name: LastNameStr | None = None - login: LowerCaseEmailStr - - role: Literal["ANONYMOUS", "GUEST", "USER", "TESTER", "PRODUCT_OWNER", "ADMIN"] - groups: MyGroupsGet | None = None - gravatar_id: Annotated[str | None, Field(deprecated=True)] = None - - expiration_date: Annotated[ - date | None, - Field( - description="If user has a trial account, it sets the expiration date, otherwise None", - alias="expirationDate", - ), - ] = None - - privacy: ProfilePrivacyGet - preferences: AggregatedPreferences - - model_config = ConfigDict( - # NOTE: old models have an hybrid between snake and camel cases! - # Should be unified at some point - populate_by_name=True, - json_schema_extra={ - "examples": [ - { - "id": 42, - "login": "bla@foo.com", - "userName": "bla42", - "role": "admin", # pre - "expirationDate": "2022-09-14", # optional - "preferences": {}, - "privacy": {"hide_fullname": 0, "hide_email": 1}, - }, - ] - }, - ) - - @field_validator("role", mode="before") - @classmethod - def _to_upper_string(cls, v): - if isinstance(v, str): - return v.upper() - if isinstance(v, UserRole): - return v.name.upper() - return v - - -class ProfileUpdate(BaseModel): - first_name: FirstNameStr | None = None - last_name: LastNameStr | None = None - user_name: Annotated[IDStr | None, Field(alias="userName")] = None - - privacy: ProfilePrivacyUpdate | None = None - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "first_name": "Pedro", - "last_name": "Crespo", - } - } - ) - - @field_validator("user_name") - @classmethod - def _validate_user_name(cls, value: str): - # Ensure valid characters (alphanumeric + . _ -) - if not re.match(r"^[a-zA-Z][a-zA-Z0-9._-]*$", value): - msg = f"Username '{value}' must start with a letter and can only contain letters, numbers and '_', '.' or '-'." - raise ValueError(msg) - - # Ensure no consecutive special characters - if re.search(r"[_.-]{2,}", value): - msg = f"Username '{value}' cannot contain consecutive special characters like '__'." - raise ValueError(msg) - - # Ensure it doesn't end with a special character - if {value[0], value[-1]}.intersection({"_", "-", "."}): - msg = f"Username '{value}' cannot end or start with a special character." - raise ValueError(msg) - - # Check reserved words (example list; extend as needed) - reserved_words = { - "admin", - "root", - "system", - "null", - "undefined", - "support", - "moderator", - # NOTE: add here extra via env vars - } - if any(w in value.lower() for w in reserved_words): - msg = f"Username '{value}' cannot be used." - raise ValueError(msg) - - return value - - # # Permissions # 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 29bc82f4a20..8ff676476ee 100644 --- a/services/web/server/tests/unit/isolated/test_users_models.py +++ b/services/web/server/tests/unit/isolated/test_users_models.py @@ -10,17 +10,18 @@ import pytest from faker import Faker +from models_library.api_schemas_webserver.users import ( + ProfileGet, + ProfilePrivacyGet, + ProfileUpdate, +) from models_library.generics import Envelope from models_library.utils.fastapi_encoders import jsonable_encoder from pydantic import BaseModel from servicelib.rest_constants import RESPONSE_MODEL_POLICY from simcore_postgres_database.models.users import UserRole -from simcore_service_webserver.users._models import ProfilePrivacyGet, ToUserUpdateDB -from simcore_service_webserver.users.schemas import ( - ProfileGet, - ProfileUpdate, - ThirdPartyToken, -) +from simcore_service_webserver.users._models import ToUserUpdateDB +from simcore_service_webserver.users.schemas import ThirdPartyToken @pytest.mark.parametrize( @@ -161,11 +162,11 @@ def test_parsing_output_of_get_user_profile(): def test_mapping_update_models_from_rest_to_db(): profile_update = ProfileUpdate.model_validate( - # input in rest + # request payload { "first_name": "foo", "userName": "foo1234", - "privacy": {"hide_fullname": False}, + "privacy": {"hideFullname": False}, } ) @@ -173,7 +174,7 @@ def test_mapping_update_models_from_rest_to_db(): profile_update_db = ToUserUpdateDB.from_api(profile_update) # expected - assert profile_update_db.to_columns() == { + assert profile_update_db.to_db() == { "first_name": "foo", "name": "foo1234", "privacy_hide_fullname": False, From ffe4ebf5c4d82880546161329ff8069c088c3280 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 5 Dec 2024 22:12:26 +0100 Subject: [PATCH 30/36] minor --- api/specs/web-server/_users.py | 3 +-- .../tests/unit/with_dbs/03/login/test_login_registration.py | 2 +- services/web/server/tests/unit/with_dbs/03/test_users.py | 4 ++-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/api/specs/web-server/_users.py b/api/specs/web-server/_users.py index da4f3a95209..895df7173b4 100644 --- a/api/specs/web-server/_users.py +++ b/api/specs/web-server/_users.py @@ -7,6 +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_preferences import PatchRequestBody from models_library.generics import Envelope from models_library.user_preferences import PreferenceIdentifier @@ -24,8 +25,6 @@ from simcore_service_webserver.users._tokens_handlers import _TokenPathParams from simcore_service_webserver.users.schemas import ( PermissionGet, - ProfileGet, - ProfileUpdate, ThirdPartyToken, TokenCreate, ) 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 d99f8f1f297..762642dfb5c 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,6 +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.products import ProductName from pytest_mock import MockerFixture from pytest_simcore.helpers.assert_checks import assert_error, assert_status @@ -31,7 +32,6 @@ get_plugin_settings, ) from simcore_service_webserver.login.storage import AsyncpgStorage -from simcore_service_webserver.users.schemas import ProfileGet @pytest.fixture 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 31f088a2aca..4e2829c6fce 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,6 +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.generics import Envelope from psycopg2 import OperationalError from pytest_simcore.helpers.assert_checks import assert_status @@ -38,7 +39,6 @@ PreUserProfile, UserProfile, ) -from simcore_service_webserver.users.schemas import ProfileGet @pytest.fixture @@ -210,7 +210,7 @@ async def test_profile_workflow( json={ "first_name": "Odei", # NOTE: still not camecase! "userName": "odei123", - "privacy": {"hide_fullname": False}, + "privacy": {"hideFullname": False}, }, ) await assert_status(resp, status.HTTP_204_NO_CONTENT) From 1fc1195bde2d8ca1ca010242b008a22bacbf30a4 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 5 Dec 2024 22:16:34 +0100 Subject: [PATCH 31/36] update OAS --- .../api/v0/openapi.yaml | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 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 f620894fb2c..55bd6492319 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 @@ -4540,7 +4540,7 @@ paths: '403': description: ProjectInvalidRightsError '404': - description: UserDefaultWalletNotFoundError, ProjectNotFoundError + description: ProjectNotFoundError, UserDefaultWalletNotFoundError '409': description: ProjectTooManyProjectOpenedError '422': @@ -11588,29 +11588,29 @@ components: title: ProfileGet ProfilePrivacyGet: properties: - hide_fullname: + hideFullname: type: boolean - title: Hide Fullname - hide_email: + title: Hidefullname + hideEmail: type: boolean - title: Hide Email + title: Hideemail type: object required: - - hide_fullname - - hide_email + - hideFullname + - hideEmail title: ProfilePrivacyGet ProfilePrivacyUpdate: properties: - hide_fullname: + hideFullname: anyOf: - type: boolean - type: 'null' - title: Hide Fullname - hide_email: + title: Hidefullname + hideEmail: anyOf: - type: boolean - type: 'null' - title: Hide Email + title: Hideemail type: object title: ProfilePrivacyUpdate ProfileUpdate: @@ -11627,13 +11627,13 @@ components: maxLength: 255 - type: 'null' title: Last Name - user_name: + userName: anyOf: - type: string maxLength: 100 minLength: 1 - type: 'null' - title: User Name + title: Username privacy: anyOf: - $ref: '#/components/schemas/ProfilePrivacyUpdate' From 2f54321cf2de845d43a2b4253ca12e8fbcc0e3c5 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 5 Dec 2024 22:32:12 +0100 Subject: [PATCH 32/36] updates tests --- .../services/webserver.py | 29 +++++++++++++++---- .../tests/unit/_with_db/test_api_user.py | 4 +-- 2 files changed, 26 insertions(+), 7 deletions(-) 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 26313d03230..719d2ae50c2 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 @@ -30,6 +30,8 @@ ) from models_library.api_schemas_webserver.resource_usage import PricingPlanGet from models_library.api_schemas_webserver.wallets import WalletGet +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.generics import Envelope from models_library.projects import ProjectID from models_library.projects_nodes_io import NodeID @@ -78,6 +80,7 @@ WalletGetWithAvailableCreditsLegacy, ) from ..models.schemas.profiles import Profile, ProfileUpdate +from ..models.schemas.profiles import Profile, ProfileUpdate, UserRoleEnum from ..models.schemas.solvers import SolverKeyId from ..models.schemas.studies import StudyPort from ..utils.client_base import BaseServiceClientApi, setup_client_instance @@ -243,17 +246,33 @@ async def _wait_for_long_running_task_results(self, lrt_response: httpx.Response async def get_me(self) -> Profile: response = await self.client.get("/me", cookies=self.session_cookies) response.raise_for_status() - profile: Profile | None = ( - Envelope[Profile].model_validate_json(response.text).data + + got: WebProfileGet | None = ( + Envelope[WebProfileGet].model_validate_json(response.text).data + ) + assert got is not None # nosec + + return Profile( + first_name=got.first_name, + last_name=got.last_name, + id=got.id, + login=got.login, + role=UserRoleEnum(got.role), + groups=got.groups.model_dump() if got.groups else None, # type: ignore + gravatar_id=got.gravatar_id, ) - assert profile is not None # nosec - return profile @_exception_mapper(_PROFILE_STATUS_MAP) async def update_me(self, *, profile_update: ProfileUpdate) -> Profile: + + update = WebProfileUpdate( + first_name=profile_update.first_name, + last_name=profile_update.last_name, + ) + response = await self.client.patch( "/me", - json=profile_update.model_dump(exclude_none=True), + json=update.model_dump(exclude_none=True, exclude_unset=True), cookies=self.session_cookies, ) response.raise_for_status() 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 0a42177867b..17b4e2efc1c 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 @@ -4,12 +4,12 @@ import json -from copy import deepcopy import httpx import pytest import respx from fastapi import FastAPI +from models_library.api_schemas_webserver.users import ProfileGet as WebProfileGet from respx import MockRouter from simcore_service_api_server._meta import API_VTAG from simcore_service_api_server.core.settings import ApplicationSettings @@ -32,7 +32,7 @@ def mocked_webserver_service_api(app: FastAPI): ) as respx_mock: # NOTE: webserver-api uses the same schema as api-server! # in-memory fake data - me = deepcopy(Profile.model_config["json_schema_extra"]["example"]) + me = WebProfileGet.model_json_schema()["examples"][0] def _get_me(request): return httpx.Response(status.HTTP_200_OK, json={"data": me}) From 5e10c29fe94a7005f98728c68791c04231719c6b Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Fri, 6 Dec 2024 00:04:41 +0100 Subject: [PATCH 33/36] updates tests --- .../src/simcore_service_api_server/services/webserver.py | 5 +++-- services/api-server/tests/unit/_with_db/test_api_user.py | 8 ++++---- 2 files changed, 7 insertions(+), 6 deletions(-) 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 719d2ae50c2..3bfbcb6a610 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 @@ -265,14 +265,15 @@ async def get_me(self) -> Profile: @_exception_mapper(_PROFILE_STATUS_MAP) async def update_me(self, *, profile_update: ProfileUpdate) -> Profile: - update = WebProfileUpdate( + update = WebProfileUpdate.model_construct( + _fields_set=profile_update.model_fields_set, first_name=profile_update.first_name, last_name=profile_update.last_name, ) response = await self.client.patch( "/me", - json=update.model_dump(exclude_none=True, exclude_unset=True), + json=update.model_dump(exclude_unset=True), cookies=self.session_cookies, ) response.raise_for_status() 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 17b4e2efc1c..24836d1b3cd 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 @@ -32,7 +32,9 @@ def mocked_webserver_service_api(app: FastAPI): ) as respx_mock: # NOTE: webserver-api uses the same schema as api-server! # in-memory fake data - me = WebProfileGet.model_json_schema()["examples"][0] + me: dict = WebProfileGet.model_json_schema()["examples"][0] + me["first_name"] = "James" + me["last_name"] = "Maxwell" def _get_me(request): return httpx.Response(status.HTTP_200_OK, json={"data": me}) @@ -43,12 +45,10 @@ def _update_me(request: httpx.Request): return httpx.Response(status.HTTP_200_OK, json={"data": me}) respx_mock.get("/me", name="get_me").mock(side_effect=_get_me) - respx_mock.put("/me", name="update_me").mock(side_effect=_update_me) + respx_mock.patch("/me", name="update_me").mock(side_effect=_update_me) yield respx_mock - del me - async def test_get_profile( client: httpx.AsyncClient, From d16e92313e8681a0049b2b6a0da726352e381aca Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Fri, 6 Dec 2024 11:23:27 +0100 Subject: [PATCH 34/36] cleanup --- .../src/simcore_service_api_server/services/webserver.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 3bfbcb6a610..9301b5ce42c 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,9 +29,9 @@ ProjectInputUpdate, ) from models_library.api_schemas_webserver.resource_usage import PricingPlanGet -from models_library.api_schemas_webserver.wallets import WalletGet 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.wallets import WalletGet from models_library.generics import Envelope from models_library.projects import ProjectID from models_library.projects_nodes_io import NodeID @@ -79,7 +79,6 @@ PricingUnitGetLegacy, WalletGetWithAvailableCreditsLegacy, ) -from ..models.schemas.profiles import Profile, ProfileUpdate from ..models.schemas.profiles import Profile, ProfileUpdate, UserRoleEnum from ..models.schemas.solvers import SolverKeyId from ..models.schemas.studies import StudyPort From f6a3eade26228a0deaa25c1c994f2644af3906bf Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Fri, 6 Dec 2024 11:59:32 +0100 Subject: [PATCH 35/36] api-removed-without-deprecation --- api/specs/web-server/_users.py | 10 ++++++++++ .../api/v0/openapi.yaml | 16 ++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/api/specs/web-server/_users.py b/api/specs/web-server/_users.py index 895df7173b4..c161a7aa69a 100644 --- a/api/specs/web-server/_users.py +++ b/api/specs/web-server/_users.py @@ -48,6 +48,16 @@ async def update_my_profile(_profile: ProfileUpdate): ... +@router.put( + "/me", + status_code=status.HTTP_204_NO_CONTENT, + deprecated=True, + description="Use PATCH instead", +) +async def replace_my_profile(_profile: ProfileUpdate): + ... + + @router.patch( "/me/preferences/{preference_id}", status_code=status.HTTP_204_NO_CONTENT, 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 55bd6492319..91516c62ee8 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 @@ -1087,6 +1087,22 @@ paths: application/json: schema: $ref: '#/components/schemas/Envelope_ProfileGet_' + put: + tags: + - user + summary: Replace My Profile + description: Use PATCH instead + operationId: replace_my_profile + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ProfileUpdate' + required: true + responses: + '204': + description: Successful Response + deprecated: true patch: tags: - user From 0eadc8c378417d40e9c7f58ac9dfbd765981ac2b Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Fri, 6 Dec 2024 13:23:10 +0100 Subject: [PATCH 36/36] fixes test --- .../server/src/simcore_service_webserver/users/_handlers.py | 3 +++ 1 file changed, 3 insertions(+) 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 b9d9ae0bec8..d67d772e0ee 100644 --- a/services/web/server/src/simcore_service_webserver/users/_handlers.py +++ b/services/web/server/src/simcore_service_webserver/users/_handlers.py @@ -81,6 +81,9 @@ async def get_my_profile(request: web.Request) -> web.Response: @routes.patch(f"/{API_VTAG}/me", name="update_my_profile") +@routes.put( + f"/{API_VTAG}/me", name="replace_my_profile" # deprecated. Use patch instead +) @login_required @permission_required("user.profile.update") @_handle_users_exceptions