Skip to content

Commit 763be96

Browse files
authored
🎨 Added user identifiers to the UserAccountGetmodel (#8358)
1 parent 3d6e600 commit 763be96

File tree

10 files changed

+114
-27
lines changed

10 files changed

+114
-27
lines changed
Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,36 @@
11
from types import UnionType
2-
from typing import Any, Literal, get_args, get_origin
2+
from typing import Annotated, Any, Literal, Union, get_args, get_origin
33

44
from pydantic.fields import FieldInfo
55

6+
NoneType: type = type(None)
7+
68

79
def get_type(info: FieldInfo) -> Any:
810
field_type = info.annotation
911
if args := get_args(info.annotation):
10-
field_type = next(a for a in args if a is not type(None))
12+
field_type = next(a for a in args if a is not NoneType)
1113
return field_type
1214

1315

16+
def _unwrap_annotation(ann):
17+
"""Peel off Annotated wrappers until reaching the core type."""
18+
while get_origin(ann) is Annotated:
19+
ann = get_args(ann)[0]
20+
return ann
21+
22+
1423
def is_literal(info: FieldInfo) -> bool:
15-
return get_origin(info.annotation) is Literal
24+
ann = _unwrap_annotation(info.annotation)
25+
return get_origin(ann) is Literal
1626

1727

1828
def is_nullable(info: FieldInfo) -> bool:
19-
origin = get_origin(info.annotation) # X | None or Optional[X] will return Union
20-
if origin is UnionType:
21-
return any(x in get_args(info.annotation) for x in (type(None), Any))
22-
return False
29+
"""Checks whether a field allows None as a value."""
30+
ann = _unwrap_annotation(info.annotation)
31+
origin = get_origin(ann) # X | None or Optional[X] will return Union
32+
33+
if origin in (Union, UnionType):
34+
return any(arg is NoneType or arg is Any for arg in get_args(ann))
35+
36+
return ann is NoneType or ann is Any

packages/common-library/tests/test_pydantic_fields_extension.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
from collections.abc import Callable
2-
from typing import Any, Literal
2+
from typing import Annotated, Any, Literal
33

44
import pytest
55
from common_library.pydantic_fields_extension import get_type, is_literal, is_nullable
6-
from pydantic import BaseModel
6+
from pydantic import BaseModel, PositiveInt
77

88

99
class MyModel(BaseModel):
@@ -12,6 +12,11 @@ class MyModel(BaseModel):
1212
c: str = "bla"
1313
d: bool | None = None
1414
e: Literal["bla"]
15+
f: Annotated[
16+
PositiveInt | None,
17+
"nullable inside Annotated (PositiveInt = Annotated[int, ...])",
18+
]
19+
g: Annotated[Literal["foo", "bar"], "literal inside Annotated"]
1520

1621

1722
@pytest.mark.parametrize(
@@ -50,6 +55,8 @@ class MyModel(BaseModel):
5055
),
5156
(is_literal, False, "d"),
5257
(is_literal, True, "e"),
58+
(is_literal, False, "f"),
59+
(is_literal, True, "g"),
5360
(
5461
is_nullable,
5562
False,
@@ -67,6 +74,11 @@ class MyModel(BaseModel):
6774
),
6875
(is_nullable, True, "d"),
6976
(is_nullable, False, "e"),
77+
(
78+
is_nullable,
79+
True,
80+
"f",
81+
),
7082
],
7183
)
7284
def test_field_fn(fn: Callable[[Any], Any], expected: Any, name: str):

