Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
84758cb
models
pcrespov Jul 15, 2025
c2a945a
updates tests
pcrespov Jul 15, 2025
ce2fe31
rename
pcrespov Jul 15, 2025
3ca9293
repo
pcrespov Jul 15, 2025
ce00ac3
phonenumber
pcrespov Jul 15, 2025
15c9307
phone validator
pcrespov Jul 15, 2025
31ae420
add phonenumbers dependency and update phone number generation in tests
pcrespov Jul 15, 2025
ab93e95
remove patch phone
pcrespov Jul 15, 2025
e4dd119
renaming
pcrespov Jul 15, 2025
2306f86
cleanup
pcrespov Jul 15, 2025
08131c0
undo phone
pcrespov Jul 15, 2025
eafc4e1
phone registration
pcrespov Jul 15, 2025
3f38795
draft update phone
pcrespov Jul 15, 2025
8eac989
created rest api
pcrespov Jul 15, 2025
3e8cac0
add phone registration endpoints with placeholder implementations
pcrespov Jul 15, 2025
f68a0ff
services/webserver api version: 0.71.0 → 0.72.0
pcrespov Jul 15, 2025
f5c1043
rname
pcrespov Jul 15, 2025
8d79213
rename
pcrespov Jul 15, 2025
570dfcb
draft implementation
pcrespov Jul 15, 2025
e692c8b
cleanup
pcrespov Jul 15, 2025
3e0f6ec
cleanup oas
pcrespov Jul 15, 2025
978259b
udpates oas
pcrespov Jul 15, 2025
908ad8d
remove
pcrespov Jul 15, 2025
166e9d3
add phone registration error handling and exceptions
pcrespov Jul 15, 2025
a148164
cleaup
pcrespov Jul 15, 2025
18ce938
manager
pcrespov Jul 15, 2025
4913416
rename
pcrespov Jul 15, 2025
9b2ee37
fix: retain data in registration session during start_registration
pcrespov Jul 15, 2025
cbea6dd
Merge branch 'master' into is280/update-phone-number
pcrespov Jul 15, 2025
6764706
split
pcrespov Jul 15, 2025
c4213ef
fix: remove duplicate field in MyFunctionPermissionsGet and update te…
pcrespov Jul 15, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 57 additions & 4 deletions api/specs/web-server/_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@
from models_library.api_schemas_webserver.users import (
MyFunctionPermissionsGet,
MyPermissionGet,
MyProfileGet,
MyProfilePatch,
MyPhoneConfirm,
MyPhoneRegister,
MyProfileRestGet,
MyProfileRestPatch,
MyTokenCreate,
MyTokenGet,
TokenPathParams,
Expand All @@ -36,7 +38,7 @@

@router.get(
"/me",
response_model=Envelope[MyProfileGet],
response_model=Envelope[MyProfileRestGet],
)
async def get_my_profile(): ...

Expand All @@ -45,7 +47,58 @@ 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.post(
"/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): ...


@router.post(
"/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(): ...


@router.post(
"/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): ...


@router.patch(
Expand Down
4 changes: 2 additions & 2 deletions packages/models-library/requirements/_base.in
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@
arrow
jsonschema
orjson
pydantic[email]
pydantic-settings
pydantic-extra-types
pydantic-settings
pydantic[email]
Original file line number Diff line number Diff line change
Expand Up @@ -62,14 +62,15 @@ 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")
]
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
Expand Down Expand Up @@ -141,6 +142,7 @@ def from_domain_model(
"last_name",
"email",
"role",
"phone",
"privacy",
"expiration_date",
},
Expand All @@ -155,21 +157,19 @@ 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
# NOTE: phone is updated via a dedicated endpoint!

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"}]})

model_config = ConfigDict(json_schema_extra=_update_json_schema_extra)

@field_validator("user_name")
@classmethod
Expand Down Expand Up @@ -207,6 +207,27 @@ 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"),
]


class MyPhoneConfirm(InputSchema):
code: Annotated[
str,
StringConstraints(strip_whitespace=True, pattern=r"^[A-Za-z0-9]+$"),
Field(description="Alphanumeric confirmation code"),
]


#
# USER
#
Expand Down
2 changes: 2 additions & 0 deletions packages/models-library/src/models_library/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class MyProfile(BaseModel):
email: LowerCaseEmailStr
role: UserRole
privacy: PrivacyDict
phone: str | None
expiration_date: datetime.date | None = None

@staticmethod
Expand All @@ -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,
Expand Down
30 changes: 15 additions & 15 deletions packages/models-library/tests/test_api_schemas_webserver_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,74 +8,74 @@
import pytest
from common_library.users_enums import UserRole
from models_library.api_schemas_webserver.users import (
MyProfileGet,
MyProfilePatch,
MyProfileRestGet,
MyProfileRestPatch,
)
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


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():
# Ensure no consecutive 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
4 changes: 2 additions & 2 deletions packages/models-library/tests/test_users.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
)
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@
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 MyProfileRestGet as WebProfileGet
from models_library.api_schemas_webserver.users import (
MyProfilePatch as WebProfileUpdate,
MyProfileRestPatch as WebProfileUpdate,
)
from models_library.api_schemas_webserver.wallets import WalletGet
from models_library.generics import Envelope
Expand Down
2 changes: 1 addition & 1 deletion services/api-server/tests/unit/_with_db/test_api_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion services/web/server/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.71.1
0.72.0
6 changes: 3 additions & 3 deletions services/web/server/setup.cfg
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[bumpversion]
current_version = 0.71.1
current_version = 0.72.0
commit = True
message = services/webserver api version: {current_version} → {new_version}
tag = False
Expand All @@ -13,13 +13,13 @@ commit_args = --no-verify
addopts = --strict-markers
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function
markers =
markers =
slow: marks tests as slow (deselect with '-m "not slow"')
acceptance_test: "marks tests as 'acceptance tests' i.e. does the system do what the user expects? Typically those are workflows."
testit: "marks test to run during development"
heavy_load: "mark tests that require large amount of data"

[mypy]
plugins =
plugins =
pydantic.mypy
sqlalchemy.ext.mypy.plugin
Loading
Loading