Skip to content

Commit 39a51ae

Browse files
authored
Merge branch 'master' into pr-osparc-remove-zero-pull-rate
2 parents 9958bec + 9c6068d commit 39a51ae

File tree

36 files changed

+628
-316
lines changed

36 files changed

+628
-316
lines changed

api/specs/web-server/_groups.py

Lines changed: 66 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,21 @@
88

99
from fastapi import APIRouter, Depends, status
1010
from models_library.api_schemas_webserver.groups import (
11-
AllUsersGroups,
11+
GroupCreate,
12+
GroupGet,
13+
GroupUpdate,
1214
GroupUserGet,
13-
UsersGroup,
15+
MyGroupsGet,
1416
)
1517
from models_library.generics import Envelope
16-
from models_library.users import GroupID, UserID
1718
from simcore_service_webserver._meta import API_VTAG
18-
from simcore_service_webserver.groups._handlers import _ClassifiersQuery
19+
from simcore_service_webserver.groups._handlers import (
20+
GroupUserAdd,
21+
GroupUserUpdate,
22+
_ClassifiersQuery,
23+
_GroupPathParams,
24+
_GroupUserPathParams,
25+
)
1926
from simcore_service_webserver.scicrunch.models import ResearchResource, ResourceHit
2027

2128
router = APIRouter(
@@ -28,106 +35,130 @@
2835

2936
@router.get(
3037
"/groups",
31-
response_model=Envelope[AllUsersGroups],
38+
response_model=Envelope[MyGroupsGet],
3239
)
3340
async def list_groups():
34-
...
41+
"""
42+
List all groups (organizations, primary, everyone and products) I belong to
43+
"""
3544

3645

3746
@router.post(
3847
"/groups",
39-
response_model=Envelope[UsersGroup],
48+
response_model=Envelope[GroupGet],
4049
status_code=status.HTTP_201_CREATED,
4150
)
42-
async def create_group():
43-
...
51+
async def create_group(_b: GroupCreate):
52+
"""
53+
Creates an organization group
54+
"""
4455

4556

4657
@router.get(
4758
"/groups/{gid}",
48-
response_model=Envelope[UsersGroup],
59+
response_model=Envelope[GroupGet],
4960
)
50-
async def get_group(gid: GroupID):
51-
...
61+
async def get_group(_p: Annotated[_GroupPathParams, Depends()]):
62+
"""
63+
Get an organization group
64+
"""
5265

5366

5467
@router.patch(
5568
"/groups/{gid}",
56-
response_model=Envelope[UsersGroup],
69+
response_model=Envelope[GroupGet],
5770
)
58-
async def update_group(gid: GroupID, _update: UsersGroup):
59-
...
71+
async def update_group(
72+
_p: Annotated[_GroupPathParams, Depends()],
73+
_b: GroupUpdate,
74+
):
75+
"""
76+
Updates organization groups
77+
"""
6078

6179

6280
@router.delete(
6381
"/groups/{gid}",
6482
status_code=status.HTTP_204_NO_CONTENT,
6583
)
66-
async def delete_group(gid: GroupID):
67-
...
84+
async def delete_group(_p: Annotated[_GroupPathParams, Depends()]):
85+
"""
86+
Deletes organization groups
87+
"""
6888

6989

7090
@router.get(
7191
"/groups/{gid}/users",
7292
response_model=Envelope[list[GroupUserGet]],
7393
)
74-
async def get_group_users(gid: GroupID):
75-
...
94+
async def get_all_group_users(_p: Annotated[_GroupPathParams, Depends()]):
95+
"""
96+
Gets users in organization groups
97+
"""
7698

7799

78100
@router.post(
79101
"/groups/{gid}/users",
80102
status_code=status.HTTP_204_NO_CONTENT,
81103
)
82104
async def add_group_user(
83-
gid: GroupID,
84-
_new: GroupUserGet,
105+
_p: Annotated[_GroupPathParams, Depends()],
106+
_b: GroupUserAdd,
85107
):
86-
...
108+
"""
109+
Adds a user to an organization group
110+
"""
87111

88112

89113
@router.get(
90114
"/groups/{gid}/users/{uid}",
91115
response_model=Envelope[GroupUserGet],
92116
)
93117
async def get_group_user(
94-
gid: GroupID,
95-
uid: UserID,
118+
_p: Annotated[_GroupUserPathParams, Depends()],
96119
):
97-
...
120+
"""
121+
Gets specific user in an organization group
122+
"""
98123

99124

100125
@router.patch(
101126
"/groups/{gid}/users/{uid}",
102127
response_model=Envelope[GroupUserGet],
103128
)
104129
async def update_group_user(
105-
gid: GroupID,
106-
uid: UserID,
107-
_update: GroupUserGet,
130+
_p: Annotated[_GroupUserPathParams, Depends()],
131+
_b: GroupUserUpdate,
108132
):
109-
# FIXME: update type
110-
...
133+
"""
134+
Updates user (access-rights) to an organization group
135+
"""
111136

112137

113138
@router.delete(
114139
"/groups/{gid}/users/{uid}",
115140
status_code=status.HTTP_204_NO_CONTENT,
116141
)
117142
async def delete_group_user(
118-
gid: GroupID,
119-
uid: UserID,
143+
_p: Annotated[_GroupUserPathParams, Depends()],
120144
):
121-
...
145+
"""
146+
Removes a user from an organization group
147+
"""
148+
149+
150+
#
151+
# Classifiers
152+
#
122153

123154

124155
@router.get(
125156
"/groups/{gid}/classifiers",
126157
response_model=Envelope[dict[str, Any]],
127158
)
128159
async def get_group_classifiers(
129-
gid: GroupID,
130-
_query: Annotated[_ClassifiersQuery, Depends()],
160+
_p: Annotated[_GroupPathParams, Depends()],
161+
_q: Annotated[_ClassifiersQuery, Depends()],
131162
):
132163
...
133164

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

Lines changed: 66 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
from contextlib import suppress
22
from typing import Any, ClassVar
33

4-
from pydantic import AnyUrl, BaseModel, Field, ValidationError, parse_obj_as, validator
4+
from pydantic import (
5+
AnyUrl,
6+
BaseModel,
7+
Field,
8+
ValidationError,
9+
parse_obj_as,
10+
root_validator,
11+
validator,
12+
)
513

614
from ..emails import LowerCaseEmailStr
7-
8-
#
9-
# GROUPS MODELS defined in OPENAPI specs
10-
#
15+
from ..users import UserID
16+
from ..utils.common_validators import create__check_only_one_is_set__root_validator
17+
from ._base import InputSchema, OutputSchema
1118

1219

1320
class GroupAccessRights(BaseModel):
@@ -29,7 +36,7 @@ class Config:
2936
}
3037

