Skip to content

Commit 1ce9f08

Browse files
authored
♻️ web-server: Refactor users domain for improved layer separation and upgrading to asyncpg (#6937)
1 parent e391377 commit 1ce9f08

File tree

91 files changed

+2568
-1799
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

91 files changed

+2568
-1799
lines changed

api/specs/web-server/_users.py

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,27 +7,27 @@
77
from typing import Annotated
88

99
from fastapi import APIRouter, Depends, status
10-
from models_library.api_schemas_webserver.users import MyProfileGet, MyProfilePatch
10+
from models_library.api_schemas_webserver.users import (
11+
MyPermissionGet,
12+
MyProfileGet,
13+
MyProfilePatch,
14+
MyTokenCreate,
15+
MyTokenGet,
16+
UserGet,
17+
UsersSearchQueryParams,
18+
)
1119
from models_library.api_schemas_webserver.users_preferences import PatchRequestBody
1220
from models_library.generics import Envelope
1321
from models_library.user_preferences import PreferenceIdentifier
1422
from simcore_service_webserver._meta import API_VTAG
15-
from simcore_service_webserver.users._handlers import PreUserProfile, _SearchQueryParams
23+
from simcore_service_webserver.users._common.schemas import PreRegisteredUserGet
1624
from simcore_service_webserver.users._notifications import (
1725
UserNotification,
1826
UserNotificationCreate,
1927
UserNotificationPatch,
2028
)
21-
from simcore_service_webserver.users._notifications_handlers import (
22-
_NotificationPathParams,
23-
)
24-
from simcore_service_webserver.users._schemas import UserProfile
25-
from simcore_service_webserver.users._tokens_handlers import _TokenPathParams
26-
from simcore_service_webserver.users.schemas import (
27-
PermissionGet,
28-
ThirdPartyToken,
29-
TokenCreate,
30-
)
29+
from simcore_service_webserver.users._notifications_rest import _NotificationPathParams
30+
from simcore_service_webserver.users._tokens_rest import _TokenPathParams
3131

3232
router = APIRouter(prefix=f"/{API_VTAG}", tags=["user"])
3333

@@ -63,32 +63,32 @@ async def replace_my_profile(_profile: MyProfilePatch):
6363
status_code=status.HTTP_204_NO_CONTENT,
6464
)
6565
async def set_frontend_preference(
66-
preference_id: PreferenceIdentifier, # noqa: ARG001
67-
body_item: PatchRequestBody, # noqa: ARG001
66+
preference_id: PreferenceIdentifier,
67+
body_item: PatchRequestBody,
6868
):
6969
...
7070

7171

7272
@router.get(
7373
"/me/tokens",
74-
response_model=Envelope[list[ThirdPartyToken]],
74+
response_model=Envelope[list[MyTokenGet]],
7575
)
7676
async def list_tokens():
7777
...
7878

7979

8080
@router.post(
8181
"/me/tokens",
82-
response_model=Envelope[ThirdPartyToken],
82+
response_model=Envelope[MyTokenGet],
8383
status_code=status.HTTP_201_CREATED,
8484
)
85-
async def create_token(_token: TokenCreate):
85+
async def create_token(_token: MyTokenCreate):
8686
...
8787

8888

