From 84758cbe2de2be4d50c151eefd213b7506299495 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 15 Jul 2025 13:36:42 +0200 Subject: [PATCH 01/30] models --- .../api_schemas_webserver/users.py | 21 ++++++++++++------- .../src/models_library/users.py | 2 ++ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/models-library/src/models_library/api_schemas_webserver/users.py b/packages/models-library/src/models_library/api_schemas_webserver/users.py index ab3d8b18e2d..8400f1b8d0e 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/users.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/users.py @@ -70,6 +70,7 @@ class MyProfileGet(OutputSchemaWithoutCamelCase): first_name: FirstNameStr | None = None last_name: LastNameStr | None = None login: LowerCaseEmailStr + phone: str | None = None role: Literal["ANONYMOUS", "GUEST", "USER", "TESTER", "PRODUCT_OWNER", "ADMIN"] groups: MyGroupsGet | None = None @@ -141,6 +142,7 @@ def from_domain_model( "last_name", "email", "role", + "phone", "privacy", "expiration_date", }, @@ -159,17 +161,22 @@ class MyProfilePatch(InputSchemaWithoutCamelCase): first_name: FirstNameStr | None = None last_name: LastNameStr | None = None user_name: Annotated[IDStr | None, Field(alias="userName", min_length=4)] = None + phone: str | None = None privacy: MyProfilePrivacyPatch | None = None - model_config = ConfigDict( - json_schema_extra={ - "example": { - "first_name": "Pedro", - "last_name": "Crespo", + @staticmethod + def _update_json_schema_extra(schema: JsonDict) -> None: + schema.update( + { + "examples": [ + {"first_name": "Pedro", "last_name": "Crespo"}, + {"phone": "+34 123 456 789"}, + ] } - } - ) + ) + + model_config = ConfigDict(json_schema_extra=_update_json_schema_extra) @field_validator("user_name") @classmethod diff --git a/packages/models-library/src/models_library/users.py b/packages/models-library/src/models_library/users.py index 3b8d4344b4b..a4cb18036b5 100644 --- a/packages/models-library/src/models_library/users.py +++ b/packages/models-library/src/models_library/users.py @@ -38,6 +38,7 @@ class MyProfile(BaseModel): email: LowerCaseEmailStr role: UserRole privacy: PrivacyDict + phone: str | None expiration_date: datetime.date | None = None @staticmethod @@ -50,6 +51,7 @@ def _update_json_schema_extra(schema: JsonDict) -> None: "user_name": "PtN5Ab0uv", "first_name": "PtN5Ab0uv", "last_name": "", + "phone": None, "role": "GUEST", "privacy": { "hide_email": True, From c2a945a4c78303c3e96bddeec6a5531dd237f137 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 15 Jul 2025 13:46:41 +0200 Subject: [PATCH 02/30] updates tests --- .../tests/unit/isolated/test_users_models.py | 4 +- .../with_dbs/03/test_users_rest_profiles.py | 63 +++++++++++++++++++ 2 files changed, 65 insertions(+), 2 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 f11385225cf..ff282c30546 100644 --- a/services/web/server/tests/unit/isolated/test_users_models.py +++ b/services/web/server/tests/unit/isolated/test_users_models.py @@ -18,7 +18,7 @@ from models_library.utils.fastapi_encoders import jsonable_encoder from servicelib.rest_constants import RESPONSE_MODEL_POLICY from simcore_postgres_database import utils_users -from simcore_service_webserver.users._models import ToUserUpdateDB +from simcore_service_webserver.users._models import UserDBAdapter @pytest.fixture @@ -132,7 +132,7 @@ def test_mapping_update_models_from_rest_to_db(): ) # to db - profile_update_db = ToUserUpdateDB.from_api(profile_update) + profile_update_db = UserDBAdapter.from_schema(profile_update) # expected assert profile_update_db.to_db() == { diff --git a/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py b/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py index 8b8779e39b4..8d6e28abc8a 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py +++ b/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py @@ -590,3 +590,66 @@ async def test_get_profile_with_failing_db_connection( data, error = await assert_status(resp, expected) assert not data assert error["message"] == "Authentication service is temporary unavailable" + + +@pytest.mark.parametrize("user_role", [UserRole.USER]) +async def test_get_and_update_phone_in_profile( + user_role: UserRole, + logged_user: UserInfoDict, + client: TestClient, +): + assert client.app + + # GET initial profile + url = client.app.router["get_my_profile"].url_for() + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + + initial_profile = MyProfileGet.model_validate(data) + initial_phone = initial_profile.phone + + # UPDATE phone number + new_phone = "+34 123 456 789" + url = client.app.router["update_my_profile"].url_for() + resp = await client.patch( + f"{url}", + json={ + "phone": new_phone, + }, + ) + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # GET updated profile + 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 = MyProfileGet.model_validate(data) + + # Verify phone was updated + assert updated_profile.phone == new_phone + assert updated_profile.phone != initial_phone + + # Verify other fields remained unchanged + assert updated_profile.first_name == initial_profile.first_name + assert updated_profile.last_name == initial_profile.last_name + assert updated_profile.login == initial_profile.login + assert updated_profile.user_name == initial_profile.user_name + + # UPDATE phone to None (clear it) + url = client.app.router["update_my_profile"].url_for() + resp = await client.patch( + f"{url}", + json={ + "phone": None, + }, + ) + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # GET profile after clearing phone + url = client.app.router["get_my_profile"].url_for() + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + + cleared_profile = MyProfileGet.model_validate(data) + assert cleared_profile.phone is None From ce2fe31158a0e8c7586a5f3ca314c3546a0a7e0b Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 15 Jul 2025 13:47:43 +0200 Subject: [PATCH 03/30] rename --- .../server/src/simcore_service_webserver/users/_models.py | 7 ++++--- .../simcore_service_webserver/users/_users_repository.py | 8 ++++---- .../src/simcore_service_webserver/users/_users_service.py | 4 ++-- 3 files changed, 10 insertions(+), 9 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 967f010d0b0..26dabfd056d 100644 --- a/services/web/server/src/simcore_service_webserver/users/_models.py +++ b/services/web/server/src/simcore_service_webserver/users/_models.py @@ -43,9 +43,9 @@ def flatten_dict(d: dict, parent_key="", sep="_"): return dict(items) -class ToUserUpdateDB(BaseModel): +class UserDBAdapter(BaseModel): """ - Maps ProfileUpdate api-model into UserUpdate db-model + Maps ProfileUpdate api schema into UserUpdate db-model """ # NOTE: field names are UserDB columns @@ -54,6 +54,7 @@ class ToUserUpdateDB(BaseModel): name: Annotated[str | None, Field(alias="user_name")] = None first_name: str | None = None last_name: str | None = None + phone: str | None = None privacy_hide_username: bool | None = None privacy_hide_fullname: bool | None = None @@ -62,7 +63,7 @@ class ToUserUpdateDB(BaseModel): model_config = ConfigDict(extra="forbid") @classmethod - def from_api(cls, profile_update) -> Self: + def from_schema(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)) diff --git a/services/web/server/src/simcore_service_webserver/users/_users_repository.py b/services/web/server/src/simcore_service_webserver/users/_users_repository.py index b1cdfd33637..ba65f10cb2d 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_repository.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_repository.py @@ -38,7 +38,7 @@ from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine from ..db.plugin import get_asyncpg_engine -from ._models import FullNameDict, ToUserUpdateDB +from ._models import FullNameDict from .exceptions import ( BillingDetailsNotFoundError, UserNameDuplicateError, @@ -471,16 +471,16 @@ async def update_user_profile( app: web.Application, *, user_id: UserID, - update: ToUserUpdateDB, + updated_values: dict[str, Any], ) -> None: """ Raises: UserNotFoundError UserNameAlreadyExistsError """ - user_id = _parse_as_user(user_id) + if updated_values: + user_id = _parse_as_user(user_id) - if updated_values := update.to_db(): try: async with transaction_context(engine=get_asyncpg_engine(app)) as conn: await conn.execute( diff --git a/services/web/server/src/simcore_service_webserver/users/_users_service.py b/services/web/server/src/simcore_service_webserver/users/_users_service.py index 88364e0a122..0353f4a0822 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_service.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_service.py @@ -22,8 +22,8 @@ from . import _users_repository from ._models import ( FullNameDict, - ToUserUpdateDB, UserCredentialsTuple, + UserDBAdapter, UserDisplayAndIdNamesTuple, UserIdNamesTuple, ) @@ -282,5 +282,5 @@ async def update_my_profile( await _users_repository.update_user_profile( app, user_id=user_id, - update=ToUserUpdateDB.from_api(update), + updated_values=UserDBAdapter.from_schema(update).to_db(), ) From 3ca9293a37013cbf2d63d25d28f8bf2d8d3bd735 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 15 Jul 2025 13:56:18 +0200 Subject: [PATCH 04/30] repo --- .../src/simcore_service_webserver/users/_users_repository.py | 3 ++- .../src/simcore_service_webserver/users/_users_service.py | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/users/_users_repository.py b/services/web/server/src/simcore_service_webserver/users/_users_repository.py index ba65f10cb2d..8b2f5ec4a08 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_repository.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_repository.py @@ -440,6 +440,7 @@ async def get_my_profile(app: web.Application, *, user_id: UserID) -> MyProfile: users.c.last_name, users.c.email, users.c.role, + users.c.phone, sa.func.json_build_object( "hide_username", users.c.privacy_hide_username, @@ -457,7 +458,7 @@ async def get_my_profile(app: web.Application, *, user_id: UserID) -> MyProfile: ).label("expiration_date"), ).where(users.c.id == user_id) ) - row = await result.first() + row = await result.one_or_none() if not row: raise UserNotFoundError(user_id=user_id) diff --git a/services/web/server/src/simcore_service_webserver/users/_users_service.py b/services/web/server/src/simcore_service_webserver/users/_users_service.py index 0353f4a0822..3185b0239f8 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_service.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_service.py @@ -4,12 +4,13 @@ import pycountry from aiohttp import web from models_library.api_schemas_webserver.users import MyProfilePatch +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.groups import GroupID from models_library.payments import UserInvoiceAddress from models_library.products import ProductName -from models_library.users import UserBillingDetails, UserID, UserPermission +from models_library.users import MyProfile, UserBillingDetails, UserID, UserPermission from pydantic import TypeAdapter from simcore_postgres_database.models.users import UserStatus from simcore_postgres_database.utils_groups_extra_properties import ( @@ -249,7 +250,7 @@ async def update_expired_users(app: web.Application) -> list[UserID]: async def get_my_profile( app: web.Application, *, user_id: UserID, product_name: ProductName -): +) -> tuple[MyProfile, AggregatedPreferences]: """Caller and target user is the same. Privacy settings do not apply here :raises UserNotFoundError: From ce00ac3fb06f5a3f26be6957c31de7097f76f2c5 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 15 Jul 2025 14:58:28 +0200 Subject: [PATCH 05/30] phonenumber --- packages/models-library/requirements/_base.in | 5 +++-- packages/models-library/requirements/_base.txt | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/models-library/requirements/_base.in b/packages/models-library/requirements/_base.in index b33d20bdd6b..45d7bb6ac11 100644 --- a/packages/models-library/requirements/_base.in +++ b/packages/models-library/requirements/_base.in @@ -7,6 +7,7 @@ arrow jsonschema orjson -pydantic[email] -pydantic-settings +phonenumbers pydantic-extra-types +pydantic-settings +pydantic[email] diff --git a/packages/models-library/requirements/_base.txt b/packages/models-library/requirements/_base.txt index d680cfc7b08..9beb3987184 100644 --- a/packages/models-library/requirements/_base.txt +++ b/packages/models-library/requirements/_base.txt @@ -22,6 +22,8 @@ orjson==3.10.15 # -c requirements/../../../requirements/constraints.txt # -r requirements/../../../packages/common-library/requirements/_base.in # -r requirements/_base.in +phonenumbers==9.0.9 + # via -r requirements/_base.in pydantic==2.10.6 # via # -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt From 15c930797cd566bea94039a9db6f5c116d3216eb Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 15 Jul 2025 15:00:20 +0200 Subject: [PATCH 06/30] phone validator --- .../src/models_library/api_schemas_webserver/users.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/models-library/src/models_library/api_schemas_webserver/users.py b/packages/models-library/src/models_library/api_schemas_webserver/users.py index 8400f1b8d0e..79458ce5a1e 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/users.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/users.py @@ -20,6 +20,7 @@ field_validator, ) from pydantic.config import JsonDict +from pydantic_extra_types.phone_numbers import PhoneNumberValidator from ..basic_types import IDStr from ..emails import LowerCaseEmailStr @@ -161,7 +162,7 @@ class MyProfilePatch(InputSchemaWithoutCamelCase): first_name: FirstNameStr | None = None last_name: LastNameStr | None = None user_name: Annotated[IDStr | None, Field(alias="userName", min_length=4)] = None - phone: str | None = None + phone: Annotated[str, PhoneNumberValidator()] | None = None privacy: MyProfilePrivacyPatch | None = None @@ -171,7 +172,7 @@ def _update_json_schema_extra(schema: JsonDict) -> None: { "examples": [ {"first_name": "Pedro", "last_name": "Crespo"}, - {"phone": "+34 123 456 789"}, + {"phone": "+41 44 245 96 96"}, ] } ) From 31ae4209d7ffd18d4caaaa53eefe52603263eba8 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 15 Jul 2025 15:07:34 +0200 Subject: [PATCH 07/30] add phonenumbers dependency and update phone number generation in tests --- services/web/server/requirements/_base.txt | 7 +++++++ .../tests/unit/with_dbs/03/test_users_rest_profiles.py | 4 +++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/services/web/server/requirements/_base.txt b/services/web/server/requirements/_base.txt index e0c48caa415..0626bcb944f 100644 --- a/services/web/server/requirements/_base.txt +++ b/services/web/server/requirements/_base.txt @@ -584,6 +584,13 @@ pamqp==3.2.1 # via aiormq passlib==1.7.4 # via -r requirements/_base.in +phonenumbers==9.0.9 + # via + # -r requirements/../../../../packages/models-library/requirements/_base.in + # -r requirements/../../../../packages/notifications-library/requirements/../../../packages/models-library/requirements/_base.in + # -r requirements/../../../../packages/service-library/requirements/../../../packages/models-library/requirements/_base.in + # -r requirements/../../../../packages/simcore-sdk/requirements/../../../packages/models-library/requirements/_base.in + # -r requirements/../../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/../../../packages/models-library/requirements/_base.in pillow==10.3.0 # via captcha pint==0.24.3 diff --git a/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py b/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py index 8d6e28abc8a..d5d3f13d946 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py +++ b/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py @@ -17,6 +17,7 @@ from aiohttp.test_utils import TestClient from aiopg.sa.connection import SAConnection from common_library.users_enums import UserRole +from faker import Faker from models_library.api_schemas_webserver.groups import GroupUserGet from models_library.api_schemas_webserver.users import ( MyProfileGet, @@ -597,6 +598,7 @@ async def test_get_and_update_phone_in_profile( user_role: UserRole, logged_user: UserInfoDict, client: TestClient, + faker: Faker, ): assert client.app @@ -609,7 +611,7 @@ async def test_get_and_update_phone_in_profile( initial_phone = initial_profile.phone # UPDATE phone number - new_phone = "+34 123 456 789" + new_phone = faker.phone_number() url = client.app.router["update_my_profile"].url_for() resp = await client.patch( f"{url}", From ab93e95342aaf28c1daba860d39639f27a706044 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 15 Jul 2025 17:30:05 +0200 Subject: [PATCH 08/30] remove patch phone --- api/specs/web-server/_users.py | 4 ++-- .../models_library/api_schemas_webserver/users.py | 14 +++----------- .../tests/test_api_schemas_webserver_users.py | 8 ++++---- packages/models-library/tests/test_users.py | 4 ++-- .../services_http/webserver.py | 2 +- .../tests/unit/_with_db/test_api_user.py | 2 +- .../users/_controller/rest/users_rest.py | 4 ++-- .../tests/unit/isolated/test_users_models.py | 14 +++++++------- .../with_dbs/03/login/test_login_registration.py | 4 ++-- .../unit/with_dbs/03/test_users_rest_profiles.py | 14 +++++++------- 10 files changed, 31 insertions(+), 39 deletions(-) diff --git a/api/specs/web-server/_users.py b/api/specs/web-server/_users.py index 093434bac1c..f60ecfeb3e4 100644 --- a/api/specs/web-server/_users.py +++ b/api/specs/web-server/_users.py @@ -10,8 +10,8 @@ from models_library.api_schemas_webserver.users import ( MyFunctionPermissionsGet, MyPermissionGet, - MyProfileGet, MyProfilePatch, + MyProfileRestGet, MyTokenCreate, MyTokenGet, TokenPathParams, @@ -36,7 +36,7 @@ @router.get( "/me", - response_model=Envelope[MyProfileGet], + response_model=Envelope[MyProfileRestGet], ) async def get_my_profile(): ... diff --git a/packages/models-library/src/models_library/api_schemas_webserver/users.py b/packages/models-library/src/models_library/api_schemas_webserver/users.py index 79458ce5a1e..ff510e93e54 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/users.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/users.py @@ -20,7 +20,6 @@ field_validator, ) from pydantic.config import JsonDict -from pydantic_extra_types.phone_numbers import PhoneNumberValidator from ..basic_types import IDStr from ..emails import LowerCaseEmailStr @@ -63,7 +62,7 @@ class MyProfilePrivacyPatch(InputSchema): hide_email: bool | None = None -class MyProfileGet(OutputSchemaWithoutCamelCase): +class MyProfileRestGet(OutputSchemaWithoutCamelCase): id: UserID user_name: Annotated[ IDStr, Field(description="Unique username identifier", alias="userName") @@ -162,20 +161,13 @@ class MyProfilePatch(InputSchemaWithoutCamelCase): first_name: FirstNameStr | None = None last_name: LastNameStr | None = None user_name: Annotated[IDStr | None, Field(alias="userName", min_length=4)] = None - phone: Annotated[str, PhoneNumberValidator()] | None = None + # NOTE: phone is updated via a dedicated endpoint! privacy: MyProfilePrivacyPatch | None = None @staticmethod def _update_json_schema_extra(schema: JsonDict) -> None: - schema.update( - { - "examples": [ - {"first_name": "Pedro", "last_name": "Crespo"}, - {"phone": "+41 44 245 96 96"}, - ] - } - ) + schema.update({"examples": [{"first_name": "Pedro", "last_name": "Crespo"}]}) model_config = ConfigDict(json_schema_extra=_update_json_schema_extra) diff --git a/packages/models-library/tests/test_api_schemas_webserver_users.py b/packages/models-library/tests/test_api_schemas_webserver_users.py index afefb91c481..fa4532e792c 100644 --- a/packages/models-library/tests/test_api_schemas_webserver_users.py +++ b/packages/models-library/tests/test_api_schemas_webserver_users.py @@ -8,21 +8,21 @@ import pytest from common_library.users_enums import UserRole from models_library.api_schemas_webserver.users import ( - MyProfileGet, MyProfilePatch, + MyProfileRestGet, ) from pydantic import ValidationError @pytest.mark.parametrize("user_role", [u.name for u in UserRole]) def test_profile_get_role(user_role: str): - for example in MyProfileGet.model_json_schema()["examples"]: + for example in MyProfileRestGet.model_json_schema()["examples"]: data = deepcopy(example) data["role"] = user_role - m1 = MyProfileGet(**data) + m1 = MyProfileRestGet(**data) data["role"] = UserRole(user_role) - m2 = MyProfileGet(**data) + m2 = MyProfileRestGet(**data) assert m1 == m2 diff --git a/packages/models-library/tests/test_users.py b/packages/models-library/tests/test_users.py index 4c9d2756934..743ee51135a 100644 --- a/packages/models-library/tests/test_users.py +++ b/packages/models-library/tests/test_users.py @@ -1,4 +1,4 @@ -from models_library.api_schemas_webserver.users import MyProfileGet +from models_library.api_schemas_webserver.users import MyProfileRestGet from models_library.api_schemas_webserver.users_preferences import Preference from models_library.groups import AccessRightsDict, Group, GroupsByTypeTuple from models_library.users import MyProfile @@ -22,6 +22,6 @@ def test_adapter_from_model_to_schema(): ) my_preferences = {"foo": Preference(default_value=3, value=1)} - MyProfileGet.from_domain_model( + MyProfileRestGet.from_domain_model( my_profile, my_groups_by_type, my_product_group, my_preferences ) diff --git a/services/api-server/src/simcore_service_api_server/services_http/webserver.py b/services/api-server/src/simcore_service_api_server/services_http/webserver.py index b4b998b3934..8adac0a76d2 100644 --- a/services/api-server/src/simcore_service_api_server/services_http/webserver.py +++ b/services/api-server/src/simcore_service_api_server/services_http/webserver.py @@ -30,10 +30,10 @@ ProjectInputUpdate, ) from models_library.api_schemas_webserver.resource_usage import PricingPlanGet -from models_library.api_schemas_webserver.users import MyProfileGet as WebProfileGet from models_library.api_schemas_webserver.users import ( MyProfilePatch as WebProfileUpdate, ) +from models_library.api_schemas_webserver.users import MyProfileRestGet as WebProfileGet from models_library.api_schemas_webserver.wallets import WalletGet from models_library.generics import Envelope from models_library.products import ProductName 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 5b29f72ef15..b255889b339 100644 --- a/services/api-server/tests/unit/_with_db/test_api_user.py +++ b/services/api-server/tests/unit/_with_db/test_api_user.py @@ -9,7 +9,7 @@ import pytest import respx from fastapi import FastAPI -from models_library.api_schemas_webserver.users import MyProfileGet as WebProfileGet +from models_library.api_schemas_webserver.users import MyProfileRestGet as WebProfileGet from pytest_mock import MockType from respx import MockRouter from simcore_service_api_server._meta import API_VTAG diff --git a/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py b/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py index f45f9433bb9..c1eac51498e 100644 --- a/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py @@ -3,8 +3,8 @@ from aiohttp import web from models_library.api_schemas_webserver.users import ( - MyProfileGet, MyProfilePatch, + MyProfileRestGet, UserGet, UsersSearch, ) @@ -64,7 +64,7 @@ async def get_my_profile(request: web.Request) -> web.Response: request.app, user_id=req_ctx.user_id, product_name=req_ctx.product_name ) - profile = MyProfileGet.from_domain_model( + profile = MyProfileRestGet.from_domain_model( my_profile, groups_by_type, my_product_group, 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 ff282c30546..70b7729120e 100644 --- a/services/web/server/tests/unit/isolated/test_users_models.py +++ b/services/web/server/tests/unit/isolated/test_users_models.py @@ -10,9 +10,9 @@ import pytest from faker import Faker from models_library.api_schemas_webserver.users import ( - MyProfileGet, MyProfilePatch, MyProfilePrivacyGet, + MyProfileRestGet, ) from models_library.generics import Envelope from models_library.utils.fastapi_encoders import jsonable_encoder @@ -22,11 +22,11 @@ @pytest.fixture -def fake_profile_get(faker: Faker) -> MyProfileGet: +def fake_profile_get(faker: Faker) -> MyProfileRestGet: fake_profile: dict[str, Any] = faker.simple_profile() first, last = fake_profile["name"].rsplit(maxsplit=1) - return MyProfileGet( + return MyProfileRestGet( id=faker.pyint(), first_name=first, last_name=last, @@ -40,7 +40,7 @@ def fake_profile_get(faker: Faker) -> MyProfileGet: ) -def test_profile_get_expiration_date(fake_profile_get: MyProfileGet): +def test_profile_get_expiration_date(fake_profile_get: MyProfileRestGet): fake_expiration = datetime.now(UTC) profile = fake_profile_get.model_copy( @@ -53,7 +53,7 @@ def test_profile_get_expiration_date(fake_profile_get: MyProfileGet): assert body["expirationDate"] == fake_expiration.date().isoformat() -def test_auto_compute_gravatar__deprecated(fake_profile_get: MyProfileGet): +def test_auto_compute_gravatar__deprecated(fake_profile_get: MyProfileRestGet): profile = fake_profile_get.model_copy() @@ -62,7 +62,7 @@ def test_auto_compute_gravatar__deprecated(fake_profile_get: MyProfileGet): assert ( "gravatar_id" not in data - ), f"{dict(MyProfileGet.model_fields)['gravatar_id'].deprecated=}" + ), f"{dict(MyProfileRestGet.model_fields)['gravatar_id'].deprecated=}" assert data["id"] == profile.id assert data["first_name"] == profile.first_name assert data["last_name"] == profile.last_name @@ -116,7 +116,7 @@ def test_parsing_output_of_get_user_profile(): }, } - profile = MyProfileGet.model_validate(result_from_db_query_and_composition) + profile = MyProfileRestGet.model_validate(result_from_db_query_and_composition) assert "password" not in profile.model_dump(exclude_unset=True) 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 1262b78ed97..ea7bbae817e 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_registration.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_registration.py @@ -9,7 +9,7 @@ import pytest from aiohttp.test_utils import TestClient from faker import Faker -from models_library.api_schemas_webserver.users import MyProfileGet +from models_library.api_schemas_webserver.users import MyProfileRestGet from models_library.products import ProductName from pytest_mock import MockerFixture from pytest_simcore.helpers.assert_checks import assert_error, assert_status @@ -494,7 +494,7 @@ async def test_registraton_with_invitation_for_trial_account( url = client.app.router["get_my_profile"].url_for() response = await client.get(url.path) data, _ = await assert_status(response, status.HTTP_200_OK) - profile = MyProfileGet.model_validate(data) + profile = MyProfileRestGet.model_validate(data) expected = invitation.user["created_at"] + timedelta(days=TRIAL_DAYS) assert profile.expiration_date diff --git a/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py b/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py index d5d3f13d946..6fd23dcffa4 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py +++ b/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py @@ -20,7 +20,7 @@ from faker import Faker from models_library.api_schemas_webserver.groups import GroupUserGet from models_library.api_schemas_webserver.users import ( - MyProfileGet, + MyProfileRestGet, UserGet, ) from psycopg2 import OperationalError @@ -383,7 +383,7 @@ async def test_get_profile( data, error = await assert_status(resp, status.HTTP_200_OK) assert not error - profile = MyProfileGet.model_validate(data) + profile = MyProfileRestGet.model_validate(data) assert profile.login == logged_user["email"] assert profile.first_name == logged_user.get("first_name", None) @@ -465,7 +465,7 @@ async def test_profile_workflow( url = client.app.router["get_my_profile"].url_for() resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) - my_profile = MyProfileGet.model_validate(data) + my_profile = MyProfileRestGet.model_validate(data) url = client.app.router["update_my_profile"].url_for() resp = await client.patch( @@ -481,7 +481,7 @@ async def test_profile_workflow( url = client.app.router["get_my_profile"].url_for() resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) - updated_profile = MyProfileGet.model_validate(data) + updated_profile = MyProfileRestGet.model_validate(data) assert updated_profile.first_name != my_profile.first_name assert updated_profile.last_name == my_profile.last_name @@ -607,7 +607,7 @@ async def test_get_and_update_phone_in_profile( resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) - initial_profile = MyProfileGet.model_validate(data) + initial_profile = MyProfileRestGet.model_validate(data) initial_phone = initial_profile.phone # UPDATE phone number @@ -626,7 +626,7 @@ async def test_get_and_update_phone_in_profile( resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) - updated_profile = MyProfileGet.model_validate(data) + updated_profile = MyProfileRestGet.model_validate(data) # Verify phone was updated assert updated_profile.phone == new_phone @@ -653,5 +653,5 @@ async def test_get_and_update_phone_in_profile( resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) - cleared_profile = MyProfileGet.model_validate(data) + cleared_profile = MyProfileRestGet.model_validate(data) assert cleared_profile.phone is None From e4dd11977b67e6ccf07636845dfbad27a1b3d7b4 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 15 Jul 2025 17:34:12 +0200 Subject: [PATCH 09/30] renaming --- .../src/simcore_service_webserver/security_twofa/__init__.py | 0 .../web/server/src/simcore_service_webserver/users/_models.py | 2 +- .../src/simcore_service_webserver/users/_users_service.py | 4 ++-- services/web/server/tests/unit/isolated/test_users_models.py | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) create mode 100644 services/web/server/src/simcore_service_webserver/security_twofa/__init__.py diff --git a/services/web/server/src/simcore_service_webserver/security_twofa/__init__.py b/services/web/server/src/simcore_service_webserver/security_twofa/__init__.py new file mode 100644 index 00000000000..e69de29bb2d 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 26dabfd056d..315cb0d846f 100644 --- a/services/web/server/src/simcore_service_webserver/users/_models.py +++ b/services/web/server/src/simcore_service_webserver/users/_models.py @@ -43,7 +43,7 @@ def flatten_dict(d: dict, parent_key="", sep="_"): return dict(items) -class UserDBAdapter(BaseModel): +class UserModelAdapter(BaseModel): """ Maps ProfileUpdate api schema into UserUpdate db-model """ diff --git a/services/web/server/src/simcore_service_webserver/users/_users_service.py b/services/web/server/src/simcore_service_webserver/users/_users_service.py index 3185b0239f8..ea13ae28fec 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_service.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_service.py @@ -24,9 +24,9 @@ from ._models import ( FullNameDict, UserCredentialsTuple, - UserDBAdapter, UserDisplayAndIdNamesTuple, UserIdNamesTuple, + UserModelAdapter, ) from .exceptions import ( MissingGroupExtraPropertiesForProductError, @@ -283,5 +283,5 @@ async def update_my_profile( await _users_repository.update_user_profile( app, user_id=user_id, - updated_values=UserDBAdapter.from_schema(update).to_db(), + updated_values=UserModelAdapter.from_schema(update).to_db(), ) 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 70b7729120e..58dbcd53b72 100644 --- a/services/web/server/tests/unit/isolated/test_users_models.py +++ b/services/web/server/tests/unit/isolated/test_users_models.py @@ -18,7 +18,7 @@ from models_library.utils.fastapi_encoders import jsonable_encoder from servicelib.rest_constants import RESPONSE_MODEL_POLICY from simcore_postgres_database import utils_users -from simcore_service_webserver.users._models import UserDBAdapter +from simcore_service_webserver.users._models import UserModelAdapter @pytest.fixture @@ -132,7 +132,7 @@ def test_mapping_update_models_from_rest_to_db(): ) # to db - profile_update_db = UserDBAdapter.from_schema(profile_update) + profile_update_db = UserModelAdapter.from_schema(profile_update) # expected assert profile_update_db.to_db() == { From 2306f86c6652ec421ae4c68790e1b3c03ce00e91 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 15 Jul 2025 17:39:30 +0200 Subject: [PATCH 10/30] cleanup --- api/specs/web-server/_users.py | 4 ++-- packages/models-library/requirements/_base.in | 1 - .../models-library/requirements/_base.txt | 2 -- .../api_schemas_webserver/users.py | 2 +- .../tests/test_api_schemas_webserver_users.py | 22 +++++++++---------- .../services_http/webserver.py | 4 ++-- .../users/_controller/rest/users_rest.py | 4 ++-- .../users/_models.py | 6 ++--- .../users/_users_service.py | 6 ++--- .../tests/unit/isolated/test_users_models.py | 12 +++++----- 10 files changed, 30 insertions(+), 33 deletions(-) diff --git a/api/specs/web-server/_users.py b/api/specs/web-server/_users.py index f60ecfeb3e4..f6d6affee09 100644 --- a/api/specs/web-server/_users.py +++ b/api/specs/web-server/_users.py @@ -10,8 +10,8 @@ from models_library.api_schemas_webserver.users import ( MyFunctionPermissionsGet, MyPermissionGet, - MyProfilePatch, MyProfileRestGet, + MyProfileRestPatch, MyTokenCreate, MyTokenGet, TokenPathParams, @@ -45,7 +45,7 @@ async def get_my_profile(): ... "/me", status_code=status.HTTP_204_NO_CONTENT, ) -async def update_my_profile(_body: MyProfilePatch): ... +async def update_my_profile(_body: MyProfileRestPatch): ... @router.patch( diff --git a/packages/models-library/requirements/_base.in b/packages/models-library/requirements/_base.in index 45d7bb6ac11..34141532522 100644 --- a/packages/models-library/requirements/_base.in +++ b/packages/models-library/requirements/_base.in @@ -7,7 +7,6 @@ arrow jsonschema orjson -phonenumbers pydantic-extra-types pydantic-settings pydantic[email] diff --git a/packages/models-library/requirements/_base.txt b/packages/models-library/requirements/_base.txt index 9beb3987184..d680cfc7b08 100644 --- a/packages/models-library/requirements/_base.txt +++ b/packages/models-library/requirements/_base.txt @@ -22,8 +22,6 @@ orjson==3.10.15 # -c requirements/../../../requirements/constraints.txt # -r requirements/../../../packages/common-library/requirements/_base.in # -r requirements/_base.in -phonenumbers==9.0.9 - # via -r requirements/_base.in pydantic==2.10.6 # via # -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt diff --git a/packages/models-library/src/models_library/api_schemas_webserver/users.py b/packages/models-library/src/models_library/api_schemas_webserver/users.py index ff510e93e54..89953ae1d81 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/users.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/users.py @@ -157,7 +157,7 @@ def from_domain_model( ) -class MyProfilePatch(InputSchemaWithoutCamelCase): +class MyProfileRestPatch(InputSchemaWithoutCamelCase): first_name: FirstNameStr | None = None last_name: LastNameStr | None = None user_name: Annotated[IDStr | None, Field(alias="userName", min_length=4)] = None diff --git a/packages/models-library/tests/test_api_schemas_webserver_users.py b/packages/models-library/tests/test_api_schemas_webserver_users.py index fa4532e792c..43375a67e20 100644 --- a/packages/models-library/tests/test_api_schemas_webserver_users.py +++ b/packages/models-library/tests/test_api_schemas_webserver_users.py @@ -8,8 +8,8 @@ import pytest from common_library.users_enums import UserRole from models_library.api_schemas_webserver.users import ( - MyProfilePatch, MyProfileRestGet, + MyProfileRestPatch, ) from pydantic import ValidationError @@ -29,23 +29,23 @@ def test_profile_get_role(user_role: str): def test_my_profile_patch_username_min_len(): # minimum length username is 4 with pytest.raises(ValidationError) as err_info: - MyProfilePatch.model_validate({"userName": "abc"}) + MyProfileRestPatch.model_validate({"userName": "abc"}) assert err_info.value.error_count() == 1 assert err_info.value.errors()[0]["type"] == "too_short" - MyProfilePatch.model_validate({"userName": "abcd"}) # OK + MyProfileRestPatch.model_validate({"userName": "abcd"}) # OK def test_my_profile_patch_username_valid_characters(): # Ensure valid characters (alphanumeric + . _ -) with pytest.raises(ValidationError, match="start with a letter") as err_info: - MyProfilePatch.model_validate({"userName": "1234"}) + MyProfileRestPatch.model_validate({"userName": "1234"}) assert err_info.value.error_count() == 1 assert err_info.value.errors()[0]["type"] == "value_error" - MyProfilePatch.model_validate({"userName": "u1234"}) # OK + MyProfileRestPatch.model_validate({"userName": "u1234"}) # OK def test_my_profile_patch_username_special_characters(): @@ -53,29 +53,29 @@ def test_my_profile_patch_username_special_characters(): with pytest.raises( ValidationError, match="consecutive special characters" ) as err_info: - MyProfilePatch.model_validate({"userName": "u1__234"}) + MyProfileRestPatch.model_validate({"userName": "u1__234"}) assert err_info.value.error_count() == 1 assert err_info.value.errors()[0]["type"] == "value_error" - MyProfilePatch.model_validate({"userName": "u1_234"}) # OK + MyProfileRestPatch.model_validate({"userName": "u1_234"}) # OK # Ensure it doesn't end with a special character with pytest.raises(ValidationError, match="end with") as err_info: - MyProfilePatch.model_validate({"userName": "u1234_"}) + MyProfileRestPatch.model_validate({"userName": "u1234_"}) assert err_info.value.error_count() == 1 assert err_info.value.errors()[0]["type"] == "value_error" - MyProfilePatch.model_validate({"userName": "u1_234"}) # OK + MyProfileRestPatch.model_validate({"userName": "u1_234"}) # OK def test_my_profile_patch_username_reserved_words(): # Check reserved words (example list; extend as needed) with pytest.raises(ValidationError, match="cannot be used") as err_info: - MyProfilePatch.model_validate({"userName": "admin"}) + MyProfileRestPatch.model_validate({"userName": "admin"}) assert err_info.value.error_count() == 1 assert err_info.value.errors()[0]["type"] == "value_error" - MyProfilePatch.model_validate({"userName": "midas"}) # OK + MyProfileRestPatch.model_validate({"userName": "midas"}) # OK diff --git a/services/api-server/src/simcore_service_api_server/services_http/webserver.py b/services/api-server/src/simcore_service_api_server/services_http/webserver.py index 8adac0a76d2..c564c29cbd0 100644 --- a/services/api-server/src/simcore_service_api_server/services_http/webserver.py +++ b/services/api-server/src/simcore_service_api_server/services_http/webserver.py @@ -30,10 +30,10 @@ ProjectInputUpdate, ) from models_library.api_schemas_webserver.resource_usage import PricingPlanGet +from models_library.api_schemas_webserver.users import MyProfileRestGet as WebProfileGet from models_library.api_schemas_webserver.users import ( - MyProfilePatch as WebProfileUpdate, + MyProfileRestPatch as WebProfileUpdate, ) -from models_library.api_schemas_webserver.users import MyProfileRestGet as WebProfileGet from models_library.api_schemas_webserver.wallets import WalletGet from models_library.generics import Envelope from models_library.products import ProductName diff --git a/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py b/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py index c1eac51498e..8ea604fd746 100644 --- a/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py @@ -3,8 +3,8 @@ from aiohttp import web from models_library.api_schemas_webserver.users import ( - MyProfilePatch, MyProfileRestGet, + MyProfileRestPatch, UserGet, UsersSearch, ) @@ -77,7 +77,7 @@ async def get_my_profile(request: web.Request) -> web.Response: @handle_rest_requests_exceptions async def update_my_profile(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) - profile_update = await parse_request_body_as(MyProfilePatch, request) + profile_update = await parse_request_body_as(MyProfileRestPatch, request) await _users_service.update_my_profile( request.app, user_id=req_ctx.user_id, update=profile_update 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 315cb0d846f..b9f612d34c5 100644 --- a/services/web/server/src/simcore_service_webserver/users/_models.py +++ b/services/web/server/src/simcore_service_webserver/users/_models.py @@ -1,5 +1,6 @@ from typing import Annotated, Any, NamedTuple, Self, TypedDict +from models_library.api_schemas_webserver.users import MyProfileRestPatch from models_library.basic_types import IDStr from models_library.emails import LowerCaseEmailStr from pydantic import BaseModel, ConfigDict, EmailStr, Field @@ -54,7 +55,6 @@ class UserModelAdapter(BaseModel): name: Annotated[str | None, Field(alias="user_name")] = None first_name: str | None = None last_name: str | None = None - phone: str | None = None privacy_hide_username: bool | None = None privacy_hide_fullname: bool | None = None @@ -63,13 +63,13 @@ class UserModelAdapter(BaseModel): model_config = ConfigDict(extra="forbid") @classmethod - def from_schema(cls, profile_update) -> Self: + def from_rest_schema_model(cls, profile_update: MyProfileRestPatch) -> 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_db(self) -> dict[str, Any]: + def to_db_values(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/_users_service.py b/services/web/server/src/simcore_service_webserver/users/_users_service.py index ea13ae28fec..76d479ad5ec 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_service.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_service.py @@ -3,7 +3,7 @@ import pycountry from aiohttp import web -from models_library.api_schemas_webserver.users import MyProfilePatch +from models_library.api_schemas_webserver.users import MyProfileRestPatch from models_library.api_schemas_webserver.users_preferences import AggregatedPreferences from models_library.basic_types import IDStr from models_library.emails import LowerCaseEmailStr @@ -277,11 +277,11 @@ async def update_my_profile( app: web.Application, *, user_id: UserID, - update: MyProfilePatch, + update: MyProfileRestPatch, ) -> None: await _users_repository.update_user_profile( app, user_id=user_id, - updated_values=UserModelAdapter.from_schema(update).to_db(), + updated_values=UserModelAdapter.from_rest_schema_model(update).to_db_values(), ) 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 58dbcd53b72..48b621c51f5 100644 --- a/services/web/server/tests/unit/isolated/test_users_models.py +++ b/services/web/server/tests/unit/isolated/test_users_models.py @@ -10,9 +10,9 @@ import pytest from faker import Faker from models_library.api_schemas_webserver.users import ( - MyProfilePatch, MyProfilePrivacyGet, MyProfileRestGet, + MyProfileRestPatch, ) from models_library.generics import Envelope from models_library.utils.fastapi_encoders import jsonable_encoder @@ -122,7 +122,7 @@ def test_parsing_output_of_get_user_profile(): def test_mapping_update_models_from_rest_to_db(): - profile_update = MyProfilePatch.model_validate( + profile_update = MyProfileRestPatch.model_validate( # request payload { "first_name": "foo", @@ -132,10 +132,10 @@ def test_mapping_update_models_from_rest_to_db(): ) # to db - profile_update_db = UserModelAdapter.from_schema(profile_update) + profile_update_db = UserModelAdapter.from_rest_schema_model(profile_update) # expected - assert profile_update_db.to_db() == { + assert profile_update_db.to_db_values() == { "first_name": "foo", "name": "foo1234", "privacy_hide_fullname": False, @@ -146,7 +146,7 @@ def test_mapping_update_models_from_rest_to_db(): def test_utils_user_generates_valid_myprofile_patch(): username = utils_users._generate_username_from_email("xi@email.com") # noqa: SLF001 - MyProfilePatch.model_validate({"userName": username}) - MyProfilePatch.model_validate( + MyProfileRestPatch.model_validate({"userName": username}) + MyProfileRestPatch.model_validate( {"userName": utils_users.generate_alternative_username(username)} ) From 08131c003ce8c77c5ebd723252b991d2a90e5517 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 15 Jul 2025 17:40:11 +0200 Subject: [PATCH 11/30] undo phone --- .../src/simcore_service_webserver/security_twofa/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 services/web/server/src/simcore_service_webserver/security_twofa/__init__.py diff --git a/services/web/server/src/simcore_service_webserver/security_twofa/__init__.py b/services/web/server/src/simcore_service_webserver/security_twofa/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 From eafc4e14d99cbb9fa9643e817b08156891f86806 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 15 Jul 2025 17:56:31 +0200 Subject: [PATCH 12/30] phone registration --- api/specs/web-server/_users.py | 16 ++++++++++++++ .../api_schemas_webserver/users.py | 22 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/api/specs/web-server/_users.py b/api/specs/web-server/_users.py index f6d6affee09..5712f162541 100644 --- a/api/specs/web-server/_users.py +++ b/api/specs/web-server/_users.py @@ -10,6 +10,8 @@ from models_library.api_schemas_webserver.users import ( MyFunctionPermissionsGet, MyPermissionGet, + MyPhoneConfirm, + MyPhoneRegister, MyProfileRestGet, MyProfileRestPatch, MyTokenCreate, @@ -48,6 +50,20 @@ async def get_my_profile(): ... async def update_my_profile(_body: MyProfileRestPatch): ... +@router.post( + "/me/phone:register", + status_code=status.HTTP_202_ACCEPTED, +) +async def register_my_phone_init(_body: MyPhoneRegister): ... + + +@router.post( + "/me/phone:confirm", + status_code=status.HTTP_204_NO_CONTENT, +) +async def register_my_phone_confirm(_body: MyPhoneConfirm): ... + + @router.patch( "/me/preferences/{preference_id}", status_code=status.HTTP_204_NO_CONTENT, diff --git a/packages/models-library/src/models_library/api_schemas_webserver/users.py b/packages/models-library/src/models_library/api_schemas_webserver/users.py index 89953ae1d81..6d1be6f1875 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/users.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/users.py @@ -207,6 +207,28 @@ def _validate_user_name(cls, value: str): return value +# +# PHONE REGISTRATION +# + + +class MyPhoneRegister(InputSchema): + phone: Annotated[ + str, + StringConstraints(strip_whitespace=True, min_length=1), + Field(description="Phone number to register"), + ] + force: bool = False + + +class MyPhoneConfirm(InputSchema): + code: Annotated[ + str, + StringConstraints(strip_whitespace=True, pattern=r"^[A-Za-z0-9]+$"), + Field(description="Alphanumeric confirmation code"), + ] + + # # USER # From 3f387955c79f21dd2c34594621e95abe4cb14ae4 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 15 Jul 2025 17:59:29 +0200 Subject: [PATCH 13/30] draft update phone --- .../with_dbs/03/test_users_rest_profiles.py | 35 +++++++++++++++---- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py b/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py index 6fd23dcffa4..3ef0bea798c 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py +++ b/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py @@ -610,15 +610,25 @@ async def test_get_and_update_phone_in_profile( initial_profile = MyProfileRestGet.model_validate(data) initial_phone = initial_profile.phone - # UPDATE phone number + # REGISTER phone number new_phone = faker.phone_number() - url = client.app.router["update_my_profile"].url_for() - resp = await client.patch( + url = client.app.router["register_my_phone_init"].url_for() + resp = await client.post( f"{url}", json={ "phone": new_phone, }, ) + await assert_status(resp, status.HTTP_202_ACCEPTED) + + # CONFIRM phone registration + url = client.app.router["register_my_phone_confirm"].url_for() + resp = await client.post( + f"{url}", + json={ + "code": "123456", + }, + ) await assert_status(resp, status.HTTP_204_NO_CONTENT) # GET updated profile @@ -638,12 +648,23 @@ async def test_get_and_update_phone_in_profile( assert updated_profile.login == initial_profile.login assert updated_profile.user_name == initial_profile.user_name - # UPDATE phone to None (clear it) - url = client.app.router["update_my_profile"].url_for() - resp = await client.patch( + # REGISTER phone to None (clear it) using force=True + url = client.app.router["register_my_phone_init"].url_for() + resp = await client.post( + f"{url}", + json={ + "phone": "", + "force": True, + }, + ) + await assert_status(resp, status.HTTP_202_ACCEPTED) + + # CONFIRM phone clearing + url = client.app.router["register_my_phone_confirm"].url_for() + resp = await client.post( f"{url}", json={ - "phone": None, + "code": "123456", }, ) await assert_status(resp, status.HTTP_204_NO_CONTENT) From 8eac989acb4662b0533a93b834c3df58ce6d248f Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 15 Jul 2025 18:25:15 +0200 Subject: [PATCH 14/30] created rest api --- api/specs/web-server/_users.py | 14 +- .../api_schemas_webserver/users.py | 2 +- .../with_dbs/03/test_users_rest_profiles.py | 183 +++++++++++++++++- 3 files changed, 186 insertions(+), 13 deletions(-) diff --git a/api/specs/web-server/_users.py b/api/specs/web-server/_users.py index 5712f162541..a1a20febdda 100644 --- a/api/specs/web-server/_users.py +++ b/api/specs/web-server/_users.py @@ -52,16 +52,26 @@ async def update_my_profile(_body: MyProfileRestPatch): ... @router.post( "/me/phone:register", + description="Starts the phone registration process", status_code=status.HTTP_202_ACCEPTED, ) -async def register_my_phone_init(_body: MyPhoneRegister): ... +async def my_phone_register(_body: MyPhoneRegister): ... + + +@router.post( + "/me/phone:resend", + description="Resends the phone registration code", + status_code=status.HTTP_202_ACCEPTED, +) +async def my_phone_resend(): ... @router.post( "/me/phone:confirm", + description="Confirms the phone registration", status_code=status.HTTP_204_NO_CONTENT, ) -async def register_my_phone_confirm(_body: MyPhoneConfirm): ... +async def my_phone_confirm(_body: MyPhoneConfirm): ... @router.patch( diff --git a/packages/models-library/src/models_library/api_schemas_webserver/users.py b/packages/models-library/src/models_library/api_schemas_webserver/users.py index 6d1be6f1875..e4d18851621 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/users.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/users.py @@ -218,7 +218,6 @@ class MyPhoneRegister(InputSchema): StringConstraints(strip_whitespace=True, min_length=1), Field(description="Phone number to register"), ] - force: bool = False class MyPhoneConfirm(InputSchema): @@ -409,3 +408,4 @@ def from_domain_model(cls, permission: UserPermission) -> Self: class MyFunctionPermissionsGet(OutputSchema): write_functions: bool + write_functions: bool diff --git a/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py b/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py index 3ef0bea798c..342063d845e 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py +++ b/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py @@ -594,7 +594,7 @@ async def test_get_profile_with_failing_db_connection( @pytest.mark.parametrize("user_role", [UserRole.USER]) -async def test_get_and_update_phone_in_profile( +async def test_phone_registration_basic_workflow( user_role: UserRole, logged_user: UserInfoDict, client: TestClient, @@ -612,7 +612,7 @@ async def test_get_and_update_phone_in_profile( # REGISTER phone number new_phone = faker.phone_number() - url = client.app.router["register_my_phone_init"].url_for() + url = client.app.router["my_phone_register"].url_for() resp = await client.post( f"{url}", json={ @@ -622,7 +622,7 @@ async def test_get_and_update_phone_in_profile( await assert_status(resp, status.HTTP_202_ACCEPTED) # CONFIRM phone registration - url = client.app.router["register_my_phone_confirm"].url_for() + url = client.app.router["my_phone_confirm"].url_for() resp = await client.post( f"{url}", json={ @@ -648,18 +648,178 @@ async def test_get_and_update_phone_in_profile( assert updated_profile.login == initial_profile.login assert updated_profile.user_name == initial_profile.user_name - # REGISTER phone to None (clear it) using force=True + +@pytest.mark.parametrize("user_role", [UserRole.USER]) +async def test_phone_registration_workflow( + user_role: UserRole, + logged_user: UserInfoDict, + client: TestClient, + faker: Faker, +): + assert client.app + + # GET initial profile + url = client.app.router["get_my_profile"].url_for() + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + + initial_profile = MyProfileRestGet.model_validate(data) + initial_phone = initial_profile.phone + + # STEP 1: REGISTER phone number + new_phone = faker.phone_number() + url = client.app.router["my_phone_register"].url_for() + resp = await client.post( + f"{url}", + json={ + "phone": new_phone, + }, + ) + await assert_status(resp, status.HTTP_202_ACCEPTED) + + # STEP 2: CONFIRM phone registration + url = client.app.router["my_phone_confirm"].url_for() + resp = await client.post( + f"{url}", + json={ + "code": "123456", + }, + ) + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # GET updated profile + 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 = MyProfileRestGet.model_validate(data) + + # Verify phone was updated + assert updated_profile.phone == new_phone + assert updated_profile.phone != initial_phone + + # Verify other fields remained unchanged + assert updated_profile.first_name == initial_profile.first_name + assert updated_profile.last_name == initial_profile.last_name + assert updated_profile.login == initial_profile.login + assert updated_profile.user_name == initial_profile.user_name + + +@pytest.mark.parametrize("user_role", [UserRole.USER]) +async def test_phone_registration_with_resend( + user_role: UserRole, + logged_user: UserInfoDict, + client: TestClient, + faker: Faker, +): + assert client.app + + # STEP 1: REGISTER phone number + new_phone = faker.phone_number() + url = client.app.router["my_phone_register"].url_for() + resp = await client.post( + f"{url}", + json={ + "phone": new_phone, + }, + ) + await assert_status(resp, status.HTTP_202_ACCEPTED) + + # STEP 2: RESEND code (optional step) + url = client.app.router["my_phone_resend"].url_for() + resp = await client.post(f"{url}") + await assert_status(resp, status.HTTP_202_ACCEPTED) + + # STEP 3: CONFIRM phone registration + url = client.app.router["my_phone_confirm"].url_for() + resp = await client.post( + f"{url}", + json={ + "code": "123456", + }, + ) + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # GET updated profile + 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 = MyProfileRestGet.model_validate(data) + + # Verify phone was updated + assert updated_profile.phone == new_phone + + +@pytest.mark.parametrize("user_role", [UserRole.USER]) +async def test_phone_registration_change_existing_phone( + user_role: UserRole, + logged_user: UserInfoDict, + client: TestClient, + faker: Faker, +): + assert client.app + + # Set initial phone + first_phone = faker.phone_number() + url = client.app.router["my_phone_register"].url_for() + resp = await client.post( + f"{url}", + json={ + "phone": first_phone, + }, + ) + await assert_status(resp, status.HTTP_202_ACCEPTED) + + url = client.app.router["my_phone_confirm"].url_for() + resp = await client.post( + f"{url}", + json={ + "code": "123456", + }, + ) + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # Change to new phone + new_phone = faker.phone_number() + url = client.app.router["my_phone_register"].url_for() + resp = await client.post( + f"{url}", + json={ + "phone": new_phone, + }, + ) + await assert_status(resp, status.HTTP_202_ACCEPTED) + + url = client.app.router["my_phone_confirm"].url_for() + resp = await client.post( + f"{url}", + json={ + "code": "123456", + }, + ) + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # GET updated profile + 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 = MyProfileRestGet.model_validate(data) + + # Verify phone was updated to new phone + assert updated_profile.phone == new_phone + assert updated_profile.phone != first_phone + new_phone = faker.phone_number() url = client.app.router["register_my_phone_init"].url_for() resp = await client.post( f"{url}", json={ - "phone": "", - "force": True, + "phone": new_phone, }, ) await assert_status(resp, status.HTTP_202_ACCEPTED) - # CONFIRM phone clearing url = client.app.router["register_my_phone_confirm"].url_for() resp = await client.post( f"{url}", @@ -669,10 +829,13 @@ async def test_get_and_update_phone_in_profile( ) await assert_status(resp, status.HTTP_204_NO_CONTENT) - # GET profile after clearing phone + # GET updated profile url = client.app.router["get_my_profile"].url_for() resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) - cleared_profile = MyProfileRestGet.model_validate(data) - assert cleared_profile.phone is None + updated_profile = MyProfileRestGet.model_validate(data) + + # Verify phone was updated to new phone + assert updated_profile.phone == new_phone + assert updated_profile.phone != first_phone From 3e8cac0486d596ca3cab936d52e8142e345ea7cb Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 15 Jul 2025 18:32:33 +0200 Subject: [PATCH 15/30] add phone registration endpoints with placeholder implementations --- .../users/_controller/rest/users_rest.py | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py b/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py index 8ea604fd746..c97b330cd74 100644 --- a/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py @@ -3,6 +3,8 @@ from aiohttp import web from models_library.api_schemas_webserver.users import ( + MyPhoneConfirm, + MyPhoneRegister, MyProfileRestGet, MyProfileRestPatch, UserGet, @@ -12,6 +14,9 @@ from servicelib.aiohttp.requests_validation import ( parse_request_body_as, ) +from simcore_service_webserver.application_settings_utils import ( + requires_dev_feature_enabled, +) from ...._meta import API_VTAG from ....groups import api as groups_service @@ -85,6 +90,52 @@ async def update_my_profile(request: web.Request) -> web.Response: return web.json_response(status=status.HTTP_204_NO_CONTENT) +# +# PHONE REGISTRATION: /me/phone:* +# + + +@routes.post(f"/{API_VTAG}/me/phone:register", name="my_phone_register") +@login_required +@permission_required("user.profile.update") +@requires_dev_feature_enabled +@handle_rest_requests_exceptions +async def my_phone_register(request: web.Request) -> web.Response: + req_ctx = UsersRequestContext.model_validate(request) + phone_register = await parse_request_body_as(MyPhoneRegister, request) + + # NOTE: Implementation will be added in next PR + msg = "Phone registration not yet implemented" + raise NotImplementedError(msg) + + +@routes.post(f"/{API_VTAG}/me/phone:resend", name="my_phone_resend") +@login_required +@permission_required("user.profile.update") +@requires_dev_feature_enabled +@handle_rest_requests_exceptions +async def my_phone_resend(request: web.Request) -> web.Response: + req_ctx = UsersRequestContext.model_validate(request) + + # NOTE: Implementation will be added in next PR + msg = "Phone code resend not yet implemented" + raise NotImplementedError(msg) + + +@routes.post(f"/{API_VTAG}/me/phone:confirm", name="my_phone_confirm") +@login_required +@permission_required("user.profile.update") +@requires_dev_feature_enabled +@handle_rest_requests_exceptions +async def my_phone_confirm(request: web.Request) -> web.Response: + req_ctx = UsersRequestContext.model_validate(request) + phone_confirm = await parse_request_body_as(MyPhoneConfirm, request) + + # NOTE: Implementation will be added in next PR + msg = "Phone confirmation not yet implemented" + raise NotImplementedError(msg) + + # # USERS (public) # From f68a0ff24ef9474dc986f3391517bcf193eaa76a Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 15 Jul 2025 18:32:49 +0200 Subject: [PATCH 16/30] =?UTF-8?q?services/webserver=20api=20version:=200.7?= =?UTF-8?q?1.0=20=E2=86=92=200.72.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 e6c73154d93..7375dee5f49 100644 --- a/services/web/server/VERSION +++ b/services/web/server/VERSION @@ -1 +1 @@ -0.71.0 +0.72.0 diff --git a/services/web/server/setup.cfg b/services/web/server/setup.cfg index 05cf7cceee2..48ba7dcb0aa 100644 --- a/services/web/server/setup.cfg +++ b/services/web/server/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.71.0 +current_version = 0.72.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 30131162154..3f83c525d32 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.71.0 + version: 0.72.0 servers: - url: '' description: webserver From f5c1043ef38d519d83f4957495b178b1e7a29ea4 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 15 Jul 2025 18:36:24 +0200 Subject: [PATCH 17/30] rname --- api/specs/web-server/_users.py | 6 +- .../api/v0/openapi.yaml | 168 +++++++++++++----- .../users/_controller/rest/users_rest.py | 12 +- .../with_dbs/03/test_users_rest_profiles.py | 24 +-- 4 files changed, 141 insertions(+), 69 deletions(-) diff --git a/api/specs/web-server/_users.py b/api/specs/web-server/_users.py index a1a20febdda..f1e13b57bd6 100644 --- a/api/specs/web-server/_users.py +++ b/api/specs/web-server/_users.py @@ -55,7 +55,7 @@ async def update_my_profile(_body: MyProfileRestPatch): ... description="Starts the phone registration process", status_code=status.HTTP_202_ACCEPTED, ) -async def my_phone_register(_body: MyPhoneRegister): ... +async def my_profile_phone_register(_body: MyPhoneRegister): ... @router.post( @@ -63,7 +63,7 @@ async def my_phone_register(_body: MyPhoneRegister): ... description="Resends the phone registration code", status_code=status.HTTP_202_ACCEPTED, ) -async def my_phone_resend(): ... +async def my_profile_phone_resend(): ... @router.post( @@ -71,7 +71,7 @@ async def my_phone_resend(): ... description="Confirms the phone registration", status_code=status.HTTP_204_NO_CONTENT, ) -async def my_phone_confirm(_body: MyPhoneConfirm): ... +async def my_profile_phone_confirm(_body: MyPhoneConfirm): ... @router.patch( 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 3f83c525d32..811ed0b7886 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 @@ -1187,7 +1187,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Envelope_MyProfileGet_' + $ref: '#/components/schemas/Envelope_MyProfileRestGet_' patch: tags: - users @@ -1197,7 +1197,55 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/MyProfilePatch' + $ref: '#/components/schemas/MyProfileRestPatch' + required: true + responses: + '204': + description: Successful Response + /v0/me/phone:register: + post: + tags: + - users + summary: My Phone Register + description: Starts the phone registration process + operationId: my_profile_phone_register + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/MyPhoneRegister' + required: true + responses: + '202': + description: Successful Response + content: + application/json: + schema: {} + /v0/me/phone:resend: + post: + tags: + - users + summary: My Phone Resend + description: Resends the phone registration code + operationId: my_profile_phone_resend + responses: + '202': + description: Successful Response + content: + application/json: + schema: {} + /v0/me/phone:confirm: + post: + tags: + - users + summary: My Phone Confirm + description: Confirms the phone registration + operationId: my_profile_phone_confirm + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/MyPhoneConfirm' required: true responses: '204': @@ -10471,11 +10519,11 @@ components: title: Error type: object title: Envelope[MyGroupsGet] - Envelope_MyProfileGet_: + Envelope_MyProfileRestGet_: properties: data: anyOf: - - $ref: '#/components/schemas/MyProfileGet' + - $ref: '#/components/schemas/MyProfileRestGet' - type: 'null' error: anyOf: @@ -10483,7 +10531,7 @@ components: - type: 'null' title: Error type: object - title: Envelope[MyProfileGet] + title: Envelope[MyProfileRestGet] Envelope_MyTokenGet_: properties: data: @@ -12964,7 +13012,65 @@ components: - name - allowed title: MyPermissionGet - MyProfileGet: + MyPhoneConfirm: + properties: + code: + type: string + pattern: ^[A-Za-z0-9]+$ + title: Code + description: Alphanumeric confirmation code + type: object + required: + - code + title: MyPhoneConfirm + MyPhoneRegister: + properties: + phone: + type: string + minLength: 1 + title: Phone + description: Phone number to register + type: object + required: + - phone + title: MyPhoneRegister + MyProfilePrivacyGet: + properties: + hideUsername: + type: boolean + title: Hideusername + hideFullname: + type: boolean + title: Hidefullname + hideEmail: + type: boolean + title: Hideemail + type: object + required: + - hideUsername + - hideFullname + - hideEmail + title: MyProfilePrivacyGet + MyProfilePrivacyPatch: + properties: + hideUsername: + anyOf: + - type: boolean + - type: 'null' + title: Hideusername + hideFullname: + anyOf: + - type: boolean + - type: 'null' + title: Hidefullname + hideEmail: + anyOf: + - type: boolean + - type: 'null' + title: Hideemail + type: object + title: MyProfilePrivacyPatch + MyProfileRestGet: properties: id: type: integer @@ -12993,6 +13099,11 @@ components: type: string format: email title: Login + phone: + anyOf: + - type: string + - type: 'null' + title: Phone role: type: string enum: @@ -13036,8 +13147,8 @@ components: - role - privacy - preferences - title: MyProfileGet - MyProfilePatch: + title: MyProfileRestGet + MyProfileRestPatch: properties: first_name: anyOf: @@ -13063,46 +13174,7 @@ components: - $ref: '#/components/schemas/MyProfilePrivacyPatch' - type: 'null' type: object - title: MyProfilePatch - example: - first_name: Pedro - last_name: Crespo - MyProfilePrivacyGet: - properties: - hideUsername: - type: boolean - title: Hideusername - hideFullname: - type: boolean - title: Hidefullname - hideEmail: - type: boolean - title: Hideemail - type: object - required: - - hideUsername - - hideFullname - - hideEmail - title: MyProfilePrivacyGet - MyProfilePrivacyPatch: - properties: - hideUsername: - anyOf: - - type: boolean - - type: 'null' - title: Hideusername - hideFullname: - anyOf: - - type: boolean - - type: 'null' - title: Hidefullname - hideEmail: - anyOf: - - type: boolean - - type: 'null' - title: Hideemail - type: object - title: MyProfilePrivacyPatch + title: MyProfileRestPatch MyTokenCreate: properties: service: diff --git a/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py b/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py index c97b330cd74..ba6c64088f6 100644 --- a/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py @@ -95,12 +95,12 @@ async def update_my_profile(request: web.Request) -> web.Response: # -@routes.post(f"/{API_VTAG}/me/phone:register", name="my_phone_register") +@routes.post(f"/{API_VTAG}/me/phone:register", name="my_profile_phone_register") @login_required @permission_required("user.profile.update") @requires_dev_feature_enabled @handle_rest_requests_exceptions -async def my_phone_register(request: web.Request) -> web.Response: +async def my_profile_phone_register(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) phone_register = await parse_request_body_as(MyPhoneRegister, request) @@ -109,12 +109,12 @@ async def my_phone_register(request: web.Request) -> web.Response: raise NotImplementedError(msg) -@routes.post(f"/{API_VTAG}/me/phone:resend", name="my_phone_resend") +@routes.post(f"/{API_VTAG}/me/phone:resend", name="my_profile_phone_resend") @login_required @permission_required("user.profile.update") @requires_dev_feature_enabled @handle_rest_requests_exceptions -async def my_phone_resend(request: web.Request) -> web.Response: +async def my_profile_phone_resend(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) # NOTE: Implementation will be added in next PR @@ -122,12 +122,12 @@ async def my_phone_resend(request: web.Request) -> web.Response: raise NotImplementedError(msg) -@routes.post(f"/{API_VTAG}/me/phone:confirm", name="my_phone_confirm") +@routes.post(f"/{API_VTAG}/me/phone:confirm", name="my_profile_phone_confirm") @login_required @permission_required("user.profile.update") @requires_dev_feature_enabled @handle_rest_requests_exceptions -async def my_phone_confirm(request: web.Request) -> web.Response: +async def my_profile_phone_confirm(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) phone_confirm = await parse_request_body_as(MyPhoneConfirm, request) diff --git a/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py b/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py index 342063d845e..567d4296612 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py +++ b/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py @@ -612,7 +612,7 @@ async def test_phone_registration_basic_workflow( # REGISTER phone number new_phone = faker.phone_number() - url = client.app.router["my_phone_register"].url_for() + url = client.app.router["my_profile_phone_register"].url_for() resp = await client.post( f"{url}", json={ @@ -622,7 +622,7 @@ async def test_phone_registration_basic_workflow( await assert_status(resp, status.HTTP_202_ACCEPTED) # CONFIRM phone registration - url = client.app.router["my_phone_confirm"].url_for() + url = client.app.router["my_profile_phone_confirm"].url_for() resp = await client.post( f"{url}", json={ @@ -668,7 +668,7 @@ async def test_phone_registration_workflow( # STEP 1: REGISTER phone number new_phone = faker.phone_number() - url = client.app.router["my_phone_register"].url_for() + url = client.app.router["my_profile_phone_register"].url_for() resp = await client.post( f"{url}", json={ @@ -678,7 +678,7 @@ async def test_phone_registration_workflow( await assert_status(resp, status.HTTP_202_ACCEPTED) # STEP 2: CONFIRM phone registration - url = client.app.router["my_phone_confirm"].url_for() + url = client.app.router["my_profile_phone_confirm"].url_for() resp = await client.post( f"{url}", json={ @@ -716,7 +716,7 @@ async def test_phone_registration_with_resend( # STEP 1: REGISTER phone number new_phone = faker.phone_number() - url = client.app.router["my_phone_register"].url_for() + url = client.app.router["my_profile_phone_register"].url_for() resp = await client.post( f"{url}", json={ @@ -726,12 +726,12 @@ async def test_phone_registration_with_resend( await assert_status(resp, status.HTTP_202_ACCEPTED) # STEP 2: RESEND code (optional step) - url = client.app.router["my_phone_resend"].url_for() + url = client.app.router["my_profile_phone_resend"].url_for() resp = await client.post(f"{url}") await assert_status(resp, status.HTTP_202_ACCEPTED) # STEP 3: CONFIRM phone registration - url = client.app.router["my_phone_confirm"].url_for() + url = client.app.router["my_profile_phone_confirm"].url_for() resp = await client.post( f"{url}", json={ @@ -762,7 +762,7 @@ async def test_phone_registration_change_existing_phone( # Set initial phone first_phone = faker.phone_number() - url = client.app.router["my_phone_register"].url_for() + url = client.app.router["my_profile_phone_register"].url_for() resp = await client.post( f"{url}", json={ @@ -771,7 +771,7 @@ async def test_phone_registration_change_existing_phone( ) await assert_status(resp, status.HTTP_202_ACCEPTED) - url = client.app.router["my_phone_confirm"].url_for() + url = client.app.router["my_profile_phone_confirm"].url_for() resp = await client.post( f"{url}", json={ @@ -782,7 +782,7 @@ async def test_phone_registration_change_existing_phone( # Change to new phone new_phone = faker.phone_number() - url = client.app.router["my_phone_register"].url_for() + url = client.app.router["my_profile_phone_register"].url_for() resp = await client.post( f"{url}", json={ @@ -791,7 +791,7 @@ async def test_phone_registration_change_existing_phone( ) await assert_status(resp, status.HTTP_202_ACCEPTED) - url = client.app.router["my_phone_confirm"].url_for() + url = client.app.router["my_profile_phone_confirm"].url_for() resp = await client.post( f"{url}", json={ @@ -820,7 +820,7 @@ async def test_phone_registration_change_existing_phone( ) await assert_status(resp, status.HTTP_202_ACCEPTED) - url = client.app.router["register_my_phone_confirm"].url_for() + url = client.app.router["register_my_profile_phone_confirm"].url_for() resp = await client.post( f"{url}", json={ From 8d79213c51dfc7ac7b0758fbe8bfe81e3fd5f2a5 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 15 Jul 2025 18:39:39 +0200 Subject: [PATCH 18/30] rename --- api/specs/web-server/_users.py | 6 ++--- .../api/v0/openapi.yaml | 6 ++--- .../users/_controller/rest/users_rest.py | 12 +++++----- .../with_dbs/03/test_users_rest_profiles.py | 24 +++++++++---------- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/api/specs/web-server/_users.py b/api/specs/web-server/_users.py index f1e13b57bd6..a1a20febdda 100644 --- a/api/specs/web-server/_users.py +++ b/api/specs/web-server/_users.py @@ -55,7 +55,7 @@ async def update_my_profile(_body: MyProfileRestPatch): ... description="Starts the phone registration process", status_code=status.HTTP_202_ACCEPTED, ) -async def my_profile_phone_register(_body: MyPhoneRegister): ... +async def my_phone_register(_body: MyPhoneRegister): ... @router.post( @@ -63,7 +63,7 @@ async def my_profile_phone_register(_body: MyPhoneRegister): ... description="Resends the phone registration code", status_code=status.HTTP_202_ACCEPTED, ) -async def my_profile_phone_resend(): ... +async def my_phone_resend(): ... @router.post( @@ -71,7 +71,7 @@ async def my_profile_phone_resend(): ... description="Confirms the phone registration", status_code=status.HTTP_204_NO_CONTENT, ) -async def my_profile_phone_confirm(_body: MyPhoneConfirm): ... +async def my_phone_confirm(_body: MyPhoneConfirm): ... @router.patch( 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 811ed0b7886..4f1859c4b68 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 @@ -1208,7 +1208,7 @@ paths: - users summary: My Phone Register description: Starts the phone registration process - operationId: my_profile_phone_register + operationId: my_phone_register requestBody: content: application/json: @@ -1227,7 +1227,7 @@ paths: - users summary: My Phone Resend description: Resends the phone registration code - operationId: my_profile_phone_resend + operationId: my_phone_resend responses: '202': description: Successful Response @@ -1240,7 +1240,7 @@ paths: - users summary: My Phone Confirm description: Confirms the phone registration - operationId: my_profile_phone_confirm + operationId: my_phone_confirm requestBody: content: application/json: diff --git a/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py b/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py index ba6c64088f6..c97b330cd74 100644 --- a/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py @@ -95,12 +95,12 @@ async def update_my_profile(request: web.Request) -> web.Response: # -@routes.post(f"/{API_VTAG}/me/phone:register", name="my_profile_phone_register") +@routes.post(f"/{API_VTAG}/me/phone:register", name="my_phone_register") @login_required @permission_required("user.profile.update") @requires_dev_feature_enabled @handle_rest_requests_exceptions -async def my_profile_phone_register(request: web.Request) -> web.Response: +async def my_phone_register(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) phone_register = await parse_request_body_as(MyPhoneRegister, request) @@ -109,12 +109,12 @@ async def my_profile_phone_register(request: web.Request) -> web.Response: raise NotImplementedError(msg) -@routes.post(f"/{API_VTAG}/me/phone:resend", name="my_profile_phone_resend") +@routes.post(f"/{API_VTAG}/me/phone:resend", name="my_phone_resend") @login_required @permission_required("user.profile.update") @requires_dev_feature_enabled @handle_rest_requests_exceptions -async def my_profile_phone_resend(request: web.Request) -> web.Response: +async def my_phone_resend(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) # NOTE: Implementation will be added in next PR @@ -122,12 +122,12 @@ async def my_profile_phone_resend(request: web.Request) -> web.Response: raise NotImplementedError(msg) -@routes.post(f"/{API_VTAG}/me/phone:confirm", name="my_profile_phone_confirm") +@routes.post(f"/{API_VTAG}/me/phone:confirm", name="my_phone_confirm") @login_required @permission_required("user.profile.update") @requires_dev_feature_enabled @handle_rest_requests_exceptions -async def my_profile_phone_confirm(request: web.Request) -> web.Response: +async def my_phone_confirm(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) phone_confirm = await parse_request_body_as(MyPhoneConfirm, request) diff --git a/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py b/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py index 567d4296612..342063d845e 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py +++ b/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py @@ -612,7 +612,7 @@ async def test_phone_registration_basic_workflow( # REGISTER phone number new_phone = faker.phone_number() - url = client.app.router["my_profile_phone_register"].url_for() + url = client.app.router["my_phone_register"].url_for() resp = await client.post( f"{url}", json={ @@ -622,7 +622,7 @@ async def test_phone_registration_basic_workflow( await assert_status(resp, status.HTTP_202_ACCEPTED) # CONFIRM phone registration - url = client.app.router["my_profile_phone_confirm"].url_for() + url = client.app.router["my_phone_confirm"].url_for() resp = await client.post( f"{url}", json={ @@ -668,7 +668,7 @@ async def test_phone_registration_workflow( # STEP 1: REGISTER phone number new_phone = faker.phone_number() - url = client.app.router["my_profile_phone_register"].url_for() + url = client.app.router["my_phone_register"].url_for() resp = await client.post( f"{url}", json={ @@ -678,7 +678,7 @@ async def test_phone_registration_workflow( await assert_status(resp, status.HTTP_202_ACCEPTED) # STEP 2: CONFIRM phone registration - url = client.app.router["my_profile_phone_confirm"].url_for() + url = client.app.router["my_phone_confirm"].url_for() resp = await client.post( f"{url}", json={ @@ -716,7 +716,7 @@ async def test_phone_registration_with_resend( # STEP 1: REGISTER phone number new_phone = faker.phone_number() - url = client.app.router["my_profile_phone_register"].url_for() + url = client.app.router["my_phone_register"].url_for() resp = await client.post( f"{url}", json={ @@ -726,12 +726,12 @@ async def test_phone_registration_with_resend( await assert_status(resp, status.HTTP_202_ACCEPTED) # STEP 2: RESEND code (optional step) - url = client.app.router["my_profile_phone_resend"].url_for() + url = client.app.router["my_phone_resend"].url_for() resp = await client.post(f"{url}") await assert_status(resp, status.HTTP_202_ACCEPTED) # STEP 3: CONFIRM phone registration - url = client.app.router["my_profile_phone_confirm"].url_for() + url = client.app.router["my_phone_confirm"].url_for() resp = await client.post( f"{url}", json={ @@ -762,7 +762,7 @@ async def test_phone_registration_change_existing_phone( # Set initial phone first_phone = faker.phone_number() - url = client.app.router["my_profile_phone_register"].url_for() + url = client.app.router["my_phone_register"].url_for() resp = await client.post( f"{url}", json={ @@ -771,7 +771,7 @@ async def test_phone_registration_change_existing_phone( ) await assert_status(resp, status.HTTP_202_ACCEPTED) - url = client.app.router["my_profile_phone_confirm"].url_for() + url = client.app.router["my_phone_confirm"].url_for() resp = await client.post( f"{url}", json={ @@ -782,7 +782,7 @@ async def test_phone_registration_change_existing_phone( # Change to new phone new_phone = faker.phone_number() - url = client.app.router["my_profile_phone_register"].url_for() + url = client.app.router["my_phone_register"].url_for() resp = await client.post( f"{url}", json={ @@ -791,7 +791,7 @@ async def test_phone_registration_change_existing_phone( ) await assert_status(resp, status.HTTP_202_ACCEPTED) - url = client.app.router["my_profile_phone_confirm"].url_for() + url = client.app.router["my_phone_confirm"].url_for() resp = await client.post( f"{url}", json={ @@ -820,7 +820,7 @@ async def test_phone_registration_change_existing_phone( ) await assert_status(resp, status.HTTP_202_ACCEPTED) - url = client.app.router["register_my_profile_phone_confirm"].url_for() + url = client.app.router["register_my_phone_confirm"].url_for() resp = await client.post( f"{url}", json={ From 570dfcb45c3944a96c6445fe321a12e72f3d13a3 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 15 Jul 2025 18:50:54 +0200 Subject: [PATCH 19/30] draft implementation --- .../users/_controller/rest/users_rest.py | 82 +++++++++++++++++-- .../users/_users_service.py | 20 +++++ 2 files changed, 93 insertions(+), 9 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py b/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py index c97b330cd74..ad64a819507 100644 --- a/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py @@ -2,6 +2,7 @@ from contextlib import suppress from aiohttp import web +from common_library.user_messages import user_message from models_library.api_schemas_webserver.users import ( MyPhoneConfirm, MyPhoneRegister, @@ -25,6 +26,7 @@ from ....products import products_web from ....products.models import Product from ....security.decorators import permission_required +from ....session.api import get_session from ....utils_aiohttp import envelope_json_response from ... import _users_service from ._rest_exceptions import handle_rest_requests_exceptions @@ -32,6 +34,11 @@ _logger = logging.getLogger(__name__) +# Phone registration session keys +_PHONE_REGISTRATION_KEY = "phone_registration" +_PHONE_PENDING_KEY = "phone_pending" +_PHONE_CODE_KEY = "phone_code" +_PHONE_CODE_VALUE_FAKE = "123456" routes = web.RouteTableDef() @@ -104,9 +111,21 @@ async def my_phone_register(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) phone_register = await parse_request_body_as(MyPhoneRegister, request) - # NOTE: Implementation will be added in next PR - msg = "Phone registration not yet implemented" - raise NotImplementedError(msg) + session = await get_session(request) + + # Store phone registration state in session + session[_PHONE_REGISTRATION_KEY] = { + "user_id": req_ctx.user_id, + "phone": phone_register.phone, + "status": "pending_confirmation", + } + + # NOTE: In real implementation, generate and send SMS code here + # For testing, we'll use a fixed code + session[_PHONE_CODE_KEY] = _PHONE_CODE_VALUE_FAKE + session[_PHONE_PENDING_KEY] = True + + return web.json_response(status=status.HTTP_202_ACCEPTED) @routes.post(f"/{API_VTAG}/me/phone:resend", name="my_phone_resend") @@ -116,10 +135,25 @@ async def my_phone_register(request: web.Request) -> web.Response: @handle_rest_requests_exceptions async def my_phone_resend(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) + session = await get_session(request) + + # Check if there's a pending phone registration + if not session.get(_PHONE_PENDING_KEY): + raise web.HTTPBadRequest( + text=user_message("No pending phone registration found"), + ) + + phone_registration = session.get(_PHONE_REGISTRATION_KEY) + if not phone_registration or phone_registration["user_id"] != req_ctx.user_id: + raise web.HTTPBadRequest( + text=user_message("Invalid phone registration session") + ) - # NOTE: Implementation will be added in next PR - msg = "Phone code resend not yet implemented" - raise NotImplementedError(msg) + # NOTE: In real implementation, regenerate and resend SMS code here + # For testing, we'll use the same fixed code + session[_PHONE_CODE_KEY] = _PHONE_CODE_VALUE_FAKE + + return web.json_response(status=status.HTTP_202_ACCEPTED) @routes.post(f"/{API_VTAG}/me/phone:confirm", name="my_phone_confirm") @@ -130,10 +164,40 @@ async def my_phone_resend(request: web.Request) -> web.Response: async def my_phone_confirm(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) phone_confirm = await parse_request_body_as(MyPhoneConfirm, request) + session = await get_session(request) + + # Check if there's a pending phone registration + if not session.get(_PHONE_PENDING_KEY): + raise web.HTTPBadRequest( + text=user_message("No pending phone registration found"), + ) + + phone_registration = session.get(_PHONE_REGISTRATION_KEY) + if not phone_registration or phone_registration["user_id"] != req_ctx.user_id: + raise web.HTTPBadRequest( + text=user_message("Invalid phone registration session"), + ) + + # Verify the confirmation code + expected_code = session.get(_PHONE_CODE_KEY) + if not expected_code or phone_confirm.code != expected_code: + raise web.HTTPBadRequest( + text=user_message("Invalid confirmation code"), + ) + + # Update user's phone number in the database + await _users_service.update_user_phone( + request.app, + user_id=req_ctx.user_id, + phone=phone_registration["phone"], + ) - # NOTE: Implementation will be added in next PR - msg = "Phone confirmation not yet implemented" - raise NotImplementedError(msg) + # Clear session data + session.pop(_PHONE_REGISTRATION_KEY, None) + session.pop(_PHONE_PENDING_KEY, None) + session.pop(_PHONE_CODE_KEY, None) + + return web.json_response(status=status.HTTP_204_NO_CONTENT) # diff --git a/services/web/server/src/simcore_service_webserver/users/_users_service.py b/services/web/server/src/simcore_service_webserver/users/_users_service.py index 76d479ad5ec..fa4a7f28f5b 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_service.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_service.py @@ -285,3 +285,23 @@ async def update_my_profile( user_id=user_id, updated_values=UserModelAdapter.from_rest_schema_model(update).to_db_values(), ) + + +async def update_user_phone( + app: web.Application, + *, + user_id: UserID, + phone: str, +) -> None: + """Update user's phone number after successful verification + + Args: + app: Web application instance + user_id: ID of the user whose phone to update + phone: Verified phone number to set + """ + await _users_repository.update_user_profile( + app, + user_id=user_id, + updated_values={"phone": phone}, + ) From e692c8b86f015a25ba2b54f30926c712ccf645d2 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 15 Jul 2025 19:04:45 +0200 Subject: [PATCH 20/30] cleanup --- .../users/_controller/rest/users_rest.py | 4 +- .../with_dbs/03/test_users_rest_profiles.py | 43 ++++--------------- 2 files changed, 12 insertions(+), 35 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py b/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py index ad64a819507..fda2415589b 100644 --- a/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py @@ -38,7 +38,9 @@ _PHONE_REGISTRATION_KEY = "phone_registration" _PHONE_PENDING_KEY = "phone_pending" _PHONE_CODE_KEY = "phone_code" -_PHONE_CODE_VALUE_FAKE = "123456" +_PHONE_CODE_VALUE_FAKE = ( + "123456" # NOTE: temporary fake while developing phone registration feature +) routes = web.RouteTableDef() diff --git a/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py b/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py index 342063d845e..4d60eb81eac 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py +++ b/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py @@ -36,6 +36,9 @@ from simcore_service_webserver.user_preferences._service import ( get_frontend_user_preferences_aggregation, ) +from simcore_service_webserver.users._controller.rest.users_rest import ( + _PHONE_CODE_VALUE_FAKE, +) from sqlalchemy.exc import OperationalError as SQLAlchemyOperationalError from sqlalchemy.ext.asyncio import AsyncConnection @@ -50,6 +53,7 @@ def app_environment( { "WEBSERVER_GARBAGE_COLLECTOR": "null", "WEBSERVER_DB_LISTENER": "0", + "WEBSERVER_DEV_FEATURES_ENABLED": "1", # NOTE: still under development }, ) @@ -626,7 +630,7 @@ async def test_phone_registration_basic_workflow( resp = await client.post( f"{url}", json={ - "code": "123456", + "code": _PHONE_CODE_VALUE_FAKE, }, ) await assert_status(resp, status.HTTP_204_NO_CONTENT) @@ -682,7 +686,7 @@ async def test_phone_registration_workflow( resp = await client.post( f"{url}", json={ - "code": "123456", + "code": _PHONE_CODE_VALUE_FAKE, }, ) await assert_status(resp, status.HTTP_204_NO_CONTENT) @@ -735,7 +739,7 @@ async def test_phone_registration_with_resend( resp = await client.post( f"{url}", json={ - "code": "123456", + "code": _PHONE_CODE_VALUE_FAKE, }, ) await assert_status(resp, status.HTTP_204_NO_CONTENT) @@ -775,7 +779,7 @@ async def test_phone_registration_change_existing_phone( resp = await client.post( f"{url}", json={ - "code": "123456", + "code": _PHONE_CODE_VALUE_FAKE, }, ) await assert_status(resp, status.HTTP_204_NO_CONTENT) @@ -795,36 +799,7 @@ async def test_phone_registration_change_existing_phone( resp = await client.post( f"{url}", json={ - "code": "123456", - }, - ) - await assert_status(resp, status.HTTP_204_NO_CONTENT) - - # GET updated profile - 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 = MyProfileRestGet.model_validate(data) - - # Verify phone was updated to new phone - assert updated_profile.phone == new_phone - assert updated_profile.phone != first_phone - new_phone = faker.phone_number() - url = client.app.router["register_my_phone_init"].url_for() - resp = await client.post( - f"{url}", - json={ - "phone": new_phone, - }, - ) - await assert_status(resp, status.HTTP_202_ACCEPTED) - - url = client.app.router["register_my_phone_confirm"].url_for() - resp = await client.post( - f"{url}", - json={ - "code": "123456", + "code": _PHONE_CODE_VALUE_FAKE, }, ) await assert_status(resp, status.HTTP_204_NO_CONTENT) From 3e0f6ecc9329baf1633b0fd6c97b67a15b1dca28 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 15 Jul 2025 19:10:56 +0200 Subject: [PATCH 21/30] cleanup oas --- api/specs/web-server/_users.py | 27 ++ .../with_dbs/03/test_users_rest_profiles.py | 234 ++++++++++++++++++ 2 files changed, 261 insertions(+) diff --git a/api/specs/web-server/_users.py b/api/specs/web-server/_users.py index a1a20febdda..892045413ac 100644 --- a/api/specs/web-server/_users.py +++ b/api/specs/web-server/_users.py @@ -54,6 +54,14 @@ async def update_my_profile(_body: MyProfileRestPatch): ... "/me/phone:register", description="Starts the phone registration process", status_code=status.HTTP_202_ACCEPTED, + responses={ + status.HTTP_202_ACCEPTED: {"description": "Phone registration initiated"}, + status.HTTP_401_UNAUTHORIZED: {"description": "Authentication required"}, + status.HTTP_403_FORBIDDEN: {"description": "Insufficient permissions"}, + status.HTTP_422_UNPROCESSABLE_ENTITY: { + "description": "Invalid phone number format" + }, + }, ) async def my_phone_register(_body: MyPhoneRegister): ... @@ -62,6 +70,14 @@ async def my_phone_register(_body: MyPhoneRegister): ... "/me/phone:resend", description="Resends the phone registration code", status_code=status.HTTP_202_ACCEPTED, + responses={ + status.HTTP_202_ACCEPTED: {"description": "Phone code resent"}, + status.HTTP_400_BAD_REQUEST: { + "description": "No pending phone registration found" + }, + status.HTTP_401_UNAUTHORIZED: {"description": "Authentication required"}, + status.HTTP_403_FORBIDDEN: {"description": "Insufficient permissions"}, + }, ) async def my_phone_resend(): ... @@ -70,6 +86,17 @@ async def my_phone_resend(): ... "/me/phone:confirm", description="Confirms the phone registration", status_code=status.HTTP_204_NO_CONTENT, + responses={ + status.HTTP_204_NO_CONTENT: {"description": "Phone registration confirmed"}, + status.HTTP_400_BAD_REQUEST: { + "description": "No pending registration or invalid code" + }, + status.HTTP_401_UNAUTHORIZED: {"description": "Authentication required"}, + status.HTTP_403_FORBIDDEN: {"description": "Insufficient permissions"}, + status.HTTP_422_UNPROCESSABLE_ENTITY: { + "description": "Invalid confirmation code format" + }, + }, ) async def my_phone_confirm(_body: MyPhoneConfirm): ... diff --git a/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py b/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py index 4d60eb81eac..af2a425bc87 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py +++ b/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py @@ -814,3 +814,237 @@ async def test_phone_registration_change_existing_phone( # Verify phone was updated to new phone assert updated_profile.phone == new_phone assert updated_profile.phone != first_phone + + +# +# PHONE REGISTRATION FAILURE TESTS +# + + +@pytest.mark.parametrize("user_role", [UserRole.USER]) +async def test_phone_resend_without_pending_registration( + user_role: UserRole, + logged_user: UserInfoDict, + client: TestClient, +): + assert client.app + + # Try to resend code without any pending registration + url = client.app.router["my_phone_resend"].url_for() + resp = await client.post(f"{url}") + await assert_status(resp, status.HTTP_400_BAD_REQUEST) + + +@pytest.mark.parametrize("user_role", [UserRole.USER]) +async def test_phone_confirm_without_pending_registration( + user_role: UserRole, + logged_user: UserInfoDict, + client: TestClient, +): + assert client.app + + # Try to confirm code without any pending registration + url = client.app.router["my_phone_confirm"].url_for() + resp = await client.post( + f"{url}", + json={ + "code": _PHONE_CODE_VALUE_FAKE, + }, + ) + await assert_status(resp, status.HTTP_400_BAD_REQUEST) + + +@pytest.mark.parametrize("user_role", [UserRole.USER]) +async def test_phone_confirm_with_wrong_code( + user_role: UserRole, + logged_user: UserInfoDict, + client: TestClient, + faker: Faker, +): + assert client.app + + # STEP 1: REGISTER phone number + new_phone = faker.phone_number() + url = client.app.router["my_phone_register"].url_for() + resp = await client.post( + f"{url}", + json={ + "phone": new_phone, + }, + ) + await assert_status(resp, status.HTTP_202_ACCEPTED) + + # STEP 2: Try to confirm with wrong code + url = client.app.router["my_phone_confirm"].url_for() + resp = await client.post( + f"{url}", + json={ + "code": "wrong_code", + }, + ) + await assert_status(resp, status.HTTP_400_BAD_REQUEST) + + +@pytest.mark.parametrize("user_role", [UserRole.USER]) +async def test_phone_confirm_with_invalid_code_format( + user_role: UserRole, + logged_user: UserInfoDict, + client: TestClient, + faker: Faker, +): + assert client.app + + # STEP 1: REGISTER phone number + new_phone = faker.phone_number() + url = client.app.router["my_phone_register"].url_for() + resp = await client.post( + f"{url}", + json={ + "phone": new_phone, + }, + ) + await assert_status(resp, status.HTTP_202_ACCEPTED) + + # STEP 2: Try to confirm with invalid code format (contains special characters) + url = client.app.router["my_phone_confirm"].url_for() + resp = await client.post( + f"{url}", + json={ + "code": "123-456", # Invalid format according to pattern + }, + ) + await assert_status(resp, status.HTTP_422_UNPROCESSABLE_ENTITY) + + +@pytest.mark.parametrize("user_role", [UserRole.USER]) +async def test_phone_register_with_empty_phone( + user_role: UserRole, + logged_user: UserInfoDict, + client: TestClient, +): + assert client.app + + # Try to register with empty phone number + url = client.app.router["my_phone_register"].url_for() + resp = await client.post( + f"{url}", + json={ + "phone": "", # Empty phone number + }, + ) + await assert_status(resp, status.HTTP_422_UNPROCESSABLE_ENTITY) + + # Try to register with whitespace-only phone number + url = client.app.router["my_phone_register"].url_for() + resp = await client.post( + f"{url}", + json={ + "phone": " ", # Whitespace only + }, + ) + await assert_status(resp, status.HTTP_422_UNPROCESSABLE_ENTITY) + + +@pytest.mark.parametrize("user_role", [UserRole.USER]) +async def test_phone_confirm_with_empty_code( + user_role: UserRole, + logged_user: UserInfoDict, + client: TestClient, + faker: Faker, +): + assert client.app + + # STEP 1: REGISTER phone number + new_phone = faker.phone_number() + url = client.app.router["my_phone_register"].url_for() + resp = await client.post( + f"{url}", + json={ + "phone": new_phone, + }, + ) + await assert_status(resp, status.HTTP_202_ACCEPTED) + + # STEP 2: Try to confirm with empty code + url = client.app.router["my_phone_confirm"].url_for() + resp = await client.post( + f"{url}", + json={ + "code": "", # Empty code + }, + ) + await assert_status(resp, status.HTTP_422_UNPROCESSABLE_ENTITY) + + +@pytest.mark.parametrize( + "user_role,expected", + [ + (UserRole.ANONYMOUS, status.HTTP_401_UNAUTHORIZED), + (UserRole.GUEST, status.HTTP_403_FORBIDDEN), + ], +) +async def test_phone_register_access_rights( + user_role: UserRole, + logged_user: UserInfoDict, + client: TestClient, + expected: HTTPStatus, + faker: Faker, +): + assert client.app + + # Try to register phone with insufficient permissions + url = client.app.router["my_phone_register"].url_for() + resp = await client.post( + f"{url}", + json={ + "phone": faker.phone_number(), + }, + ) + await assert_status(resp, expected) + + +@pytest.mark.parametrize( + "user_role,expected", + [ + (UserRole.ANONYMOUS, status.HTTP_401_UNAUTHORIZED), + (UserRole.GUEST, status.HTTP_403_FORBIDDEN), + ], +) +async def test_phone_resend_access_rights( + user_role: UserRole, + logged_user: UserInfoDict, + client: TestClient, + expected: HTTPStatus, +): + assert client.app + + # Try to resend code with insufficient permissions + url = client.app.router["my_phone_resend"].url_for() + resp = await client.post(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), + ], +) +async def test_phone_confirm_access_rights( + user_role: UserRole, + logged_user: UserInfoDict, + client: TestClient, + expected: HTTPStatus, +): + assert client.app + + # Try to confirm code with insufficient permissions + url = client.app.router["my_phone_confirm"].url_for() + resp = await client.post( + f"{url}", + json={ + "code": _PHONE_CODE_VALUE_FAKE, + }, + ) + await assert_status(resp, expected) From 978259b81bf96987fe3dfea67218e7ef5392d088 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 15 Jul 2025 19:11:21 +0200 Subject: [PATCH 22/30] udpates oas --- .../api/v0/openapi.yaml | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 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 4f1859c4b68..1c6a9a2e03f 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 @@ -1217,10 +1217,16 @@ paths: required: true responses: '202': - description: Successful Response + description: Phone registration initiated content: application/json: schema: {} + '401': + description: Authentication required + '403': + description: Insufficient permissions + '422': + description: Invalid phone number format /v0/me/phone:resend: post: tags: @@ -1230,10 +1236,16 @@ paths: operationId: my_phone_resend responses: '202': - description: Successful Response + description: Phone code resent content: application/json: schema: {} + '400': + description: No pending phone registration found + '401': + description: Authentication required + '403': + description: Insufficient permissions /v0/me/phone:confirm: post: tags: @@ -1249,7 +1261,15 @@ paths: required: true responses: '204': - description: Successful Response + description: Phone registration confirmed + '400': + description: No pending registration or invalid code + '401': + description: Authentication required + '403': + description: Insufficient permissions + '422': + description: Invalid confirmation code format /v0/me/preferences/{preference_id}: patch: tags: From 908ad8d6b9d130ae2e473485c0670d8c3b29973b Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 15 Jul 2025 19:28:47 +0200 Subject: [PATCH 23/30] remove --- services/web/server/requirements/_base.txt | 7 ------- 1 file changed, 7 deletions(-) diff --git a/services/web/server/requirements/_base.txt b/services/web/server/requirements/_base.txt index 0626bcb944f..e0c48caa415 100644 --- a/services/web/server/requirements/_base.txt +++ b/services/web/server/requirements/_base.txt @@ -584,13 +584,6 @@ pamqp==3.2.1 # via aiormq passlib==1.7.4 # via -r requirements/_base.in -phonenumbers==9.0.9 - # via - # -r requirements/../../../../packages/models-library/requirements/_base.in - # -r requirements/../../../../packages/notifications-library/requirements/../../../packages/models-library/requirements/_base.in - # -r requirements/../../../../packages/service-library/requirements/../../../packages/models-library/requirements/_base.in - # -r requirements/../../../../packages/simcore-sdk/requirements/../../../packages/models-library/requirements/_base.in - # -r requirements/../../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/../../../packages/models-library/requirements/_base.in pillow==10.3.0 # via captcha pint==0.24.3 From 166e9d3f8fefae61380530f5b1362d99859c20e0 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 15 Jul 2025 20:17:12 +0200 Subject: [PATCH 24/30] add phone registration error handling and exceptions --- .../_controller/rest/_rest_exceptions.py | 24 ++++++++++ .../users/_controller/rest/users_rest.py | 48 +++++++++++++------ .../users/exceptions.py | 12 +++++ 3 files changed, 69 insertions(+), 15 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/users/_controller/rest/_rest_exceptions.py b/services/web/server/src/simcore_service_webserver/users/_controller/rest/_rest_exceptions.py index 89b7a94cd57..cd1a104243a 100644 --- a/services/web/server/src/simcore_service_webserver/users/_controller/rest/_rest_exceptions.py +++ b/services/web/server/src/simcore_service_webserver/users/_controller/rest/_rest_exceptions.py @@ -11,6 +11,9 @@ AlreadyPreRegisteredError, MissingGroupExtraPropertiesForProductError, PendingPreRegistrationNotFoundError, + PhoneRegistrationCodeInvalidError, + PhoneRegistrationPendingNotFoundError, + PhoneRegistrationSessionInvalidError, UserNameDuplicateError, UserNotFoundError, ) @@ -54,6 +57,27 @@ _version=1, ), ), + PhoneRegistrationPendingNotFoundError: HttpErrorInfo( + status.HTTP_400_BAD_REQUEST, + user_message( + "No pending phone registration found", + _version=1, + ), + ), + PhoneRegistrationSessionInvalidError: HttpErrorInfo( + status.HTTP_400_BAD_REQUEST, + user_message( + "Invalid phone registration session", + _version=1, + ), + ), + PhoneRegistrationCodeInvalidError: HttpErrorInfo( + status.HTTP_400_BAD_REQUEST, + user_message( + "Invalid confirmation code", + _version=1, + ), + ), } handle_rest_requests_exceptions = exception_handling_decorator( diff --git a/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py b/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py index fda2415589b..668cc1587fe 100644 --- a/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py @@ -1,8 +1,8 @@ import logging from contextlib import suppress +from typing import Literal, TypedDict from aiohttp import web -from common_library.user_messages import user_message from models_library.api_schemas_webserver.users import ( MyPhoneConfirm, MyPhoneRegister, @@ -11,6 +11,7 @@ UserGet, UsersSearch, ) +from models_library.users import UserID from servicelib.aiohttp import status from servicelib.aiohttp.requests_validation import ( parse_request_body_as, @@ -29,6 +30,11 @@ from ....session.api import get_session from ....utils_aiohttp import envelope_json_response from ... import _users_service +from ...exceptions import ( + PhoneRegistrationCodeInvalidError, + PhoneRegistrationPendingNotFoundError, + PhoneRegistrationSessionInvalidError, +) from ._rest_exceptions import handle_rest_requests_exceptions from ._rest_schemas import UsersRequestContext @@ -42,6 +48,15 @@ "123456" # NOTE: temporary fake while developing phone registration feature ) + +class PhoneRegistrationData(TypedDict): + """Phone registration session data structure.""" + + user_id: UserID + phone: str + status: Literal["pending_confirmation"] + + routes = web.RouteTableDef() # @@ -116,11 +131,12 @@ async def my_phone_register(request: web.Request) -> web.Response: session = await get_session(request) # Store phone registration state in session - session[_PHONE_REGISTRATION_KEY] = { + phone_data: PhoneRegistrationData = { "user_id": req_ctx.user_id, "phone": phone_register.phone, "status": "pending_confirmation", } + session[_PHONE_REGISTRATION_KEY] = phone_data # NOTE: In real implementation, generate and send SMS code here # For testing, we'll use a fixed code @@ -141,14 +157,14 @@ async def my_phone_resend(request: web.Request) -> web.Response: # Check if there's a pending phone registration if not session.get(_PHONE_PENDING_KEY): - raise web.HTTPBadRequest( - text=user_message("No pending phone registration found"), - ) + raise PhoneRegistrationPendingNotFoundError() - phone_registration = session.get(_PHONE_REGISTRATION_KEY) + phone_registration: PhoneRegistrationData | None = session.get( + _PHONE_REGISTRATION_KEY + ) if not phone_registration or phone_registration["user_id"] != req_ctx.user_id: - raise web.HTTPBadRequest( - text=user_message("Invalid phone registration session") + raise PhoneRegistrationSessionInvalidError( + user_id=req_ctx.user_id, product_name=req_ctx.product_name ) # NOTE: In real implementation, regenerate and resend SMS code here @@ -170,21 +186,23 @@ async def my_phone_confirm(request: web.Request) -> web.Response: # Check if there's a pending phone registration if not session.get(_PHONE_PENDING_KEY): - raise web.HTTPBadRequest( - text=user_message("No pending phone registration found"), + raise PhoneRegistrationPendingNotFoundError( + user_id=req_ctx.user_id, product_name=req_ctx.product_name ) - phone_registration = session.get(_PHONE_REGISTRATION_KEY) + phone_registration: PhoneRegistrationData | None = session.get( + _PHONE_REGISTRATION_KEY + ) if not phone_registration or phone_registration["user_id"] != req_ctx.user_id: - raise web.HTTPBadRequest( - text=user_message("Invalid phone registration session"), + raise PhoneRegistrationSessionInvalidError( + user_id=req_ctx.user_id, product_name=req_ctx.product_name ) # Verify the confirmation code expected_code = session.get(_PHONE_CODE_KEY) if not expected_code or phone_confirm.code != expected_code: - raise web.HTTPBadRequest( - text=user_message("Invalid confirmation code"), + raise PhoneRegistrationCodeInvalidError( + user_id=req_ctx.user_id, product_name=req_ctx.product_name ) # Update user's phone number in the database 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 b1533222195..a71472b8374 100644 --- a/services/web/server/src/simcore_service_webserver/users/exceptions.py +++ b/services/web/server/src/simcore_service_webserver/users/exceptions.py @@ -75,3 +75,15 @@ def __init__(self, *, email: str, product_name: str, **ctx: Any): super().__init__(**ctx) self.email = email self.product_name = product_name + + +class PhoneRegistrationPendingNotFoundError(UsersBaseError): + msg_template = "No pending phone registration found" + + +class PhoneRegistrationSessionInvalidError(UsersBaseError): + msg_template = "Invalid phone registration session" + + +class PhoneRegistrationCodeInvalidError(UsersBaseError): + msg_template = "Invalid confirmation code" From a148164ab80e43682f2085aff047cdcd2ce20cb0 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 15 Jul 2025 20:24:55 +0200 Subject: [PATCH 25/30] cleaup --- .../_controller/rest/_rest_exceptions.py | 6 +- .../users/_controller/rest/users_rest.py | 73 +++++++++++-------- 2 files changed, 46 insertions(+), 33 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/users/_controller/rest/_rest_exceptions.py b/services/web/server/src/simcore_service_webserver/users/_controller/rest/_rest_exceptions.py index cd1a104243a..3fc12fca855 100644 --- a/services/web/server/src/simcore_service_webserver/users/_controller/rest/_rest_exceptions.py +++ b/services/web/server/src/simcore_service_webserver/users/_controller/rest/_rest_exceptions.py @@ -60,21 +60,21 @@ PhoneRegistrationPendingNotFoundError: HttpErrorInfo( status.HTTP_400_BAD_REQUEST, user_message( - "No pending phone registration found", + "No pending phone registration found. Please start the phone registration process first.", _version=1, ), ), PhoneRegistrationSessionInvalidError: HttpErrorInfo( status.HTTP_400_BAD_REQUEST, user_message( - "Invalid phone registration session", + "Your phone registration session is invalid or has expired. Please start the phone registration process again.", _version=1, ), ), PhoneRegistrationCodeInvalidError: HttpErrorInfo( status.HTTP_400_BAD_REQUEST, user_message( - "Invalid confirmation code", + "The confirmation code you entered is incorrect. Please check and try again.", _version=1, ), ), diff --git a/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py b/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py index 668cc1587fe..3d083b03890 100644 --- a/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py @@ -3,6 +3,7 @@ from typing import Literal, TypedDict from aiohttp import web +from aiohttp_session import Session from models_library.api_schemas_webserver.users import ( MyPhoneConfirm, MyPhoneRegister, @@ -146,6 +147,37 @@ async def my_phone_register(request: web.Request) -> web.Response: return web.json_response(status=status.HTTP_202_ACCEPTED) +def _validate_pending_phone_registration( + session: Session, user_id: UserID, product_name: str +) -> PhoneRegistrationData: + # Check if there's a pending phone registration + if not session.get(_PHONE_PENDING_KEY): + raise PhoneRegistrationPendingNotFoundError( + user_id=user_id, product_name=product_name + ) + + # Validate session belongs to current user + phone_registration: PhoneRegistrationData | None = session.get( + _PHONE_REGISTRATION_KEY + ) + if not phone_registration or phone_registration["user_id"] != user_id: + raise PhoneRegistrationSessionInvalidError( + user_id=user_id, product_name=product_name + ) + + return phone_registration + + +def _validate_confirmation_code( + session: Session, provided_code: str, *, user_id: UserID, product_name: str +) -> None: + expected_code = session.get(_PHONE_CODE_KEY) + if not expected_code or provided_code != expected_code: + raise PhoneRegistrationCodeInvalidError( + user_id=user_id, product_name=product_name + ) + + @routes.post(f"/{API_VTAG}/me/phone:resend", name="my_phone_resend") @login_required @permission_required("user.profile.update") @@ -155,17 +187,7 @@ async def my_phone_resend(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) session = await get_session(request) - # Check if there's a pending phone registration - if not session.get(_PHONE_PENDING_KEY): - raise PhoneRegistrationPendingNotFoundError() - - phone_registration: PhoneRegistrationData | None = session.get( - _PHONE_REGISTRATION_KEY - ) - if not phone_registration or phone_registration["user_id"] != req_ctx.user_id: - raise PhoneRegistrationSessionInvalidError( - user_id=req_ctx.user_id, product_name=req_ctx.product_name - ) + _validate_pending_phone_registration(session, req_ctx.user_id, req_ctx.product_name) # NOTE: In real implementation, regenerate and resend SMS code here # For testing, we'll use the same fixed code @@ -184,26 +206,16 @@ async def my_phone_confirm(request: web.Request) -> web.Response: phone_confirm = await parse_request_body_as(MyPhoneConfirm, request) session = await get_session(request) - # Check if there's a pending phone registration - if not session.get(_PHONE_PENDING_KEY): - raise PhoneRegistrationPendingNotFoundError( - user_id=req_ctx.user_id, product_name=req_ctx.product_name - ) - - phone_registration: PhoneRegistrationData | None = session.get( - _PHONE_REGISTRATION_KEY + phone_registration = _validate_pending_phone_registration( + session, req_ctx.user_id, req_ctx.product_name ) - if not phone_registration or phone_registration["user_id"] != req_ctx.user_id: - raise PhoneRegistrationSessionInvalidError( - user_id=req_ctx.user_id, product_name=req_ctx.product_name - ) - # Verify the confirmation code - expected_code = session.get(_PHONE_CODE_KEY) - if not expected_code or phone_confirm.code != expected_code: - raise PhoneRegistrationCodeInvalidError( - user_id=req_ctx.user_id, product_name=req_ctx.product_name - ) + _validate_confirmation_code( + session, + phone_confirm.code, + user_id=req_ctx.user_id, + product_name=req_ctx.product_name, + ) # Update user's phone number in the database await _users_service.update_user_phone( @@ -212,7 +224,7 @@ async def my_phone_confirm(request: web.Request) -> web.Response: phone=phone_registration["phone"], ) - # Clear session data + # Clear phone registration session data session.pop(_PHONE_REGISTRATION_KEY, None) session.pop(_PHONE_PENDING_KEY, None) session.pop(_PHONE_CODE_KEY, None) @@ -244,3 +256,4 @@ async def search_users(request: web.Request) -> web.Response: ) return envelope_json_response([UserGet.from_domain_model(user) for user in found]) + return envelope_json_response([UserGet.from_domain_model(user) for user in found]) From 18ce938ab8806f33ec0b0baba5ce601c019504f9 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 15 Jul 2025 20:30:18 +0200 Subject: [PATCH 26/30] manager --- .../users/_controller/rest/users_rest.py | 127 +++++++++--------- 1 file changed, 63 insertions(+), 64 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py b/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py index 3d083b03890..be13f493204 100644 --- a/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py @@ -58,6 +58,55 @@ class PhoneRegistrationData(TypedDict): status: Literal["pending_confirmation"] +class PhoneRegistrationSessionManager: + def __init__(self, session: Session, user_id: UserID, product_name: str): + self._session = session + self._user_id = user_id + self._product_name = product_name + + def start_registration(self, phone: str) -> None: + phone_data: PhoneRegistrationData = { + "user_id": self._user_id, + "phone": phone, + "status": "pending_confirmation", + } + self._session[_PHONE_REGISTRATION_KEY] = phone_data + self._session[_PHONE_CODE_KEY] = _PHONE_CODE_VALUE_FAKE + self._session[_PHONE_PENDING_KEY] = True + + def validate_pending_registration(self) -> PhoneRegistrationData: + if not self._session.get(_PHONE_PENDING_KEY): + raise PhoneRegistrationPendingNotFoundError( + user_id=self._user_id, product_name=self._product_name + ) + + phone_registration: PhoneRegistrationData | None = self._session.get( + _PHONE_REGISTRATION_KEY + ) + if not phone_registration or phone_registration["user_id"] != self._user_id: + raise PhoneRegistrationSessionInvalidError( + user_id=self._user_id, product_name=self._product_name + ) + + return phone_registration + + def regenerate_code(self) -> None: + self.validate_pending_registration() + self._session[_PHONE_CODE_KEY] = _PHONE_CODE_VALUE_FAKE + + def validate_confirmation_code(self, provided_code: str) -> None: + expected_code = self._session.get(_PHONE_CODE_KEY) + if not expected_code or provided_code != expected_code: + raise PhoneRegistrationCodeInvalidError( + user_id=self._user_id, product_name=self._product_name + ) + + def clear_session(self) -> None: + self._session.pop(_PHONE_REGISTRATION_KEY, None) + self._session.pop(_PHONE_PENDING_KEY, None) + self._session.pop(_PHONE_CODE_KEY, None) + + routes = web.RouteTableDef() # @@ -130,52 +179,12 @@ async def my_phone_register(request: web.Request) -> web.Response: phone_register = await parse_request_body_as(MyPhoneRegister, request) session = await get_session(request) - - # Store phone registration state in session - phone_data: PhoneRegistrationData = { - "user_id": req_ctx.user_id, - "phone": phone_register.phone, - "status": "pending_confirmation", - } - session[_PHONE_REGISTRATION_KEY] = phone_data - - # NOTE: In real implementation, generate and send SMS code here - # For testing, we'll use a fixed code - session[_PHONE_CODE_KEY] = _PHONE_CODE_VALUE_FAKE - session[_PHONE_PENDING_KEY] = True - - return web.json_response(status=status.HTTP_202_ACCEPTED) - - -def _validate_pending_phone_registration( - session: Session, user_id: UserID, product_name: str -) -> PhoneRegistrationData: - # Check if there's a pending phone registration - if not session.get(_PHONE_PENDING_KEY): - raise PhoneRegistrationPendingNotFoundError( - user_id=user_id, product_name=product_name - ) - - # Validate session belongs to current user - phone_registration: PhoneRegistrationData | None = session.get( - _PHONE_REGISTRATION_KEY + phone_session_manager = PhoneRegistrationSessionManager( + session, req_ctx.user_id, req_ctx.product_name ) - if not phone_registration or phone_registration["user_id"] != user_id: - raise PhoneRegistrationSessionInvalidError( - user_id=user_id, product_name=product_name - ) - - return phone_registration - + phone_session_manager.start_registration(phone_register.phone) -def _validate_confirmation_code( - session: Session, provided_code: str, *, user_id: UserID, product_name: str -) -> None: - expected_code = session.get(_PHONE_CODE_KEY) - if not expected_code or provided_code != expected_code: - raise PhoneRegistrationCodeInvalidError( - user_id=user_id, product_name=product_name - ) + return web.json_response(status=status.HTTP_202_ACCEPTED) @routes.post(f"/{API_VTAG}/me/phone:resend", name="my_phone_resend") @@ -185,13 +194,12 @@ def _validate_confirmation_code( @handle_rest_requests_exceptions async def my_phone_resend(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) - session = await get_session(request) - - _validate_pending_phone_registration(session, req_ctx.user_id, req_ctx.product_name) - # NOTE: In real implementation, regenerate and resend SMS code here - # For testing, we'll use the same fixed code - session[_PHONE_CODE_KEY] = _PHONE_CODE_VALUE_FAKE + session = await get_session(request) + phone_session_manager = PhoneRegistrationSessionManager( + session, req_ctx.user_id, req_ctx.product_name + ) + phone_session_manager.regenerate_code() return web.json_response(status=status.HTTP_202_ACCEPTED) @@ -204,30 +212,22 @@ async def my_phone_resend(request: web.Request) -> web.Response: async def my_phone_confirm(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) phone_confirm = await parse_request_body_as(MyPhoneConfirm, request) - session = await get_session(request) - phone_registration = _validate_pending_phone_registration( + session = await get_session(request) + phone_session_manager = PhoneRegistrationSessionManager( session, req_ctx.user_id, req_ctx.product_name ) - _validate_confirmation_code( - session, - phone_confirm.code, - user_id=req_ctx.user_id, - product_name=req_ctx.product_name, - ) + phone_registration = phone_session_manager.validate_pending_registration() + phone_session_manager.validate_confirmation_code(phone_confirm.code) - # Update user's phone number in the database await _users_service.update_user_phone( request.app, user_id=req_ctx.user_id, phone=phone_registration["phone"], ) - # Clear phone registration session data - session.pop(_PHONE_REGISTRATION_KEY, None) - session.pop(_PHONE_PENDING_KEY, None) - session.pop(_PHONE_CODE_KEY, None) + phone_session_manager.clear_session() return web.json_response(status=status.HTTP_204_NO_CONTENT) @@ -256,4 +256,3 @@ async def search_users(request: web.Request) -> web.Response: ) return envelope_json_response([UserGet.from_domain_model(user) for user in found]) - return envelope_json_response([UserGet.from_domain_model(user) for user in found]) From 491341642008ecc2ef969662007211e88b3a426b Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 15 Jul 2025 20:35:52 +0200 Subject: [PATCH 27/30] rename --- .../users/_controller/rest/users_rest.py | 76 +++++++++---------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py b/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py index be13f493204..d4ef4735df1 100644 --- a/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py @@ -41,70 +41,68 @@ _logger = logging.getLogger(__name__) -# Phone registration session keys -_PHONE_REGISTRATION_KEY = "phone_registration" -_PHONE_PENDING_KEY = "phone_pending" -_PHONE_CODE_KEY = "phone_code" -_PHONE_CODE_VALUE_FAKE = ( - "123456" # NOTE: temporary fake while developing phone registration feature +# Registration session keys +_REGISTRATION_KEY = "registration" +_REGISTRATION_PENDING_KEY = "registration_pending" +_REGISTRATION_CODE_KEY = "registration_code" +_REGISTRATION_CODE_VALUE_FAKE = ( + "123456" # NOTE: temporary fake while developing registration feature ) -class PhoneRegistrationData(TypedDict): - """Phone registration session data structure.""" +class RegistrationData(TypedDict): + """Registration session data structure.""" user_id: UserID - phone: str + data: str status: Literal["pending_confirmation"] -class PhoneRegistrationSessionManager: +class RegistrationSessionManager: def __init__(self, session: Session, user_id: UserID, product_name: str): self._session = session self._user_id = user_id self._product_name = product_name - def start_registration(self, phone: str) -> None: - phone_data: PhoneRegistrationData = { + def start_registration(self, data: str, code: str) -> None: + registration_data: RegistrationData = { "user_id": self._user_id, - "phone": phone, + "data": data, "status": "pending_confirmation", } - self._session[_PHONE_REGISTRATION_KEY] = phone_data - self._session[_PHONE_CODE_KEY] = _PHONE_CODE_VALUE_FAKE - self._session[_PHONE_PENDING_KEY] = True + self._session[_REGISTRATION_KEY] = registration_data + self._session[_REGISTRATION_CODE_KEY] = code + self._session[_REGISTRATION_PENDING_KEY] = True - def validate_pending_registration(self) -> PhoneRegistrationData: - if not self._session.get(_PHONE_PENDING_KEY): + def validate_pending_registration(self) -> RegistrationData: + if not self._session.get(_REGISTRATION_PENDING_KEY): raise PhoneRegistrationPendingNotFoundError( user_id=self._user_id, product_name=self._product_name ) - phone_registration: PhoneRegistrationData | None = self._session.get( - _PHONE_REGISTRATION_KEY - ) - if not phone_registration or phone_registration["user_id"] != self._user_id: + registration: RegistrationData | None = self._session.get(_REGISTRATION_KEY) + if not registration or registration["user_id"] != self._user_id: raise PhoneRegistrationSessionInvalidError( user_id=self._user_id, product_name=self._product_name ) - return phone_registration + return registration - def regenerate_code(self) -> None: + def regenerate_code(self, new_code: str) -> None: self.validate_pending_registration() - self._session[_PHONE_CODE_KEY] = _PHONE_CODE_VALUE_FAKE + self._session[_REGISTRATION_CODE_KEY] = new_code def validate_confirmation_code(self, provided_code: str) -> None: - expected_code = self._session.get(_PHONE_CODE_KEY) + expected_code = self._session.get(_REGISTRATION_CODE_KEY) if not expected_code or provided_code != expected_code: raise PhoneRegistrationCodeInvalidError( user_id=self._user_id, product_name=self._product_name ) def clear_session(self) -> None: - self._session.pop(_PHONE_REGISTRATION_KEY, None) - self._session.pop(_PHONE_PENDING_KEY, None) - self._session.pop(_PHONE_CODE_KEY, None) + self._session.pop(_REGISTRATION_KEY, None) + self._session.pop(_REGISTRATION_PENDING_KEY, None) + self._session.pop(_REGISTRATION_CODE_KEY, None) routes = web.RouteTableDef() @@ -179,10 +177,12 @@ async def my_phone_register(request: web.Request) -> web.Response: phone_register = await parse_request_body_as(MyPhoneRegister, request) session = await get_session(request) - phone_session_manager = PhoneRegistrationSessionManager( + registration_session_manager = RegistrationSessionManager( session, req_ctx.user_id, req_ctx.product_name ) - phone_session_manager.start_registration(phone_register.phone) + registration_session_manager.start_registration( + phone_register.phone, code=_REGISTRATION_CODE_VALUE_FAKE + ) return web.json_response(status=status.HTTP_202_ACCEPTED) @@ -196,10 +196,10 @@ async def my_phone_resend(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) session = await get_session(request) - phone_session_manager = PhoneRegistrationSessionManager( + registration_session_manager = RegistrationSessionManager( session, req_ctx.user_id, req_ctx.product_name ) - phone_session_manager.regenerate_code() + registration_session_manager.regenerate_code(new_code=_REGISTRATION_CODE_VALUE_FAKE) return web.json_response(status=status.HTTP_202_ACCEPTED) @@ -214,20 +214,20 @@ async def my_phone_confirm(request: web.Request) -> web.Response: phone_confirm = await parse_request_body_as(MyPhoneConfirm, request) session = await get_session(request) - phone_session_manager = PhoneRegistrationSessionManager( + registration_session_manager = RegistrationSessionManager( session, req_ctx.user_id, req_ctx.product_name ) - phone_registration = phone_session_manager.validate_pending_registration() - phone_session_manager.validate_confirmation_code(phone_confirm.code) + registration = registration_session_manager.validate_pending_registration() + registration_session_manager.validate_confirmation_code(phone_confirm.code) await _users_service.update_user_phone( request.app, user_id=req_ctx.user_id, - phone=phone_registration["phone"], + phone=registration["data"], ) - phone_session_manager.clear_session() + registration_session_manager.clear_session() return web.json_response(status=status.HTTP_204_NO_CONTENT) From 9b2ee379c60d8633b3c133ac06552db92d2d242c Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 15 Jul 2025 20:37:47 +0200 Subject: [PATCH 28/30] fix: retain data in registration session during start_registration --- .../users/_controller/rest/users_rest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py b/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py index d4ef4735df1..5eb8220b17d 100644 --- a/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py @@ -67,7 +67,7 @@ def __init__(self, session: Session, user_id: UserID, product_name: str): def start_registration(self, data: str, code: str) -> None: registration_data: RegistrationData = { "user_id": self._user_id, - "data": data, + "data": data, # keep data "status": "pending_confirmation", } self._session[_REGISTRATION_KEY] = registration_data From 6764706250ba944bb9eb320134f33f81d5e58a90 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 15 Jul 2025 20:44:22 +0200 Subject: [PATCH 29/30] split --- .../users/_controller/rest/users_rest.py | 68 +---------------- .../users/_users_web.py | 74 +++++++++++++++++++ .../with_dbs/03/test_users_rest_profiles.py | 16 ++-- 3 files changed, 83 insertions(+), 75 deletions(-) create mode 100644 services/web/server/src/simcore_service_webserver/users/_users_web.py diff --git a/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py b/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py index 5eb8220b17d..88a2a03dce9 100644 --- a/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py @@ -1,9 +1,7 @@ import logging from contextlib import suppress -from typing import Literal, TypedDict from aiohttp import web -from aiohttp_session import Session from models_library.api_schemas_webserver.users import ( MyPhoneConfirm, MyPhoneRegister, @@ -12,7 +10,6 @@ UserGet, UsersSearch, ) -from models_library.users import UserID from servicelib.aiohttp import status from servicelib.aiohttp.requests_validation import ( parse_request_body_as, @@ -31,80 +28,17 @@ from ....session.api import get_session from ....utils_aiohttp import envelope_json_response from ... import _users_service -from ...exceptions import ( - PhoneRegistrationCodeInvalidError, - PhoneRegistrationPendingNotFoundError, - PhoneRegistrationSessionInvalidError, -) +from ..._users_web import RegistrationSessionManager from ._rest_exceptions import handle_rest_requests_exceptions from ._rest_schemas import UsersRequestContext _logger = logging.getLogger(__name__) -# Registration session keys -_REGISTRATION_KEY = "registration" -_REGISTRATION_PENDING_KEY = "registration_pending" -_REGISTRATION_CODE_KEY = "registration_code" _REGISTRATION_CODE_VALUE_FAKE = ( "123456" # NOTE: temporary fake while developing registration feature ) -class RegistrationData(TypedDict): - """Registration session data structure.""" - - user_id: UserID - data: str - status: Literal["pending_confirmation"] - - -class RegistrationSessionManager: - def __init__(self, session: Session, user_id: UserID, product_name: str): - self._session = session - self._user_id = user_id - self._product_name = product_name - - def start_registration(self, data: str, code: str) -> None: - registration_data: RegistrationData = { - "user_id": self._user_id, - "data": data, # keep data - "status": "pending_confirmation", - } - self._session[_REGISTRATION_KEY] = registration_data - self._session[_REGISTRATION_CODE_KEY] = code - self._session[_REGISTRATION_PENDING_KEY] = True - - def validate_pending_registration(self) -> RegistrationData: - if not self._session.get(_REGISTRATION_PENDING_KEY): - raise PhoneRegistrationPendingNotFoundError( - user_id=self._user_id, product_name=self._product_name - ) - - registration: RegistrationData | None = self._session.get(_REGISTRATION_KEY) - if not registration or registration["user_id"] != self._user_id: - raise PhoneRegistrationSessionInvalidError( - user_id=self._user_id, product_name=self._product_name - ) - - return registration - - def regenerate_code(self, new_code: str) -> None: - self.validate_pending_registration() - self._session[_REGISTRATION_CODE_KEY] = new_code - - def validate_confirmation_code(self, provided_code: str) -> None: - expected_code = self._session.get(_REGISTRATION_CODE_KEY) - if not expected_code or provided_code != expected_code: - raise PhoneRegistrationCodeInvalidError( - user_id=self._user_id, product_name=self._product_name - ) - - def clear_session(self) -> None: - self._session.pop(_REGISTRATION_KEY, None) - self._session.pop(_REGISTRATION_PENDING_KEY, None) - self._session.pop(_REGISTRATION_CODE_KEY, None) - - routes = web.RouteTableDef() # diff --git a/services/web/server/src/simcore_service_webserver/users/_users_web.py b/services/web/server/src/simcore_service_webserver/users/_users_web.py new file mode 100644 index 00000000000..0aa958432e3 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/users/_users_web.py @@ -0,0 +1,74 @@ +import logging +from typing import Literal, TypedDict + +from aiohttp_session import Session +from models_library.users import UserID +from servicelib.aiohttp import status + +from .exceptions import ( + PhoneRegistrationCodeInvalidError, + PhoneRegistrationPendingNotFoundError, + PhoneRegistrationSessionInvalidError, +) + +_logger = logging.getLogger(__name__) + +# Registration session keys +_REGISTRATION_KEY = "registration" +_REGISTRATION_PENDING_KEY = "registration_pending" +_REGISTRATION_CODE_KEY = "registration_code" + + +class RegistrationData(TypedDict): + """Registration session data structure.""" + + user_id: UserID + data: str + status: Literal["pending_confirmation"] + + +class RegistrationSessionManager: + def __init__(self, session: Session, user_id: UserID, product_name: str): + self._session = session + self._user_id = user_id + self._product_name = product_name + + def start_registration(self, data: str, code: str) -> None: + registration_data: RegistrationData = { + "user_id": self._user_id, + "data": data, # keep data + "status": "pending_confirmation", + } + self._session[_REGISTRATION_KEY] = registration_data + self._session[_REGISTRATION_CODE_KEY] = code + self._session[_REGISTRATION_PENDING_KEY] = True + + def validate_pending_registration(self) -> RegistrationData: + if not self._session.get(_REGISTRATION_PENDING_KEY): + raise PhoneRegistrationPendingNotFoundError( + user_id=self._user_id, product_name=self._product_name + ) + + registration: RegistrationData | None = self._session.get(_REGISTRATION_KEY) + if not registration or registration["user_id"] != self._user_id: + raise PhoneRegistrationSessionInvalidError( + user_id=self._user_id, product_name=self._product_name + ) + + return registration + + def regenerate_code(self, new_code: str) -> None: + self.validate_pending_registration() + self._session[_REGISTRATION_CODE_KEY] = new_code + + def validate_confirmation_code(self, provided_code: str) -> None: + expected_code = self._session.get(_REGISTRATION_CODE_KEY) + if not expected_code or provided_code != expected_code: + raise PhoneRegistrationCodeInvalidError( + user_id=self._user_id, product_name=self._product_name + ) + + def clear_session(self) -> None: + self._session.pop(_REGISTRATION_KEY, None) + self._session.pop(_REGISTRATION_PENDING_KEY, None) + self._session.pop(_REGISTRATION_CODE_KEY, None) diff --git a/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py b/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py index af2a425bc87..b84af357655 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py +++ b/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py @@ -37,7 +37,7 @@ get_frontend_user_preferences_aggregation, ) from simcore_service_webserver.users._controller.rest.users_rest import ( - _PHONE_CODE_VALUE_FAKE, + _REGISTRATION_CODE_VALUE_FAKE, ) from sqlalchemy.exc import OperationalError as SQLAlchemyOperationalError from sqlalchemy.ext.asyncio import AsyncConnection @@ -630,7 +630,7 @@ async def test_phone_registration_basic_workflow( resp = await client.post( f"{url}", json={ - "code": _PHONE_CODE_VALUE_FAKE, + "code": _REGISTRATION_CODE_VALUE_FAKE, }, ) await assert_status(resp, status.HTTP_204_NO_CONTENT) @@ -686,7 +686,7 @@ async def test_phone_registration_workflow( resp = await client.post( f"{url}", json={ - "code": _PHONE_CODE_VALUE_FAKE, + "code": _REGISTRATION_CODE_VALUE_FAKE, }, ) await assert_status(resp, status.HTTP_204_NO_CONTENT) @@ -739,7 +739,7 @@ async def test_phone_registration_with_resend( resp = await client.post( f"{url}", json={ - "code": _PHONE_CODE_VALUE_FAKE, + "code": _REGISTRATION_CODE_VALUE_FAKE, }, ) await assert_status(resp, status.HTTP_204_NO_CONTENT) @@ -779,7 +779,7 @@ async def test_phone_registration_change_existing_phone( resp = await client.post( f"{url}", json={ - "code": _PHONE_CODE_VALUE_FAKE, + "code": _REGISTRATION_CODE_VALUE_FAKE, }, ) await assert_status(resp, status.HTTP_204_NO_CONTENT) @@ -799,7 +799,7 @@ async def test_phone_registration_change_existing_phone( resp = await client.post( f"{url}", json={ - "code": _PHONE_CODE_VALUE_FAKE, + "code": _REGISTRATION_CODE_VALUE_FAKE, }, ) await assert_status(resp, status.HTTP_204_NO_CONTENT) @@ -848,7 +848,7 @@ async def test_phone_confirm_without_pending_registration( resp = await client.post( f"{url}", json={ - "code": _PHONE_CODE_VALUE_FAKE, + "code": _REGISTRATION_CODE_VALUE_FAKE, }, ) await assert_status(resp, status.HTTP_400_BAD_REQUEST) @@ -1044,7 +1044,7 @@ async def test_phone_confirm_access_rights( resp = await client.post( f"{url}", json={ - "code": _PHONE_CODE_VALUE_FAKE, + "code": _REGISTRATION_CODE_VALUE_FAKE, }, ) await assert_status(resp, expected) From c4213efadf9d572485726383a76bc041447be949 Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Tue, 15 Jul 2025 21:40:44 +0200 Subject: [PATCH 30/30] fix: remove duplicate field in MyFunctionPermissionsGet and update test with invalid code --- .../src/models_library/api_schemas_webserver/users.py | 1 - .../server/src/simcore_service_webserver/users/_users_web.py | 1 - .../server/tests/unit/with_dbs/03/test_users_rest_profiles.py | 2 +- 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/models-library/src/models_library/api_schemas_webserver/users.py b/packages/models-library/src/models_library/api_schemas_webserver/users.py index e4d18851621..b81d13ed086 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/users.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/users.py @@ -408,4 +408,3 @@ def from_domain_model(cls, permission: UserPermission) -> Self: class MyFunctionPermissionsGet(OutputSchema): write_functions: bool - write_functions: bool diff --git a/services/web/server/src/simcore_service_webserver/users/_users_web.py b/services/web/server/src/simcore_service_webserver/users/_users_web.py index 0aa958432e3..91df2de62fb 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_web.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_web.py @@ -3,7 +3,6 @@ from aiohttp_session import Session from models_library.users import UserID -from servicelib.aiohttp import status from .exceptions import ( PhoneRegistrationCodeInvalidError, diff --git a/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py b/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py index b84af357655..3123f9f98e1 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py +++ b/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py @@ -879,7 +879,7 @@ async def test_phone_confirm_with_wrong_code( resp = await client.post( f"{url}", json={ - "code": "wrong_code", + "code": "wrongcode1234", }, ) await assert_status(resp, status.HTTP_400_BAD_REQUEST)