3138

32-
class UsersGroup(BaseModel):
39+
class GroupGet(OutputSchema):
3340
gid: int = Field(..., description="the group ID")
3441
label: str = Field(..., description="the group name")
3542
description: str = Field(..., description="the group description")
@@ -45,7 +52,7 @@ class UsersGroup(BaseModel):
4552

4653
@validator("thumbnail", pre=True)
4754
@classmethod
48-
def sanitize_legacy_data(cls, v):
55+
def _sanitize_legacy_data(cls, v):
4956
if v:
5057
# Enforces null if thumbnail is not valid URL or empty
5158
with suppress(ValidationError):
@@ -86,11 +93,23 @@ class Config:
8693
}
8794

8895

89-
class AllUsersGroups(BaseModel):
90-
me: UsersGroup | None = None
91-
organizations: list[UsersGroup] | None = None
92-
all: UsersGroup | None = None
93-
product: UsersGroup | None = None
96+
class GroupCreate(InputSchema):
97+
label: str
98+
description: str
99+
thumbnail: AnyUrl | None = None
100+
101+
102+
class GroupUpdate(InputSchema):
103+
label: str | None = None
104+
description: str | None = None
105+
thumbnail: AnyUrl | None = None
106+
107+
108+
class MyGroupsGet(OutputSchema):
109+
me: GroupGet
110+
organizations: list[GroupGet] | None = None
111+
all: GroupGet
112+
product: GroupGet | None = None
94113