8989
@router.get(
9090
"/me/tokens/{service}",
91-
response_model=Envelope[ThirdPartyToken],
91+
response_model=Envelope[MyTokenGet],
9292
)
9393
async def get_token(_params: Annotated[_TokenPathParams, Depends()]):
9494
...
@@ -131,30 +131,30 @@ async def mark_notification_as_read(
131131

132132
@router.get(
133133
"/me/permissions",
134-
response_model=Envelope[list[PermissionGet]],
134+
response_model=Envelope[list[MyPermissionGet]],
135135
)
136136
async def list_user_permissions():
137137
...
138138

139139

140140
@router.get(
141141
"/users:search",
142-
response_model=Envelope[list[UserProfile]],
142+
response_model=Envelope[list[UserGet]],
143143
tags=[
144144
"po",
145145
],
146146
)
147-
async def search_users(_params: Annotated[_SearchQueryParams, Depends()]):
147+
async def search_users(_params: Annotated[UsersSearchQueryParams, Depends()]):
148148
# NOTE: see `Search` in `Common Custom Methods` in https://cloud.google.com/apis/design/custom_methods
149149
...
150150

151151

152152
@router.post(
153153
"/users:pre-register",
154-
response_model=Envelope[UserProfile],
154+
response_model=Envelope[UserGet],
155155
tags=[
156156
"po",
157157
],
158158
)
159-
async def pre_register_user(_body: PreUserProfile):
159+
async def pre_register_user(_body: PreRegisteredUserGet):
160160
...

packages/pytest-simcore/src/pytest_simcore/helpers/dict_tools.py renamed to packages/common-library/src/common_library/dict_tools.py

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
1-
""" Utils to operate with dicts """
1+
""" A collection of free functions to manipulate dicts
2+
"""
23

3-
from copy import deepcopy
4-
from typing import Any, Mapping
4+
from collections.abc import Mapping
5+
from copy import copy, deepcopy
6+
from typing import Any
57

6-
ConfigDict = dict[str, Any]
8+
9+
def remap_keys(data: dict, rename: dict[str, str]) -> dict[str, Any]:
10+
"""A new dict that renames the keys of a dict while keeping the values unchanged
11+
12+
NOTE: Does not support renaming of nested keys
13+
"""
14+
return {rename.get(k, k): v for k, v in data.items()}
715

816

917
def get_from_dict(obj: Mapping[str, Any], dotted_key: str, default=None) -> Any:
@@ -28,10 +36,10 @@ def copy_from_dict(
2836
#
2937

3038
if include is None:
31-
return deepcopy(data) if deep else data.copy()
39+
return deepcopy(data) if deep else copy(data)
3240

3341
if include == ...:
34-
return deepcopy(data) if deep else data.copy()
42+
return deepcopy(data) if deep else copy(data)
3543

3644
if isinstance(include, set):
3745
return {key: data[key] for key in include}
@@ -46,7 +54,7 @@ def copy_from_dict(
4654

4755
def update_dict(obj: dict, **updates):
4856
for key, update_value in updates.items():
49-
if callable(update_value):
50-
update_value = update_value(obj[key])
51-
obj.update({key: update_value})
57+
obj.update(
58+
{key: update_value(obj[key]) if callable(update_value) else update_value}
59+
)
5260
return obj
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
from enum import Enum
2+
from functools import total_ordering
3+
4+
_USER_ROLE_TO_LEVEL = {
5+
"ANONYMOUS": 0,
6+
"GUEST": 10,
7+
"USER": 20,
8+
"TESTER": 30,
9+
"PRODUCT_OWNER": 40,
10+
"ADMIN": 100,
11+
}
12+
13+
14+
@total_ordering
15+
class UserRole(Enum):
16+
"""SORTED enumeration of user roles
17+
18+
A role defines a set of privileges the user can perform
19+
Roles are sorted from lower to highest privileges
20+
USER is the role assigned by default A user with a higher/lower role is denoted super/infra user
21+
22+
ANONYMOUS : The user is not logged in
23+
GUEST : Temporary user with very limited access. Main used for demos and for a limited amount of time
24+
USER : Registered user. Basic permissions to use the platform [default]
25+
TESTER : Upgraded user. First level of super-user with privileges to test the framework.
26+
Can use everything but does not have an effect in other users or actual data
27+
ADMIN : Framework admin.
28+
29+
See security_access.py
30+
"""
31+
32+
ANONYMOUS = "ANONYMOUS"
33+
GUEST = "GUEST"
34+
USER = "USER"
35+
TESTER = "TESTER"
36+
PRODUCT_OWNER = "PRODUCT_OWNER"
37+
ADMIN = "ADMIN"
38+
39+
@property
40+
def privilege_level(self) -> int:
41+
return _USER_ROLE_TO_LEVEL[self.name]
42+
43+
def __lt__(self, other: "UserRole") -> bool:
44+
if self.__class__ is other.__class__:
45+
return self.privilege_level < other.privilege_level
46+
return NotImplemented
47+
48+
49+
class UserStatus(str, Enum):
50+
# This is a transition state. The user is registered but not confirmed. NOTE that state is optional depending on LOGIN_REGISTRATION_CONFIRMATION_REQUIRED
51+
CONFIRMATION_PENDING = "CONFIRMATION_PENDING"
52+
# This user can now operate the platform
53+
ACTIVE = "ACTIVE"
54+
# This user is inactive because it expired after a trial period
55+
EXPIRED = "EXPIRED"
56+
# This user is inactive because he has been a bad boy
57+
BANNED = "BANNED"
58+
# This user is inactive because it was marked for deletion
59+
DELETED = "DELETED"

packages/pytest-simcore/tests/test_helpers_utils_dict.py renamed to packages/common-library/tests/test_dict_tools.py

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,19 @@
33
# pylint: disable=unused-variable
44

55

6-
import json
7-
import sys
6+
from typing import Any
87

98
import pytest
10-
from pytest_simcore.helpers.dict_tools import copy_from_dict, get_from_dict
11-
from pytest_simcore.helpers.typing_docker import TaskDict
9+
from common_library.dict_tools import (
10+
copy_from_dict,
11+
get_from_dict,
12+
remap_keys,
13+
update_dict,
14+
)
1215

1316

1417
@pytest.fixture
15-
def data():
18+
def data() -> dict[str, Any]:
1619
return {
1720
"ID": "3ifd79yhz2vpgu1iz43mf9m2d",
1821
"Version": {"Index": 176},
@@ -113,7 +116,20 @@ def data():
113116
}
114117

115118

116-
def test_get_from_dict(data: TaskDict):
119+
def test_remap_keys():
120+
assert remap_keys({"a": 1, "b": 2}, rename={"a": "A"}) == {"A": 1, "b": 2}
121+
122+
123+
def test_update_dict():
124+
def _increment(x):
125+
return x + 1
126+
127+
data = {"a": 1, "b": 2, "c": 3}
128+
129+
assert update_dict(data, a=_increment, b=42) == {"a": 2, "b": 42, "c": 3}
130+
131+
132+
def test_get_from_dict(data: dict[str, Any]):
117133

118134
assert get_from_dict(data, "Spec.ContainerSpec.Labels") == {
119135
"com.docker.stack.namespace": "master-simcore"
@@ -122,7 +138,7 @@ def test_get_from_dict(data: TaskDict):
122138
assert get_from_dict(data, "Invalid.Invalid.Invalid", default=42) == 42
123139

124140

125-
def test_copy_from_dict(data: TaskDict):
141+
def test_copy_from_dict(data: dict[str, Any]):
126142

127143
selected_data = copy_from_dict(
128144
data,
@@ -136,20 +152,11 @@ def test_copy_from_dict(data: TaskDict):
136152
},
137153
)
138154

139-
print(json.dumps(selected_data, indent=2))
140-
141155
assert selected_data["ID"] == data["ID"]
142156
assert (
143157
selected_data["Spec"]["ContainerSpec"]["Image"]
144158
== data["Spec"]["ContainerSpec"]["Image"]
145159
)
146160
assert selected_data["Status"]["State"] == data["Status"]["State"]
147161
assert "Message" not in selected_data["Status"]["State"]
148-
assert "Message" in data["Status"]["State"]
149-
150-
151-
if __name__ == "__main__":
152-
# NOTE: use in vscode "Run and Debug" -> select 'Python: Current File'
153-
sys.exit(
154-
pytest.main(["-vv", "-s", "--pdb", "--log-cli-level=WARNING", sys.argv[0]])
155-
)
162+
assert "running" in data["Status"]["State"]
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# pylint: disable=no-value-for-parameter
2+
# pylint: disable=redefined-outer-name
3+
# pylint: disable=unused-argument
4+
# pylint: disable=unused-variable
5+
6+
7+
from common_library.users_enums import _USER_ROLE_TO_LEVEL, UserRole
8+
9+
10+
def test_user_role_to_level_map_in_sync():
11+
# If fails, then update _USER_ROLE_TO_LEVEL map
12+
assert set(_USER_ROLE_TO_LEVEL.keys()) == set(UserRole.__members__.keys())
13+
14+
15+
def test_user_roles_compares_to_admin():
16+
assert UserRole.ANONYMOUS < UserRole.ADMIN
17+
assert UserRole.GUEST < UserRole.ADMIN
18+
assert UserRole.USER < UserRole.ADMIN
19+
assert UserRole.TESTER < UserRole.ADMIN
20+
assert UserRole.PRODUCT_OWNER < UserRole.ADMIN
21+
assert UserRole.ADMIN == UserRole.ADMIN
22+
23+
24+
def test_user_roles_compares_to_product_owner():
25+
assert UserRole.ANONYMOUS < UserRole.PRODUCT_OWNER
26+
assert UserRole.GUEST < UserRole.PRODUCT_OWNER
27+
assert UserRole.USER < UserRole.PRODUCT_OWNER
28+
assert UserRole.TESTER < UserRole.PRODUCT_OWNER
29+
assert UserRole.PRODUCT_OWNER == UserRole.PRODUCT_OWNER
30+
assert UserRole.ADMIN > UserRole.PRODUCT_OWNER
31+
32+
33+
def test_user_roles_compares_to_tester():
34+
assert UserRole.ANONYMOUS < UserRole.TESTER
35+
assert UserRole.GUEST < UserRole.TESTER
36+
assert UserRole.USER < UserRole.TESTER
37+
assert UserRole.TESTER == UserRole.TESTER
38+
assert UserRole.PRODUCT_OWNER > UserRole.TESTER
39+
assert UserRole.ADMIN > UserRole.TESTER
40+
41+
42+
def test_user_roles_compares_to_user():
43+
assert UserRole.ANONYMOUS < UserRole.USER
44+
assert UserRole.GUEST < UserRole.USER
45+
assert UserRole.USER == UserRole.USER
46+
assert UserRole.TESTER > UserRole.USER
47+
assert UserRole.PRODUCT_OWNER > UserRole.USER
48+
assert UserRole.ADMIN > UserRole.USER
49+
50+
51+
def test_user_roles_compares_to_guest():
52+
assert UserRole.ANONYMOUS < UserRole.GUEST
53+
assert UserRole.GUEST == UserRole.GUEST
54+
assert UserRole.USER > UserRole.GUEST
55+
assert UserRole.TESTER > UserRole.GUEST
56+
assert UserRole.PRODUCT_OWNER > UserRole.GUEST
57+
assert UserRole.ADMIN > UserRole.GUEST
58+
59+
60+
def test_user_roles_compares_to_anonymous():
61+
assert UserRole.ANONYMOUS == UserRole.ANONYMOUS
62+
assert UserRole.GUEST > UserRole.ANONYMOUS
63+
assert UserRole.USER > UserRole.ANONYMOUS
64+
assert UserRole.TESTER > UserRole.ANONYMOUS
65+
assert UserRole.PRODUCT_OWNER > UserRole.ANONYMOUS
66+
assert UserRole.ADMIN > UserRole.ANONYMOUS
67+
68+
69+
def test_user_roles_compares():
70+
# < and >
71+
assert UserRole.TESTER < UserRole.ADMIN
72+
assert UserRole.ADMIN > UserRole.TESTER
73+
74+
# >=, == and <=
75+
assert UserRole.TESTER <= UserRole.ADMIN
76+
assert UserRole.ADMIN >= UserRole.TESTER
77+
78+
assert UserRole.ADMIN <= UserRole.ADMIN
79+
assert UserRole.ADMIN == UserRole.ADMIN

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@ class InputSchema(BaseModel):
2929
)
3030

3131

32+
class OutputSchemaWithoutCamelCase(BaseModel):
33+
model_config = ConfigDict(
34+
populate_by_name=True,
35+
extra="ignore",
36+
frozen=True,
37+
)
38+
39+
3240
class OutputSchema(BaseModel):
3341
model_config = ConfigDict(
3442
alias_generator=snake_to_camel,

0 commit comments

Comments
 (0)