From 5917322459934e26b87ec2c1cd64c85ba7e629e0 Mon Sep 17 00:00:00 2001 From: paulnoirel <87332996+paulnoirel@users.noreply.github.com> Date: Mon, 12 Jan 2026 15:37:58 +0000 Subject: [PATCH] PLT-3393: invite users with user_group_roles --- libs/labelbox/src/labelbox/__init__.py | 2 +- libs/labelbox/src/labelbox/client.py | 10 +- libs/labelbox/src/labelbox/orm/model.py | 1 + .../src/labelbox/schema/organization.py | 53 +++++- libs/labelbox/src/labelbox/schema/project.py | 36 +++-- libs/labelbox/src/labelbox/schema/role.py | 7 + .../schema/test_organization_invite_user.py | 151 ++++++++++++++++++ 7 files changed, 239 insertions(+), 21 deletions(-) create mode 100644 libs/labelbox/tests/unit/schema/test_organization_invite_user.py diff --git a/libs/labelbox/src/labelbox/__init__.py b/libs/labelbox/src/labelbox/__init__.py index a7b13e77a..d8296d594 100644 --- a/libs/labelbox/src/labelbox/__init__.py +++ b/libs/labelbox/src/labelbox/__init__.py @@ -55,7 +55,7 @@ from labelbox.schema.tool_building.step_reasoning_tool import StepReasoningTool from labelbox.schema.tool_building.prompt_issue_tool import PromptIssueTool from labelbox.schema.tool_building.relationship_tool import RelationshipTool -from labelbox.schema.role import Role, ProjectRole +from labelbox.schema.role import Role, ProjectRole, UserGroupRole from labelbox.schema.invite import Invite, InviteLimit from labelbox.schema.data_row_metadata import ( DataRowMetadataOntology, diff --git a/libs/labelbox/src/labelbox/client.py b/libs/labelbox/src/labelbox/client.py index 0d8c113a3..60fb8016d 100644 --- a/libs/labelbox/src/labelbox/client.py +++ b/libs/labelbox/src/labelbox/client.py @@ -507,16 +507,16 @@ def delete_project_memberships( self, project_id: str, user_ids: list[str] ) -> dict: """Deletes project memberships for one or more users. - + Args: project_id (str): ID of the project user_ids (list[str]): List of user IDs to remove from the project - + Returns: dict: Result containing: - success (bool): True if operation succeeded - errorMessage (str or None): Error message if operation failed - + Example: >>> result = client.delete_project_memberships( >>> project_id="project123", @@ -539,12 +539,12 @@ def delete_project_memberships( errorMessage } }""" - + params = { "projectId": project_id, "userIds": user_ids, } - + result = self.execute(mutation, params) return result["deleteProjectMemberships"] diff --git a/libs/labelbox/src/labelbox/orm/model.py b/libs/labelbox/src/labelbox/orm/model.py index b4ec7c2c2..15a39fc72 100644 --- a/libs/labelbox/src/labelbox/orm/model.py +++ b/libs/labelbox/src/labelbox/orm/model.py @@ -399,6 +399,7 @@ class Entity(metaclass=EntityMeta): CatalogSlice: Type[labelbox.CatalogSlice] ModelSlice: Type[labelbox.ModelSlice] TaskQueue: Type[labelbox.TaskQueue] + UserGroupRole: Type[labelbox.UserGroupRole] @classmethod def _attributes_of_type(cls, attr_type): diff --git a/libs/labelbox/src/labelbox/schema/organization.py b/libs/labelbox/src/labelbox/schema/organization.py index 8d15cc1b2..4b84b5bba 100644 --- a/libs/labelbox/src/labelbox/schema/organization.py +++ b/libs/labelbox/src/labelbox/schema/organization.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Dict, List, Optional, Union +from typing import TYPE_CHECKING, Dict, List, Set, Optional, Union from lbox.exceptions import LabelboxError @@ -22,6 +22,7 @@ ProjectRole, Role, User, + UserGroupRole, ) @@ -65,6 +66,7 @@ def invite_user( email: str, role: "Role", project_roles: Optional[List["ProjectRole"]] = None, + user_group_roles: Optional[List["UserGroupRole"]] = None, ) -> "Invite": """ Invite a new member to the org. This will send the user an email invite @@ -88,6 +90,40 @@ def invite_user( f"Project roles cannot be set for a user with organization level permissions. Found role name `{role.name}`, expected `NONE`" ) + if user_group_roles and role.name != "NONE": + raise ValueError( + f"User Group roles cannot be set for a user with organization level permissions. Found role name `{role.name}`, expected `NONE`" + ) + + if user_group_roles: + # The backend can 500 if the same groupId appears more than once. + # We dedupe exact duplicates (same groupId+roleId), but reject + # conflicting assignments (same groupId with different roleId). + + deduped_user_group_roles: Dict[str, "UserGroupRole"] = {} + conflicting_user_group_ids: Set[str] = set() + + for user_group_role in user_group_roles: + user_group_id = user_group_role.user_group.id + role_id = user_group_role.role.uid + + existing = deduped_user_group_roles.get(user_group_id) + if existing is None: + deduped_user_group_roles[user_group_id] = user_group_role + else: + if existing.role.uid != role_id: + conflicting_user_group_ids.add(user_group_id) + + if conflicting_user_group_ids: + conflicts_str = ", ".join(sorted(conflicting_user_group_ids)) + raise ValueError( + "user_group_roles contains conflicting role assignments for " + "the same UserGroup. Each UserGroup may only appear once. " + f"Conflicting user_group.id values: {conflicts_str}" + ) + + user_group_roles = list(deduped_user_group_roles.values()) + data_param = "data" query_str = """mutation createInvitesPyApi($%s: [CreateInviteInput!]){ createInvites(data: $%s){ invite { id createdAt organizationRoleName inviteeEmail inviter { %s } }}}""" % ( @@ -104,6 +140,19 @@ def invite_user( for project_role in project_roles or [] ] + user_group_ids = [ + user_group_role.user_group.id + for user_group_role in user_group_roles or [] + ] + + user_group_with_role_ids = [ + { + "groupId": user_group_role.user_group.id, + "roleId": user_group_role.role.uid, + } + for user_group_role in user_group_roles or [] + ] + res = self.client.execute( query_str, { @@ -114,6 +163,8 @@ def invite_user( "organizationId": self.uid, "organizationRoleId": role.uid, "projects": projects, + "userGroupIds": user_group_ids, + "userGroupWithRoleIds": user_group_with_role_ids, } ] }, diff --git a/libs/labelbox/src/labelbox/schema/project.py b/libs/labelbox/src/labelbox/schema/project.py index f00a75cb2..60d6b6258 100644 --- a/libs/labelbox/src/labelbox/schema/project.py +++ b/libs/labelbox/src/labelbox/schema/project.py @@ -317,7 +317,9 @@ def get_resource_tags(self) -> List[ResourceTag]: return [ResourceTag(self.client, tag) for tag in results] - def labels(self, datasets=None, order_by=None, created_by=None) -> PaginatedCollection: + def labels( + self, datasets=None, order_by=None, created_by=None + ) -> PaginatedCollection: """Custom relationship expansion method to support limited filtering. Args: @@ -334,7 +336,7 @@ def labels(self, datasets=None, order_by=None, created_by=None) -> PaginatedColl Example: >>> # Get all labels >>> all_labels = project.labels() - >>> + >>> >>> # Get labels by specific user >>> user_labels = project.labels(created_by=user_id) >>> # or @@ -351,16 +353,22 @@ def labels(self, datasets=None, order_by=None, created_by=None) -> PaginatedColl # Build where clause where_clauses = [] - + if datasets is not None: - dataset_ids = ", ".join('"%s"' % dataset.uid for dataset in datasets) - where_clauses.append(f"dataRow: {{dataset: {{id_in: [{dataset_ids}]}}}}") - + dataset_ids = ", ".join( + '"%s"' % dataset.uid for dataset in datasets + ) + where_clauses.append( + f"dataRow: {{dataset: {{id_in: [{dataset_ids}]}}}}" + ) + if created_by is not None: # Handle both User object and user_id string - user_id = created_by.uid if hasattr(created_by, 'uid') else created_by + user_id = ( + created_by.uid if hasattr(created_by, "uid") else created_by + ) where_clauses.append(f'createdBy: {{id: "{user_id}"}}') - + if where_clauses: where = " where:{" + ", ".join(where_clauses) + "}" else: @@ -396,7 +404,7 @@ def labels(self, datasets=None, order_by=None, created_by=None) -> PaginatedColl def delete_labels_by_user(self, user_id: str) -> int: """Soft deletes all labels created by a specific user in this project. - + This performs a soft delete (sets deleted=true in the database). The labels will no longer appear in queries but remain in the database. Labels are deleted in chunks of 500 to avoid overwhelming the API. @@ -413,18 +421,18 @@ def delete_labels_by_user(self, user_id: str) -> int: >>> print(f"Deleted {deleted_count} labels") """ labels_to_delete = list(self.labels(created_by=user_id)) - + if not labels_to_delete: return 0 - + chunk_size = 500 total_deleted = 0 - + for i in range(0, len(labels_to_delete), chunk_size): - chunk = labels_to_delete[i:i + chunk_size] + chunk = labels_to_delete[i : i + chunk_size] Entity.Label.bulk_delete(chunk) total_deleted += len(chunk) - + return total_deleted def export( diff --git a/libs/labelbox/src/labelbox/schema/role.py b/libs/labelbox/src/labelbox/schema/role.py index 0367d8f0c..2ae6adc91 100644 --- a/libs/labelbox/src/labelbox/schema/role.py +++ b/libs/labelbox/src/labelbox/schema/role.py @@ -6,6 +6,7 @@ if TYPE_CHECKING: from labelbox import Client, Project + from labelbox.schema.user_group import UserGroup _ROLES: Optional[Dict[str, "Role"]] = None @@ -45,3 +46,9 @@ class UserRole(Role): ... class ProjectRole: project: "Project" role: Role + + +@dataclass +class UserGroupRole: + user_group: "UserGroup" + role: Role diff --git a/libs/labelbox/tests/unit/schema/test_organization_invite_user.py b/libs/labelbox/tests/unit/schema/test_organization_invite_user.py new file mode 100644 index 000000000..0e6ce5305 --- /dev/null +++ b/libs/labelbox/tests/unit/schema/test_organization_invite_user.py @@ -0,0 +1,151 @@ +import pytest +from types import SimpleNamespace +from unittest.mock import MagicMock + +from labelbox.schema.role import UserGroupRole +from labelbox.schema.organization import Organization + + +def test_invite_user_duplicate_user_group_roles_same_role_is_deduped(): + client = MagicMock() + client.get_user.return_value = SimpleNamespace(uid="inviter-id") + client.execute.return_value = { + "createInvites": [ + { + "invite": { + "id": "invite-id", + "createdAt": "2020-01-01T00:00:00.000Z", + "organizationRoleName": "NONE", + "inviteeEmail": "someone@example.com", + "inviter": {"id": "inviter-id"}, + } + } + ] + } + + organization = Organization( + client, + { + "id": "org-id", + "name": "Test Org", + "createdAt": "2020-01-01T00:00:00.000Z", + "updatedAt": "2020-01-01T00:00:00.000Z", + }, + ) + + org_role_none = SimpleNamespace(uid="org-role-none-id", name="NONE") + reviewer_role = SimpleNamespace(uid="reviewer-role-id", name="REVIEWER") + user_group = SimpleNamespace(id="user-group-id") + + user_group_roles = [ + UserGroupRole(user_group=user_group, role=reviewer_role), + UserGroupRole(user_group=user_group, role=reviewer_role), + ] + + organization.invite_user( + email="someone@example.com", + role=org_role_none, + user_group_roles=user_group_roles, + ) + + # ensure we only send one entry per group + args, kwargs = client.execute.call_args + assert kwargs == {} + payload = args[1]["data"][0] + assert payload["userGroupIds"] == ["user-group-id"] + assert payload["userGroupWithRoleIds"] == [ + {"groupId": "user-group-id", "roleId": "reviewer-role-id"} + ] + + +def test_invite_user_duplicate_user_group_roles_conflicting_roles_raises_value_error(): + client = MagicMock() + client.get_user.return_value = SimpleNamespace(uid="inviter-id") + + organization = Organization( + client, + { + "id": "org-id", + "name": "Test Org", + "createdAt": "2020-01-01T00:00:00.000Z", + "updatedAt": "2020-01-01T00:00:00.000Z", + }, + ) + + org_role_none = SimpleNamespace(uid="org-role-none-id", name="NONE") + reviewer_role = SimpleNamespace(uid="reviewer-role-id", name="REVIEWER") + team_manager_role = SimpleNamespace( + uid="team-manager-role-id", name="TEAM_MANAGER" + ) + user_group = SimpleNamespace(id="user-group-id") + + user_group_roles = [ + UserGroupRole(user_group=user_group, role=reviewer_role), + UserGroupRole(user_group=user_group, role=team_manager_role), + ] + + with pytest.raises(ValueError, match="conflicting role assignments"): + organization.invite_user( + email="someone@example.com", + role=org_role_none, + user_group_roles=user_group_roles, + ) + + client.execute.assert_not_called() + + +def test_invite_user_user_group_roles_payload_contains_all_groups(): + client = MagicMock() + client.get_user.return_value = SimpleNamespace(uid="inviter-id") + client.execute.return_value = { + "createInvites": [ + { + "invite": { + "id": "invite-id", + "createdAt": "2020-01-01T00:00:00.000Z", + "organizationRoleName": "NONE", + "inviteeEmail": "someone@example.com", + "inviter": {"id": "inviter-id"}, + } + } + ] + } + + organization = Organization( + client, + { + "id": "org-id", + "name": "Test Org", + "createdAt": "2020-01-01T00:00:00.000Z", + "updatedAt": "2020-01-01T00:00:00.000Z", + }, + ) + + org_role_none = SimpleNamespace(uid="org-role-none-id", name="NONE") + reviewer_role = SimpleNamespace(uid="reviewer-role-id", name="REVIEWER") + team_manager_role = SimpleNamespace( + uid="team-manager-role-id", name="TEAM_MANAGER" + ) + + ug1 = SimpleNamespace(id="user-group-1") + ug2 = SimpleNamespace(id="user-group-2") + + user_group_roles = [ + UserGroupRole(user_group=ug1, role=reviewer_role), + UserGroupRole(user_group=ug2, role=team_manager_role), + ] + + organization.invite_user( + email="someone@example.com", + role=org_role_none, + user_group_roles=user_group_roles, + ) + + args, kwargs = client.execute.call_args + assert kwargs == {} + payload = args[1]["data"][0] + assert payload["userGroupIds"] == ["user-group-1", "user-group-2"] + assert payload["userGroupWithRoleIds"] == [ + {"groupId": "user-group-1", "roleId": "reviewer-role-id"}, + {"groupId": "user-group-2", "roleId": "team-manager-role-id"}, + ]