95114
class Config:
96115
schema_extra: ClassVar[dict[str, Any]] = {
@@ -158,3 +177,38 @@ class Config:
158177
},
159178
}
160179
}
180+
181+
182+
class GroupUserAdd(InputSchema):
183+
"""
184+
Identify the user with either `email` or `uid` — only one.
185+
"""
186+
187+
uid: UserID | None = None
188+
email: LowerCaseEmailStr | None = None
189+
190+
_check_uid_or_email = root_validator(allow_reuse=True)(
191+
create__check_only_one_is_set__root_validator(["uid", "email"])
192+
)
193+
194+
class Config:
195+
schema_extra: ClassVar[dict[str, Any]] = {
196+
"examples": [{"uid": 42}, {"email": "[email protected]"}]
197+
}
198+
199+
200+
class GroupUserUpdate(InputSchema):
201+
# NOTE: since it is a single item, it is required. Cannot
202+
# update for the moment partial attributes e.g. {read: False}
203+
access_rights: GroupAccessRights
204+
205+
class Config:
206+
schema_extra: ClassVar[dict[str, Any]] = {
207+
"example": {
208+
"accessRights": {
209+
"read": True,
210+
"write": False,
211+
"delete": False,
212+
},
213+
}
214+
}

packages/models-library/src/models_library/utils/common_validators.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ class MyModel(BaseModel):
1616
"""
1717

1818
import enum
19+
import functools
20+
import operator
1921
from typing import Any
2022

2123

@@ -69,3 +71,36 @@ def null_or_none_str_to_none_validator(value: Any):
6971
if isinstance(value, str) and value.lower() in ("null", "none"):
7072
return None
7173
return value
74+
75+
76+
def create__check_only_one_is_set__root_validator(alternative_field_names: list[str]):
77+
"""Ensure exactly one and only one of the alternatives is set
78+
79+
NOTE: a field is considered here `unset` when it is `not None`. When None
80+
is used to indicate something else, please do not use this validator.
81+
82+
This is useful when you want to give the client alternative
83+
ways to set the same thing e.g. set the user by email or id or username
84+
and each of those has a different field
85+
86+
NOTE: Alternatevely, the previous example can also be solved using a
87+
single field as `user: Email | UserID | UserName`
88+
89+
SEE test_uid_or_email_are_set.py for more details
90+
"""
91+
92+
def _validator(cls, values):
93+
assert set(alternative_field_names).issubset(cls.__fields__) # nosec
94+
95+
got = {
96+
field_name: values.get(field_name) for field_name in alternative_field_names
97+
}
98+
99+
if not functools.reduce(operator.xor, (v is not None for v in got.values())):
100+
msg = (
101+
f"Either { 'or'.join(got.keys()) } must be set, but not both. Got {got}"
102+
)
103+
raise ValueError(msg)
104+
return values
105+
106+
return _validator
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from typing import Any
2+
3+
import pytest
4+
from models_library.api_schemas_webserver.groups import GroupUserAdd
5+
from pydantic import ValidationError
6+
7+
unset = object()
8+
9+
10+
@pytest.mark.parametrize("uid", [1, None, unset])
11+
@pytest.mark.parametrize("email", ["[email protected]", None, unset])
12+
def test_uid_or_email_are_set(uid: Any, email: Any):
13+
kwargs = {}
14+
if uid != unset:
15+
kwargs["uid"] = uid
16+
if email != unset:
17+
kwargs["email"] = email
18+
19+
none_are_defined = kwargs.get("uid") is None and kwargs.get("email") is None
20+
both_are_defined = kwargs.get("uid") is not None and kwargs.get("email") is not None
21+
22+
if none_are_defined or both_are_defined:
23+
with pytest.raises(ValidationError, match="not both"):
24+
GroupUserAdd(**kwargs)
25+
else:
26+
got = GroupUserAdd(**kwargs)
27+
assert bool(got.email) ^ bool(got.uid)

0 commit comments

Comments
 (0)