Skip to content

Commit a9cf6b5

Browse files
authored
[PLT-3393] Add support of groups to invite_user() (#2035)
1 parent 3a71bd1 commit a9cf6b5

File tree

5 files changed

+212
-2
lines changed

5 files changed

+212
-2
lines changed

libs/labelbox/src/labelbox/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
from labelbox.schema.tool_building.step_reasoning_tool import StepReasoningTool
5656
from labelbox.schema.tool_building.prompt_issue_tool import PromptIssueTool
5757
from labelbox.schema.tool_building.relationship_tool import RelationshipTool
58-
from labelbox.schema.role import Role, ProjectRole
58+
from labelbox.schema.role import Role, ProjectRole, UserGroupRole
5959
from labelbox.schema.invite import Invite, InviteLimit
6060
from labelbox.schema.data_row_metadata import (
6161
DataRowMetadataOntology,

libs/labelbox/src/labelbox/orm/model.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,7 @@ class Entity(metaclass=EntityMeta):
399399
CatalogSlice: Type[labelbox.CatalogSlice]
400400
ModelSlice: Type[labelbox.ModelSlice]
401401
TaskQueue: Type[labelbox.TaskQueue]
402+
UserGroupRole: Type[labelbox.UserGroupRole]
402403

403404
@classmethod
404405
def _attributes_of_type(cls, attr_type):

libs/labelbox/src/labelbox/schema/organization.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import TYPE_CHECKING, Dict, List, Optional, Union
1+
from typing import TYPE_CHECKING, Dict, List, Set, Optional, Union
22

33
from lbox.exceptions import LabelboxError
44

@@ -22,6 +22,7 @@
2222
ProjectRole,
2323
Role,
2424
User,
25+
UserGroupRole,
2526
)
2627

2728

@@ -65,6 +66,7 @@ def invite_user(
6566
email: str,
6667
role: "Role",
6768
project_roles: Optional[List["ProjectRole"]] = None,
69+
user_group_roles: Optional[List["UserGroupRole"]] = None,
6870
) -> "Invite":
6971
"""
7072
Invite a new member to the org. This will send the user an email invite
@@ -88,6 +90,40 @@ def invite_user(
8890
f"Project roles cannot be set for a user with organization level permissions. Found role name `{role.name}`, expected `NONE`"
8991
)
9092

93+
if user_group_roles and role.name != "NONE":
94+
raise ValueError(
95+
f"User Group roles cannot be set for a user with organization level permissions. Found role name `{role.name}`, expected `NONE`"
96+
)
97+
98+
if user_group_roles:
99+
# The backend can 500 if the same groupId appears more than once.
100+
# We dedupe exact duplicates (same groupId+roleId), but reject
101+
# conflicting assignments (same groupId with different roleId).
102+
103+
deduped_user_group_roles: Dict[str, "UserGroupRole"] = {}
104+
conflicting_user_group_ids: Set[str] = set()
105+
106+
for user_group_role in user_group_roles:
107+
user_group_id = user_group_role.user_group.id
108+
role_id = user_group_role.role.uid
109+
110+
existing = deduped_user_group_roles.get(user_group_id)
111+
if existing is None:
112+
deduped_user_group_roles[user_group_id] = user_group_role
113+
else:
114+
if existing.role.uid != role_id:
115+
conflicting_user_group_ids.add(user_group_id)
116+
117+
if conflicting_user_group_ids:
118+
conflicts_str = ", ".join(sorted(conflicting_user_group_ids))
119+
raise ValueError(
120+
"user_group_roles contains conflicting role assignments for "
121+
"the same UserGroup. Each UserGroup may only appear once. "
122+
f"Conflicting user_group.id values: {conflicts_str}"
123+
)
124+
125+
user_group_roles = list(deduped_user_group_roles.values())
126+
91127
data_param = "data"
92128
query_str = """mutation createInvitesPyApi($%s: [CreateInviteInput!]){
93129
createInvites(data: $%s){ invite { id createdAt organizationRoleName inviteeEmail inviter { %s } }}}""" % (
@@ -104,6 +140,19 @@ def invite_user(
104140
for project_role in project_roles or []
105141
]
106142

143+
user_group_ids = [
144+
user_group_role.user_group.id
145+
for user_group_role in user_group_roles or []
146+
]
147+
148+
user_group_with_role_ids = [
149+
{
150+
"groupId": user_group_role.user_group.id,
151+
"roleId": user_group_role.role.uid,
152+
}
153+
for user_group_role in user_group_roles or []
154+
]
155+
107156
res = self.client.execute(
108157
query_str,
109158
{
@@ -114,6 +163,8 @@ def invite_user(
114163
"organizationId": self.uid,
115164
"organizationRoleId": role.uid,
116165
"projects": projects,
166+
"userGroupIds": user_group_ids,
167+
"userGroupWithRoleIds": user_group_with_role_ids,
117168
}
118169
]
119170
},

libs/labelbox/src/labelbox/schema/role.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
if TYPE_CHECKING:
88
from labelbox import Client, Project
9+
from labelbox.schema.user_group import UserGroup
910

1011
_ROLES: Optional[Dict[str, "Role"]] = None
1112

@@ -45,3 +46,9 @@ class UserRole(Role): ...
4546
class ProjectRole:
4647
project: "Project"
4748
role: Role
49+
50+
51+
@dataclass
52+
class UserGroupRole:
53+
user_group: "UserGroup"
54+
role: Role
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import pytest
2+
from types import SimpleNamespace
3+
from unittest.mock import MagicMock
4+
5+
from labelbox.schema.role import UserGroupRole
6+
from labelbox.schema.organization import Organization
7+
8+
9+
def test_invite_user_duplicate_user_group_roles_same_role_is_deduped():
10+
client = MagicMock()
11+
client.get_user.return_value = SimpleNamespace(uid="inviter-id")
12+
client.execute.return_value = {
13+
"createInvites": [
14+
{
15+
"invite": {
16+
"id": "invite-id",
17+
"createdAt": "2020-01-01T00:00:00.000Z",
18+
"organizationRoleName": "NONE",
19+
"inviteeEmail": "[email protected]",
20+
"inviter": {"id": "inviter-id"},
21+
}
22+
}
23+
]
24+
}
25+
26+
organization = Organization(
27+
client,
28+
{
29+
"id": "org-id",
30+
"name": "Test Org",
31+
"createdAt": "2020-01-01T00:00:00.000Z",
32+
"updatedAt": "2020-01-01T00:00:00.000Z",
33+
},
34+
)
35+
36+
org_role_none = SimpleNamespace(uid="org-role-none-id", name="NONE")
37+
reviewer_role = SimpleNamespace(uid="reviewer-role-id", name="REVIEWER")
38+
user_group = SimpleNamespace(id="user-group-id")
39+
40+
user_group_roles = [
41+
UserGroupRole(user_group=user_group, role=reviewer_role),
42+
UserGroupRole(user_group=user_group, role=reviewer_role),
43+
]
44+
45+
organization.invite_user(
46+
47+
role=org_role_none,
48+
user_group_roles=user_group_roles,
49+
)
50+
51+
# ensure we only send one entry per group
52+
args, kwargs = client.execute.call_args
53+
assert kwargs == {}
54+
payload = args[1]["data"][0]
55+
assert payload["userGroupIds"] == ["user-group-id"]
56+
assert payload["userGroupWithRoleIds"] == [
57+
{"groupId": "user-group-id", "roleId": "reviewer-role-id"}
58+
]
59+
60+
61+
def test_invite_user_duplicate_user_group_roles_conflicting_roles_raises_value_error():
62+
client = MagicMock()
63+
client.get_user.return_value = SimpleNamespace(uid="inviter-id")
64+
65+
organization = Organization(
66+
client,
67+
{
68+
"id": "org-id",
69+
"name": "Test Org",
70+
"createdAt": "2020-01-01T00:00:00.000Z",
71+
"updatedAt": "2020-01-01T00:00:00.000Z",
72+
},
73+
)
74+
75+
org_role_none = SimpleNamespace(uid="org-role-none-id", name="NONE")
76+
reviewer_role = SimpleNamespace(uid="reviewer-role-id", name="REVIEWER")
77+
team_manager_role = SimpleNamespace(
78+
uid="team-manager-role-id", name="TEAM_MANAGER"
79+
)
80+
user_group = SimpleNamespace(id="user-group-id")
81+
82+
user_group_roles = [
83+
UserGroupRole(user_group=user_group, role=reviewer_role),
84+
UserGroupRole(user_group=user_group, role=team_manager_role),
85+
]
86+
87+
with pytest.raises(ValueError, match="conflicting role assignments"):
88+
organization.invite_user(
89+
90+
role=org_role_none,
91+
user_group_roles=user_group_roles,
92+
)
93+
94+
client.execute.assert_not_called()
95+
96+
97+
def test_invite_user_user_group_roles_payload_contains_all_groups():
98+
client = MagicMock()
99+
client.get_user.return_value = SimpleNamespace(uid="inviter-id")
100+
client.execute.return_value = {
101+
"createInvites": [
102+
{
103+
"invite": {
104+
"id": "invite-id",
105+
"createdAt": "2020-01-01T00:00:00.000Z",
106+
"organizationRoleName": "NONE",
107+
"inviteeEmail": "[email protected]",
108+
"inviter": {"id": "inviter-id"},
109+
}
110+
}
111+
]
112+
}
113+
114+
organization = Organization(
115+
client,
116+
{
117+
"id": "org-id",
118+
"name": "Test Org",
119+
"createdAt": "2020-01-01T00:00:00.000Z",
120+
"updatedAt": "2020-01-01T00:00:00.000Z",
121+
},
122+
)
123+
124+
org_role_none = SimpleNamespace(uid="org-role-none-id", name="NONE")
125+
reviewer_role = SimpleNamespace(uid="reviewer-role-id", name="REVIEWER")
126+
team_manager_role = SimpleNamespace(
127+
uid="team-manager-role-id", name="TEAM_MANAGER"
128+
)
129+
130+
ug1 = SimpleNamespace(id="user-group-1")
131+
ug2 = SimpleNamespace(id="user-group-2")
132+
133+
user_group_roles = [
134+
UserGroupRole(user_group=ug1, role=reviewer_role),
135+
UserGroupRole(user_group=ug2, role=team_manager_role),
136+
]
137+
138+
organization.invite_user(
139+
140+
role=org_role_none,
141+
user_group_roles=user_group_roles,
142+
)
143+
144+
args, kwargs = client.execute.call_args
145+
assert kwargs == {}
146+
payload = args[1]["data"][0]
147+
assert payload["userGroupIds"] == ["user-group-1", "user-group-2"]
148+
assert payload["userGroupWithRoleIds"] == [
149+
{"groupId": "user-group-1", "roleId": "reviewer-role-id"},
150+
{"groupId": "user-group-2", "roleId": "team-manager-role-id"},
151+
]

0 commit comments

Comments
 (0)