Skip to content

Commit 6f0c82c

Browse files
authored
✨ Get and search users applying privacy settings 🗃️ (#6966)
1 parent 493488c commit 6f0c82c

File tree

24 files changed

+886
-238
lines changed

24 files changed

+886
-238
lines changed

api/specs/web-server/_admin.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
response_model=Envelope[Union[EmailTestFailed, EmailTestPassed]],
2929
)
3030
async def test_email(
31-
_test: TestEmail, x_simcore_products_name: str | None = Header(default=None)
31+
_body: TestEmail, x_simcore_products_name: str | None = Header(default=None)
3232
):
3333
# X-Simcore-Products-Name
3434
...

api/specs/web-server/_groups.py

+10-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
# pylint: disable=too-many-arguments
55

66

7+
from enum import Enum
78
from typing import Annotated, Any
89

910
from fastapi import APIRouter, Depends, status
@@ -87,19 +88,24 @@ async def delete_group(_path: Annotated[GroupsPathParams, Depends()]):
8788
"""
8889

8990

91+
_extra_tags: list[str | Enum] = ["users"]
92+
93+
9094
@router.get(
9195
"/groups/{gid}/users",
9296
response_model=Envelope[list[GroupUserGet]],
97+
tags=_extra_tags,
9398
)
9499
async def get_all_group_users(_path: Annotated[GroupsPathParams, Depends()]):
95100
"""
96-
Gets users in organization groups
101+
Gets users in organization or primary groups
97102
"""
98103

99104

100105
@router.post(
101106
"/groups/{gid}/users",
102107
status_code=status.HTTP_204_NO_CONTENT,
108+
tags=_extra_tags,
103109
)
104110
async def add_group_user(
105111
_path: Annotated[GroupsPathParams, Depends()],
@@ -113,6 +119,7 @@ async def add_group_user(
113119
@router.get(
114120
"/groups/{gid}/users/{uid}",
115121
response_model=Envelope[GroupUserGet],
122+
tags=_extra_tags,
116123
)
117124
async def get_group_user(
118125
_path: Annotated[GroupsUsersPathParams, Depends()],
@@ -125,6 +132,7 @@ async def get_group_user(
125132
@router.patch(
126133
"/groups/{gid}/users/{uid}",
127134
response_model=Envelope[GroupUserGet],
135+
tags=_extra_tags,
128136
)
129137
async def update_group_user(
130138
_path: Annotated[GroupsUsersPathParams, Depends()],
@@ -138,6 +146,7 @@ async def update_group_user(
138146
@router.delete(
139147
"/groups/{gid}/users/{uid}",
140148
status_code=status.HTTP_204_NO_CONTENT,
149+
tags=_extra_tags,
141150
)
142151
async def delete_group_user(
143152
_path: Annotated[GroupsUsersPathParams, Depends()],

api/specs/web-server/_users.py

+48-22
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
# pylint: disable=too-many-arguments
55

66

7+
from enum import Enum
78
from typing import Annotated
89

910
from fastapi import APIRouter, Depends, status
@@ -13,8 +14,10 @@
1314
MyProfilePatch,
1415
MyTokenCreate,
1516
MyTokenGet,
17+
UserForAdminGet,
1618
UserGet,
17-
UsersSearchQueryParams,
19+
UsersForAdminSearchQueryParams,
20+
UsersSearch,
1821
)
1922
from models_library.api_schemas_webserver.users_preferences import PatchRequestBody
2023
from models_library.generics import Envelope
@@ -29,7 +32,7 @@
2932
from simcore_service_webserver.users._notifications_rest import _NotificationPathParams
3033
from simcore_service_webserver.users._tokens_rest import _TokenPathParams
3134

32-
router = APIRouter(prefix=f"/{API_VTAG}", tags=["user"])
35+
router = APIRouter(prefix=f"/{API_VTAG}", tags=["users"])
3336

3437

3538
@router.get(
@@ -44,7 +47,7 @@ async def get_my_profile():
4447
"/me",
4548
status_code=status.HTTP_204_NO_CONTENT,
4649
)
47-
async def update_my_profile(_profile: MyProfilePatch):
50+
async def update_my_profile(_body: MyProfilePatch):
4851
...
4952

5053

@@ -54,7 +57,7 @@ async def update_my_profile(_profile: MyProfilePatch):
5457
deprecated=True,
5558
description="Use PATCH instead",
5659
)
57-
async def replace_my_profile(_profile: MyProfilePatch):
60+
async def replace_my_profile(_body: MyProfilePatch):
5861
...
5962

6063

@@ -64,7 +67,7 @@ async def replace_my_profile(_profile: MyProfilePatch):
6467
)
6568
async def set_frontend_preference(
6669
preference_id: PreferenceIdentifier,
67-
body_item: PatchRequestBody,
70+
_body: PatchRequestBody,
6871
):
6972
...
7073

@@ -82,23 +85,25 @@ async def list_tokens():
8285
response_model=Envelope[MyTokenGet],
8386
status_code=status.HTTP_201_CREATED,
8487
)
85-
async def create_token(_token: MyTokenCreate):
88+
async def create_token(_body: MyTokenCreate):
8689
...
8790

8891

8992
@router.get(
9093
"/me/tokens/{service}",
9194
response_model=Envelope[MyTokenGet],
9295
)
93-
async def get_token(_params: Annotated[_TokenPathParams, Depends()]):
96+
async def get_token(
97+
_path: Annotated[_TokenPathParams, Depends()],
98+
):
9499
...
95100

96101

97102
@router.delete(
98103
"/me/tokens/{service}",
99104
status_code=status.HTTP_204_NO_CONTENT,
100105
)
101-
async def delete_token(_params: Annotated[_TokenPathParams, Depends()]):
106+
async def delete_token(_path: Annotated[_TokenPathParams, Depends()]):
102107
...
103108

104109

@@ -114,7 +119,9 @@ async def list_user_notifications():
114119
"/me/notifications",
115120
status_code=status.HTTP_204_NO_CONTENT,
116121
)
117-
async def create_user_notification(_notification: UserNotificationCreate):
122+
async def create_user_notification(
123+
_body: UserNotificationCreate,
124+
):
118125
...
119126

120127

@@ -123,8 +130,8 @@ async def create_user_notification(_notification: UserNotificationCreate):
123130
status_code=status.HTTP_204_NO_CONTENT,
124131
)
125132
async def mark_notification_as_read(
126-
_params: Annotated[_NotificationPathParams, Depends()],
127-
_notification: UserNotificationPatch,
133+
_path: Annotated[_NotificationPathParams, Depends()],
134+
_body: UserNotificationPatch,
128135
):
129136
...
130137

@@ -137,24 +144,43 @@ async def list_user_permissions():
137144
...
138145

139146

140-
@router.get(
147+
#
148+
# USERS public
149+
#
150+
151+
152+
@router.post(
141153
"/users:search",
142154
response_model=Envelope[list[UserGet]],
143-
tags=[
144-
"po",
145-
],
155+
description="Search among users who are publicly visible to the caller (i.e., me) based on their privacy settings.",
146156
)
147-
async def search_users(_params: Annotated[UsersSearchQueryParams, Depends()]):
157+
async def search_users(_body: UsersSearch):
158+
...
159+
160+
161+
#
162+
# USERS admin
163+
#
164+
165+
_extra_tags: list[str | Enum] = ["admin"]
166+
167+
168+
@router.get(
169+
"/admin/users:search",
170+
response_model=Envelope[list[UserForAdminGet]],
171+
tags=_extra_tags,
172+
)
173+
async def search_users_for_admin(
174+
_query: Annotated[UsersForAdminSearchQueryParams, Depends()]
175+
):
148176
# NOTE: see `Search` in `Common Custom Methods` in https://cloud.google.com/apis/design/custom_methods
149177
...
150178

151179

152180
@router.post(
153-
"/users:pre-register",
154-
response_model=Envelope[UserGet],
155-
tags=[
156-
"po",
157-
],
181+
"/admin/users:pre-register",
182+
response_model=Envelope[UserForAdminGet],
183+
tags=_extra_tags,
158184
)
159-
async def pre_register_user(_body: PreRegisteredUserGet):
185+
async def pre_register_user_for_admin(_body: PreRegisteredUserGet):
160186
...

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

+27-5
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
)
3030
from ..users import UserID, UserNameID
3131
from ..utils.common_validators import create__check_only_one_is_set__root_validator
32-
from ._base import InputSchema, OutputSchema
32+
from ._base import InputSchema, OutputSchema, OutputSchemaWithoutCamelCase
3333

3434
S = TypeVar("S", bound=BaseModel)
3535

@@ -248,8 +248,7 @@ def from_model(
248248
)
249249

250250

251-
class GroupUserGet(BaseModel):
252-
# OutputSchema
251+
class GroupUserGet(OutputSchemaWithoutCamelCase):
253252

254253
# Identifiers
255254
id: Annotated[UserID | None, Field(description="the user's id")] = None
@@ -275,7 +274,14 @@ class GroupUserGet(BaseModel):
275274
] = None
276275

277276
# Access Rights
278-
access_rights: GroupAccessRights = Field(..., alias="accessRights")
277+
access_rights: Annotated[
278+
GroupAccessRights | None,
279+
Field(
280+
alias="accessRights",
281+
description="If group is standard, these are these are the access rights of the user to it."
282+
"None if primary group.",
283+
),
284+
] = None
279285

280286
model_config = ConfigDict(
281287
populate_by_name=True,
@@ -293,7 +299,23 @@ class GroupUserGet(BaseModel):
293299
"write": False,
294300
"delete": False,
295301
},
296-
}
302+
},
303+
"examples": [
304+
# unique member on a primary group with two different primacy settings
305+
{
306+
"id": "16",
307+
"userName": "mrprivate",
308+
"gid": "55",
309+
},
310+
{
311+
"id": "56",
312+
"userName": "mrpublic",
313+
"login": "[email protected]",
314+
"first_name": "Mr",
315+
"last_name": "Public",
316+
"gid": "42",
317+
},
318+
],
297319
},
298320
)
299321

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

+45-4
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,31 @@
33
from enum import Enum
44
from typing import Annotated, Any, Literal, Self
55

6+
import annotated_types
67
from common_library.basic_types import DEFAULT_FACTORY
78
from common_library.dict_tools import remap_keys
89
from common_library.users_enums import UserStatus
910
from models_library.groups import AccessRightsDict
10-
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator
11+
from pydantic import (
12+
ConfigDict,
13+
EmailStr,
14+
Field,
15+
StringConstraints,
16+
ValidationInfo,
17+
field_validator,
18+
)
1119

1220
from ..basic_types import IDStr
1321
from ..emails import LowerCaseEmailStr
14-
from ..groups import AccessRightsDict, Group, GroupsByTypeTuple
22+
from ..groups import AccessRightsDict, Group, GroupID, GroupsByTypeTuple
1523
from ..products import ProductName
24+
from ..rest_base import RequestParameters
1625
from ..users import (
1726
FirstNameStr,
1827
LastNameStr,
1928
MyProfile,
2029
UserID,
30+
UserNameID,
2131
UserPermission,
2232
UserThirdPartyToken,
2333
)
@@ -185,7 +195,37 @@ def _validate_user_name(cls, value: str):
185195
#
186196

187197

188-
class UsersSearchQueryParams(BaseModel):
198+
class UsersGetParams(RequestParameters):
199+
user_id: UserID
200+
201+
202+
class UsersSearch(InputSchema):
203+
match_: Annotated[
204+
str,
205+
StringConstraints(strip_whitespace=True, min_length=1, max_length=80),
206+
Field(
207+
description="Search string to match with usernames and public profiles (e.g. emails, first/last name)",
208+
alias="match",
209+
),
210+
]
211+
limit: Annotated[int, annotated_types.Interval(ge=1, le=50)] = 10
212+
213+
214+
class UserGet(OutputSchema):
215+
# Public profile of a user subject to its privacy settings
216+
user_id: UserID
217+
group_id: GroupID
218+
user_name: UserNameID
219+
first_name: str | None = None
220+
last_name: str | None = None
221+
email: EmailStr | None = None
222+
223+
@classmethod
224+
def from_model(cls, data):
225+
return cls.model_validate(data, from_attributes=True)
226+
227+
228+
class UsersForAdminSearchQueryParams(RequestParameters):
189229
email: Annotated[
190230
str,
191231
Field(
@@ -196,7 +236,8 @@ class UsersSearchQueryParams(BaseModel):
196236
]
197237

198238

199-
class UserGet(OutputSchema):
239+
class UserForAdminGet(OutputSchema):
240+
# ONLY for admins
200241
first_name: str | None
201242
last_name: str | None
202243
email: LowerCaseEmailStr

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ class GroupMember(BaseModel):
108108
last_name: str | None
109109

110110
# group access
111-
access_rights: AccessRightsDict
111+
access_rights: AccessRightsDict | None = None
112112

113113
model_config = ConfigDict(from_attributes=True)
114114

0 commit comments

Comments
 (0)