packages/models-library/src/models_library/api_schemas_webserver/users.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525
from ..basic_types import IDStr
2626
from ..emails import LowerCaseEmailStr
27-
from ..groups import AccessRightsDict, Group, GroupID, GroupsByTypeTuple
27+
from ..groups import AccessRightsDict, Group, GroupID, GroupsByTypeTuple, PrimaryGroupID
2828
from ..products import ProductName
2929
from ..rest_base import RequestParameters
3030
from ..users import (
@@ -381,14 +381,32 @@ class UserAccountGet(OutputSchema):
381381

382382
# user status
383383
registered: bool
384-
status: UserStatus | None
384+
status: UserStatus | None = None
385385
products: Annotated[
386386
list[ProductName] | None,
387387
Field(
388388
description="List of products this users is included or None if fields is unset",
389389
),
390390
] = None
391391

392+
# user (if an account was created)
393+
user_id: Annotated[
394+
UserID | None,
395+
Field(description="Unique identifier of the user if an account was created"),
396+
] = None
397+
user_name: Annotated[
398+
UserNameID | None,
399+
Field(description="Username of the user if an account was created"),
400+
] = None
401+
user_primary_group_id: Annotated[
402+
PrimaryGroupID | None,
403+
Field(
404+
description="Primary group ID of the user if an account was created",
405+
alias="groupId",
406+
# SEE https://github.com/ITISFoundation/osparc-simcore/pull/8358#issuecomment-3279491740
407+
),
408+
] = None
409+
392410
@field_validator("status")
393411
@classmethod
394412
def _consistency_check(cls, v, info: ValidationInfo):

packages/models-library/src/models_library/groups.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@
1515
EVERYONE_GROUP_ID: Final[int] = 1
1616

1717
GroupID: TypeAlias = PositiveInt
18+
PrimaryGroupID: TypeAlias = Annotated[GroupID, Field(gt=EVERYONE_GROUP_ID)]
19+
StandardGroupID: TypeAlias = Annotated[GroupID, Field(gt=EVERYONE_GROUP_ID)]
1820

1921
__all__: tuple[str, ...] = ("GroupType",)
2022

2123

2224
class Group(BaseModel):
23-
gid: PositiveInt
25+
gid: GroupID
2426
name: str
2527
description: str
2628
group_type: Annotated[GroupType, Field(alias="type")]

services/web/server/VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.78.0
1+
0.79.0

services/web/server/setup.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[bumpversion]
2-
current_version = 0.78.0
2+
current_version = 0.79.0
33
commit = True
44
message = services/webserver api version: {current_version} → {new_version}
55
tag = False

services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ openapi: 3.1.0
22
info:
33
title: simcore-service-webserver
44
description: Main service with an interface (http-API & websockets) to the web front-end
5-
version: 0.78.0
5+
version: 0.79.0
66
servers:
77
- url: ''
88
description: webserver
@@ -18429,6 +18429,30 @@ components:
1842918429
title: Products
1843018430
description: List of products this users is included or None if fields is
1843118431
unset
18432+
userId:
18433+
anyOf:
18434+
- type: integer
18435+
exclusiveMinimum: true
18436+
minimum: 0
18437+
- type: 'null'
18438+
title: Userid
18439+
description: Unique identifier of the user if an account was created
18440+
userName:
18441+
anyOf:
18442+
- type: string
18443+
maxLength: 100
18444+
minLength: 1
18445+
- type: 'null'
18446+
title: Username
18447+
description: Username of the user if an account was created
18448+
groupId:
18449+
anyOf:
18450+
- type: integer
18451+
exclusiveMinimum: true
18452+
minimum: 1
18453+
- type: 'null'
18454+
title: Groupid
18455+
description: Primary group ID of the user if an account was created
1843218456
type: object
1843318457
required:
1843418458
- firstName
@@ -18445,7 +18469,6 @@ components:
1844518469
- preRegistrationCreated
1844618470
- accountRequestStatus
1844718471
- registered
18448-
- status
1844918472
title: UserAccountGet
1845018473
UserAccountReject:
1845118474
properties:

services/web/server/src/simcore_service_webserver/users/_accounts_repository.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -381,19 +381,25 @@ async def search_merged_pre_and_registered_users(
381381
users_pre_registration_details.c.state,
382382
users_pre_registration_details.c.postal_code,
383383
users_pre_registration_details.c.country,
384-
users_pre_registration_details.c.user_id,
384+
users_pre_registration_details.c.user_id.label("pre_reg_user_id"),
385385
users_pre_registration_details.c.extras,
386386
users_pre_registration_details.c.account_request_status,
387387
users_pre_registration_details.c.account_request_reviewed_by,
388388
users_pre_registration_details.c.account_request_reviewed_at,
389-
users.c.status,
390389
invited_by,
391390
account_request_reviewed_by_username, # account_request_reviewed_by converted to username
392391
users_pre_registration_details.c.created,
392+
# NOTE: some users have no pre-registration details (e.g. s4l-lite)
393+
users.c.id.label("user_id"), # real user_id from users table
394+
users.c.name.label("user_name"),
395+
users.c.primary_gid.label("user_primary_group_id"),
396+
users.c.status,
393397
)
394398

395399
left_outer_join = _build_left_outer_join_query(
396-
filter_by_email_like, product_name, columns
400+
filter_by_email_like,
401+
product_name,
402+
columns,
397403
)
398404
right_outer_join = _build_right_outer_join_query(
399405
filter_by_email_like,
@@ -494,6 +500,7 @@ async def list_merged_pre_and_registered_users(
494500
users_pre_registration_details.c.account_request_reviewed_at,
495501
users.c.id.label("user_id"),
496502
users.c.name.label("user_name"),
503+
users.c.primary_gid.label("user_primary_group_id"),
497504
users.c.status,
498505
# Use created_by directly instead of a subquery
499506
users_pre_registration_details.c.created_by.label("created_by"),
@@ -530,6 +537,7 @@ async def list_merged_pre_and_registered_users(
530537
sa.literal(None).label("account_request_reviewed_at"),
531538
users.c.id.label("user_id"),
532539
users.c.name.label("user_name"),
540+
users.c.primary_gid.label("user_primary_group_id"),
533541
users.c.status,
534542
# Match the created_by field from the pre_reg query
535543
sa.literal(None).label("created_by"),

services/web/server/src/simcore_service_webserver/users/_accounts_service.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,10 @@ async def _list_products_or_none(user_id):
223223
# NOTE: old users will not have extra details
224224
registered=r.user_id is not None if r.pre_email else r.status is not None,
225225
status=r.status,
226+
# user
227+
user_id=r.user_id,
228+
user_name=r.user_name,
229+
user_primary_group_id=r.user_primary_group_id,
226230
)
227231
for r in rows
228232
]

services/web/server/tests/unit/with_dbs/03/invitations/test_users_accounts_rest_registration.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,14 @@ async def test_search_and_pre_registration(
231231
):
232232
assert client.app
233233

234+
# NOTE: listing of user accounts drops nullable fields to avoid lengthy responses (even if they have no defaults)
235+
# therefore they are reconstructed here from http response payloads
236+
nullable_fields = {
237+
name: None
238+
for name, field in UserAccountGet.model_fields.items()
239+
if is_nullable(field)
240+
}
241+
234242
# ONLY in `users` and NOT `users_pre_registration_details`
235243
resp = await client.get(
236244
"/v0/admin/user-accounts:search", params={"email": logged_user["email"]}
@@ -240,12 +248,6 @@ async def test_search_and_pre_registration(
240248
found, _ = await assert_status(resp, status.HTTP_200_OK)
241249
assert len(found) == 1
242250

243-
nullable_fields = {
244-
name: None
245-
for name, field in UserAccountGet.model_fields.items()
246-
if is_nullable(field)
247-
}
248-
249251
got = UserAccountGet.model_validate({**nullable_fields, **found[0]})
250252
expected = {
251253
"first_name": logged_user.get("first_name"),
@@ -261,6 +263,9 @@ async def test_search_and_pre_registration(
261263
"extras": {},
262264
"registered": True,
263265
"status": UserStatus.ACTIVE,
266+
"user_id": logged_user["id"],
267+
"user_name": logged_user["name"],
268+
"user_primary_group_id": logged_user.get("primary_gid"),
264269
}
265270
assert got.model_dump(include=set(expected)) == expected
266271

@@ -278,8 +283,8 @@ async def test_search_and_pre_registration(
278283
)
279284
found, _ = await assert_status(resp, status.HTTP_200_OK)
280285
assert len(found) == 1
281-
got = UserAccountGet(**found[0], state=None, status=None)
282286

287+
got = UserAccountGet.model_validate({**nullable_fields, **found[0]})
283288
assert got.model_dump(include={"registered", "status"}) == {
284289
"registered": False,
285290
"status": None,
@@ -302,7 +307,8 @@ async def test_search_and_pre_registration(
302307
)
303308
found, _ = await assert_status(resp, status.HTTP_200_OK)
304309
assert len(found) == 1
305-
got = UserAccountGet(**found[0], state=None)
310+
311+
got = UserAccountGet.model_validate({**nullable_fields, **found[0]})
306312
assert got.model_dump(include={"registered", "status"}) == {
307313
"registered": True,
308314
"status": new_user["status"],

0 commit comments

Comments
 (0)