From 5c71fcfa4be421c5dc912eed75e3b12473c6493f Mon Sep 17 00:00:00 2001 From: Gabefire <33893811+Gabefire@users.noreply.github.com> Date: Thu, 10 Oct 2024 12:24:35 -0500 Subject: [PATCH 01/18] Add members model --- libs/labelbox/src/labelbox/schema/member.py | 419 ++++++++++++++++++++ 1 file changed, 419 insertions(+) create mode 100644 libs/labelbox/src/labelbox/schema/member.py diff --git a/libs/labelbox/src/labelbox/schema/member.py b/libs/labelbox/src/labelbox/schema/member.py new file mode 100644 index 000000000..41b808a28 --- /dev/null +++ b/libs/labelbox/src/labelbox/schema/member.py @@ -0,0 +1,419 @@ +from typing import Optional +from labelbox.exceptions import ( + MalformedQueryException, + ResourceNotFoundError, + UnprocessableEntityError, +) +from typing import Set, Iterator, Any +from pydantic import ( + Field, + field_validator, + model_serializer, + model_validator, + PrivateAttr, +) +from labelbox.utils import _CamelCaseMixin +from labelbox.schema.role import Role +from labelbox import Client + + +class ProjectMembership(_CamelCaseMixin): + project_id: str + role: Role + + def __hash__(self) -> int: + return self.project_id.__hash__() + + @model_serializer() + def serialize_model(self): + return { + "projectId": self.project_id, + "roleId": None if self.role.name is None else self.role.id, + } + + +class Member(_CamelCaseMixin): + """ + Represents a member in Labelbox. + + Attributes: + id (str, frozen): The ID of the member. Defaults to current users id. + updated_at (str, frozen): Last time member was updated. + created_at (str, frozen): When member was created. + email (str, frozen): Email of member. + name (str, frozen): Name of the member. + nickname (str, frozen): Nickname of the member. + picture (str, frozen): Picture of the member. + is_viewer (bool, frozen): Indicates if member is a viewer of org. + is_external_user (bool, frozen): Indicates if member is a external user of org + accessible_project_count (int, frozen): Them amount of projects the user can access + project_memberships (Set[ProjectMembership]): The current projects with role the user has access towards + can_access_all_projects (bool): If member can access all projects inside org. + default_role (Optional[Role]): Shows the members default role. None means the member does not have a default role. + user_group_ids (Set[str]): The user group ids the member is associated with. + client (Client): The Labelbox client object + + Methods: + get(self) -> "Member" + update(self) -> "Member" + delete(self) -> bool + get_user_groups(self, search: str, roles: Optional[list[Role]], group_ids: Optional[list[str]]) -> Iterator["Member"] + + Raises: + RuntimeError: If the experimental feature is not enabled in the client. + """ + + id: str = Field(frozen=True) + updated_at: str = Field(default="", frozen=True) + created_at: str = Field(default="", frozen=True) + email: str = Field(default="", frozen=True) + name: Optional[str] = Field(default=None, frozen=True) + nickname: Optional[str] = Field(default=None, frozen=True) + picture: Optional[str] = Field(default=None, frozen=True) + is_viewer: bool = Field(default=False, frozen=True) + is_external_user: bool = Field(default=False, frozen=True) + accessible_project_count: Optional[int] = Field(default=None, frozen=True) + project_memberships: Set[ProjectMembership] = Field(default=set()) + can_access_all_projects: bool = Field(default=False) + default_role: Optional[Role] = Field(default=None) + user_group_ids: Set[str] = Field(default=set()) + client: Client + _current_user_id: str = PrivateAttr() + + @field_validator("client", mode="before") + @classmethod + def experimental(cls, v: Client): + if not v.enable_experimental: + raise RuntimeError( + "Please enable experimental in client to use Members" + ) + return v + + @model_validator(mode="before") + def current_user(data: Any) -> Any: + data["_current_user_id"] = data["client"].get_user.uid + if "id" not in data: + data["id"] = data["_current_user_id"] + return data + + def get(self) -> "Member": + """ + Reloads the members information from the server. + + This method sends a GraphQL query to the server to fetch the latest information + about the members, The fetched + information is then used to update the corresponding attributes of the `Member` object. + + Returns: + Member: The updated `Member` object. + + Raises: + ResourceNotFoundError: If the query fails to fetch the member information. + """ + + if not self.id: + raise ValueError("Id is required") + query = """ + query GetMemberReworkPyApi($userId: ID!) { + user(where: { id: $userId }) { + id + updatedAt + createdAt + name + nickname + email + defaultRole { + id + name + } + picture + isViewer + isExternalUser + canAccessAllProjects + accessibleProjectCount + userGroups { + nodes { + id + } + } + __typename + } + projectMemberships(userId: $userId) { + role { + id + name + } + project { + id + } + } + } + """ + params = {"userId": self.id} + + result = self.client.execute(query, params, experimental=True) + if not result: + raise ResourceNotFoundError( + message="Failed to find user as user does not exist" + ) + + user = { + **result["user"], + "client": self.client, + "userGroups": [], + "projectMemberships": [], + "defaultRole": None, + } + model = self.model_copy(update=Member(**user).model_dump()) + + for userGroup in result["user"]["userGroups"]["nodes"]: + model.user_group_ids.add(userGroup["id"]) + + model.default_role = Role( + self.client, + field_values={ + "id": result["user"]["defaultRole"]["id"], + "name": result["user"]["defaultRole"]["name"], + }, + ) + + for project_membership in result["projectMemberships"]: + project_membership["role"] = Role( + self.client, + field_values={ + "id": project_membership["role"]["id"], + "name": project_membership["role"]["name"], + }, + ) + project_membership["projectId"] = project_membership["project"][ + "id" + ] + model.project_memberships.add( + ProjectMembership(**project_membership) + ) + + return model + + def update(self) -> "Member": + """ + Updates the member in Labelbox. + + Returns: + Member: The updated Member object. (self) + + Raises: + ResourceNotFoundError: If the update fails due to unknown member + UnprocessableEntityError: If the update fails due to a malformed input + """ + query = """ + mutation SetUserAccessPyApi($id: ID!, $roleId: ID!, $canAccessAllProjects: Boolean!, $groupIds: [String!], $projectMemberships: [ProjectMembershipsInput!]) { + setUserAccess( + where: {id: $id} + data: {roleId: $roleId, canAccessAllProjects: $canAccessAllProjects, groupIds: $groupIds, projectMemberships: $projectMemberships} + ) { + id + __typename + } + } + """ + params = { + "id": self.id, + "roleId": self.default_role.uid if self.default_role else None, + "canAccessAllProjects": self.can_access_all_projects, + "groupIds": self.user_group_ids, + "projectMemberships": [ + project_membership.model_dump() + for project_membership in self.project_memberships + ], + } + + try: + result = self.client.execute(query, params, experimental=True) + if not result: + raise ResourceNotFoundError( + message="Failed to update member as member does not exist" + ) + except MalformedQueryException as e: + raise UnprocessableEntityError("Failed to update member") from e + return self + + def delete(self) -> bool: + """ + Deletes the member from Labelbox. + + This method sends a mutation request to the Labelbox API to delete the member + with the specified ID. If the deletion is successful, it returns True. Otherwise, + it raises an UnprocessableEntityError and returns False. + + Returns: + bool: True if the member was successfully deleted, False otherwise. + + Raises: + ResourceNotFoundError: If the deletion of the member fails due to not existing + ValueError: If the member id is current users id. + """ + + query = """ + mutation DeleteMemberPyApi($id: ID!) { + updateUser(where: { id: $id }, data: { deleted: true }) { + id + deleted + } + } + """ + + if self.id == self.client.get_user().uid: + raise ValueError("Unable to delete self") + + params = {"id": self.id} + + result = self.client.execute(query, params, experimental=True) + if not result: + raise ResourceNotFoundError( + message="Failed to delete member as member does not exist" + ) + return result["data"]["updateUser"]["deleted"] + + def _get_project_memberships(self, user_id: str) -> Set[ProjectMembership]: + """ + Retrieves a set of project members from the given user_id. + + Args: + user_id (str): User id you are getting project memberships on. + + Returns: + set: A set of project members. + """ + query = """ + query GetMemberReworkPyApi($userId: ID!) { + projectMemberships(userId: $userId) { + role { + id + name + } + project { + id + } + } + } + """ + + params = {"userId": user_id} + result = self.client.execute(query, params) + + project_memberships = set() + for project_membership in result["projectMemberships"]: + project_membership["role"] = Role( + self.client, + field_values={ + "id": project_membership["role"]["id"], + "name": project_membership["role"]["name"], + }, + ) + project_membership["projectId"] = project_membership["project"][ + "id" + ] + + project_memberships.add(ProjectMembership(**project_membership)) + + return project_memberships + + def get_members( + self, + search: str = "", + roles: Optional[list[Role]] = None, + group_ids: Optional[list[str]] = None, + ) -> Iterator["Member"]: + """ + Gets all members in Labelbox. + + Args: + search (str): Email of user you are looking to receive. + roles (Optional[list[Role]]): Role of the users you are wanting to receive. + group_ids (Optional[list[str]]): List of user group ids. + + Returns: + Iterator[UserGroup]: An iterator over the user groups. + """ + query = """ + query GetOrganizationMembersPyApi( + $first: PageSize! + $skip: Int + $search: String + $roleIds: [String!] + $groupIds: [String!] + $complexFilters: [ComplexFilter!] + ) { + users( + where: { + email_contains: $search + organizationRoleId_in: $roleIds + groupId_in: $groupIds + complexFilters: $complexFilters + } + first: $first + skip: $skip + ) { + id + updatedAt + createdAt + name + nickname + email + defaultRole { + id + name + } + picture + isViewer + isExternalUser + canAccessAllProjects + accessibleProjectCount + userGroups { + nodes { + id + } + } + } + } + """ + previous_batch = 0 + batch_size = 100 + while True: + params = { + "first": batch_size, + "skip": previous_batch, + "search": search, + "roleIds": [role.uid for role in roles] if roles else None, + "groupIds": group_ids, + "complexFilters": None, + } + members = self.client.execute(query, params, experimental=True)[ + "users" + ] + + if not members: + return + yield + + for member in members: + member["projectMemberships"] = self._get_project_memberships( + member["id"] + ) + user_groups = set() + + for user_group in member["userGroups"]["nodes"]: + user_groups.add(user_group["id"]) + member["userGroupIds"] = user_groups + + member["defaultRole"] = Role( + self.client, + field_values={ + "id": member["defaultRole"]["id"], + "name": member["defaultRole"]["name"], + }, + ) + + yield Member(client=self.client, **member) + + previous_batch += batch_size From 5a4631b4570973bb129801d213bf766c45c30fca Mon Sep 17 00:00:00 2001 From: Gabefire <33893811+Gabefire@users.noreply.github.com> Date: Thu, 10 Oct 2024 12:58:48 -0500 Subject: [PATCH 02/18] Typos --- libs/labelbox/src/labelbox/schema/member.py | 28 +++++++++++++++------ 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/libs/labelbox/src/labelbox/schema/member.py b/libs/labelbox/src/labelbox/schema/member.py index 41b808a28..ae03f3e4d 100644 --- a/libs/labelbox/src/labelbox/schema/member.py +++ b/libs/labelbox/src/labelbox/schema/member.py @@ -6,6 +6,7 @@ ) from typing import Set, Iterator, Any from pydantic import ( + ConfigDict, Field, field_validator, model_serializer, @@ -78,7 +79,12 @@ class Member(_CamelCaseMixin): default_role: Optional[Role] = Field(default=None) user_group_ids: Set[str] = Field(default=set()) client: Client - _current_user_id: str = PrivateAttr() + _current_user_id: str + + def __init__(self, **data): + """set private attribute""" + super().__init__(**data) + self._current_user_id = self.client.get_user().uid @field_validator("client", mode="before") @classmethod @@ -91,9 +97,8 @@ def experimental(cls, v: Client): @model_validator(mode="before") def current_user(data: Any) -> Any: - data["_current_user_id"] = data["client"].get_user.uid if "id" not in data: - data["id"] = data["_current_user_id"] + data["id"] = data["client"].get_user().uid return data def get(self) -> "Member": @@ -250,7 +255,7 @@ def delete(self) -> bool: Raises: ResourceNotFoundError: If the deletion of the member fails due to not existing - ValueError: If the member id is current users id. + ValueError: If the member id is current member id. """ query = """ @@ -262,7 +267,7 @@ def delete(self) -> bool: } """ - if self.id == self.client.get_user().uid: + if self.id == self._current_user_id: raise ValueError("Unable to delete self") params = {"id": self.id} @@ -276,13 +281,13 @@ def delete(self) -> bool: def _get_project_memberships(self, user_id: str) -> Set[ProjectMembership]: """ - Retrieves a set of project members from the given user_id. + Retrieves a set of project membership objects from the given user_id. Args: user_id (str): User id you are getting project memberships on. Returns: - set: A set of project members. + set: A set of project memberships. """ query = """ query GetMemberReworkPyApi($userId: ID!) { @@ -417,3 +422,12 @@ def get_members( yield Member(client=self.client, **member) previous_batch += batch_size + + +if __name__ == "__main__": + api_key = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJjbG9vcmRpaGUwMDkyMDcza2Nvcm5jajdnIiwib3JnYW5pemF0aW9uSWQiOiJjbG9vcmRpZ3cwMDkxMDcza2M2cG9oeWFiIiwiYXBpS2V5SWQiOiJjbTIzbDEyZncwYjN2MDd4ZDlqNmthMTBjIiwic2VjcmV0IjoiZWNlZTQ1YzQ1YmU1NTlkOGNkNDgxOTJkMDgxZGIyYjMiLCJpYXQiOjE3Mjg1ODE4OTUsImV4cCI6MjM1OTczMzg5NX0.xm3Yvg0Zub0HKJzSFf66iClaAvBxJpTBo4dxiruW7SA" + client = Client(api_key) + client.enable_experimental = True + member = Member(client=client) + print(member) + print(member.delete()) From affd0a242ed61526d227ebb560065dec1792d08e Mon Sep 17 00:00:00 2001 From: Gabefire <33893811+Gabefire@users.noreply.github.com> Date: Thu, 10 Oct 2024 12:59:40 -0500 Subject: [PATCH 03/18] remove key --- libs/labelbox/src/labelbox/schema/member.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/libs/labelbox/src/labelbox/schema/member.py b/libs/labelbox/src/labelbox/schema/member.py index ae03f3e4d..6456d6925 100644 --- a/libs/labelbox/src/labelbox/schema/member.py +++ b/libs/labelbox/src/labelbox/schema/member.py @@ -422,12 +422,3 @@ def get_members( yield Member(client=self.client, **member) previous_batch += batch_size - - -if __name__ == "__main__": - api_key = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJjbG9vcmRpaGUwMDkyMDcza2Nvcm5jajdnIiwib3JnYW5pemF0aW9uSWQiOiJjbG9vcmRpZ3cwMDkxMDcza2M2cG9oeWFiIiwiYXBpS2V5SWQiOiJjbTIzbDEyZncwYjN2MDd4ZDlqNmthMTBjIiwic2VjcmV0IjoiZWNlZTQ1YzQ1YmU1NTlkOGNkNDgxOTJkMDgxZGIyYjMiLCJpYXQiOjE3Mjg1ODE4OTUsImV4cCI6MjM1OTczMzg5NX0.xm3Yvg0Zub0HKJzSFf66iClaAvBxJpTBo4dxiruW7SA" - client = Client(api_key) - client.enable_experimental = True - member = Member(client=client) - print(member) - print(member.delete()) From d51f3fa3c6584808390d476ba4f60accc9cf765e Mon Sep 17 00:00:00 2001 From: Gabefire <33893811+Gabefire@users.noreply.github.com> Date: Thu, 10 Oct 2024 14:30:47 -0500 Subject: [PATCH 04/18] Typos and wrote tests --- libs/labelbox/src/labelbox/schema/member.py | 13 ++- .../tests/integration/schema/test_members.py | 110 ++++++++++++++++++ 2 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 libs/labelbox/tests/integration/schema/test_members.py diff --git a/libs/labelbox/src/labelbox/schema/member.py b/libs/labelbox/src/labelbox/schema/member.py index 6456d6925..a412619e7 100644 --- a/libs/labelbox/src/labelbox/schema/member.py +++ b/libs/labelbox/src/labelbox/schema/member.py @@ -6,12 +6,10 @@ ) from typing import Set, Iterator, Any from pydantic import ( - ConfigDict, Field, field_validator, model_serializer, model_validator, - PrivateAttr, ) from labelbox.utils import _CamelCaseMixin from labelbox.schema.role import Role @@ -19,8 +17,15 @@ class ProjectMembership(_CamelCaseMixin): + """Represents a members project role + + Args: + project_id (str): id of the project you want the member included + role (Optional[Role]): Members role for the project. None represents the member having a default role. + """ + project_id: str - role: Role + role: Optional[Role] = None def __hash__(self) -> int: return self.project_id.__hash__() @@ -29,7 +34,7 @@ def __hash__(self) -> int: def serialize_model(self): return { "projectId": self.project_id, - "roleId": None if self.role.name is None else self.role.id, + "roleId": None if self.role is None else self.role.id, } diff --git a/libs/labelbox/tests/integration/schema/test_members.py b/libs/labelbox/tests/integration/schema/test_members.py new file mode 100644 index 000000000..2878f9041 --- /dev/null +++ b/libs/labelbox/tests/integration/schema/test_members.py @@ -0,0 +1,110 @@ +from uuid import uuid4 + +import faker +from labelbox.schema.member import Member, ProjectMembership +import pytest + +from labelbox.exceptions import ( + ResourceNotFoundError, +) +from labelbox.schema.user_group import UserGroup, UserGroupColor + +data = faker.Faker() + + +@pytest.fixture +def current_member(client): + yield Member(client=client).get() + + +@pytest.fixture +def member(client, current_member): + members = list(Member(client).get_members()) + test_member = None + for member in members: + if member.id != current_member.id: + test_member = member + return test_member + + +@pytest.fixture +def user_group(client): + group_name = data.name() + user_group = UserGroup(client) + user_group.name = group_name + user_group.color = UserGroupColor.BLUE + + yield user_group.create() + + user_group.delete() + + +def test_get_member(current_member, client): + current_member_eq = Member(client) + current_member_eq.get() + assert current_member_eq.id == current_member.id + assert current_member_eq.email == current_member.email + + +def test_throw_error_cannot_get_user_group_with_invalid_id(client): + Member = UserGroup(Member=client, id=str(uuid4())) + with pytest.raises(ResourceNotFoundError): + Member.get() + + +def test_throw_error_when_deleting_self(current_member, client): + with pytest.raises(ValueError): + current_member.delete() + + +def test_update_member(client, test_member, project_pack, user_group): + labeler_role = client.get_roles()["LABELER"] + reviewer_role = client.get_roles()["REVIEWER"] + for project in project_pack: + test_member.project_memberships.add( + ProjectMembership(project_id=project.uid, role=labeler_role) + ) + test_member.default_role = reviewer_role + test_member.user_group_ids.add(user_group.id) + test_member.can_access_all_projects = False + updated_member = test_member.update() + + # Verify that the member was updated successfully + assert test_member.email == updated_member.email + for project in project_pack: + assert ( + ProjectMembership(project_id=project.uid, role=labeler_role) + in updated_member.project_memberships + ) + assert user_group.id in updated_member.user_group_ids + assert updated_member.default_role == reviewer_role + + # Remove memberships and check if updated + updated_member.project_memberships = set() + updated_member.user_group_ids = set() + updated_member.default_role = labeler_role + updated_member.can_access_all_projects = True + updated_member = updated_member.update() + + assert updated_member.project_memberships == set() + assert updated_member.user_group_ids == set() + assert updated_member.default_role == labeler_role + assert updated_member.can_access_all_projects + + +def test_get_members(test_member, current_member, client): + members = list(Member(client).get_members(search=current_member.email)) + assert current_member in members + members = list( + Member(client).get_members(roles=[current_member.default_role]) + ) + assert current_member in members + members = list(Member(client).get_members()) + assert test_member in members + assert current_member in members + + +if __name__ == "__main__": + import subprocess + + subprocess.call(["pytest", "-v", __file__]) From b72ba5fa4a5c41a20cd45f37fa9125a7b16e6624 Mon Sep 17 00:00:00 2001 From: Gabefire <33893811+Gabefire@users.noreply.github.com> Date: Thu, 10 Oct 2024 15:36:30 -0500 Subject: [PATCH 05/18] Fixed tests made improvements --- libs/labelbox/src/labelbox/schema/member.py | 18 ++++++----- .../tests/integration/schema/test_members.py | 30 +++++++------------ 2 files changed, 22 insertions(+), 26 deletions(-) diff --git a/libs/labelbox/src/labelbox/schema/member.py b/libs/labelbox/src/labelbox/schema/member.py index a412619e7..d331c5abc 100644 --- a/libs/labelbox/src/labelbox/schema/member.py +++ b/libs/labelbox/src/labelbox/schema/member.py @@ -4,7 +4,7 @@ ResourceNotFoundError, UnprocessableEntityError, ) -from typing import Set, Iterator, Any +from typing import Set, Iterator, Any, List from pydantic import ( Field, field_validator, @@ -25,7 +25,7 @@ class ProjectMembership(_CamelCaseMixin): """ project_id: str - role: Optional[Role] = None + role: Optional[Role] = Field(default=None) def __hash__(self) -> int: return self.project_id.__hash__() @@ -34,7 +34,7 @@ def __hash__(self) -> int: def serialize_model(self): return { "projectId": self.project_id, - "roleId": None if self.role is None else self.role.id, + "roleId": None if self.role is None else self.role.uid, } @@ -214,6 +214,7 @@ def update(self) -> "Member": Raises: ResourceNotFoundError: If the update fails due to unknown member UnprocessableEntityError: If the update fails due to a malformed input + ValueError: If the member id is current member id. """ query = """ mutation SetUserAccessPyApi($id: ID!, $roleId: ID!, $canAccessAllProjects: Boolean!, $groupIds: [String!], $projectMemberships: [ProjectMembershipsInput!]) { @@ -230,13 +231,16 @@ def update(self) -> "Member": "id": self.id, "roleId": self.default_role.uid if self.default_role else None, "canAccessAllProjects": self.can_access_all_projects, - "groupIds": self.user_group_ids, + "groupIds": list(self.user_group_ids), "projectMemberships": [ project_membership.model_dump() for project_membership in self.project_memberships ], } + if self.id == self._current_user_id: + raise ValueError("Unable to update self") + try: result = self.client.execute(query, params, experimental=True) if not result: @@ -282,7 +286,7 @@ def delete(self) -> bool: raise ResourceNotFoundError( message="Failed to delete member as member does not exist" ) - return result["data"]["updateUser"]["deleted"] + return result["updateUser"]["deleted"] def _get_project_memberships(self, user_id: str) -> Set[ProjectMembership]: """ @@ -331,8 +335,8 @@ def _get_project_memberships(self, user_id: str) -> Set[ProjectMembership]: def get_members( self, search: str = "", - roles: Optional[list[Role]] = None, - group_ids: Optional[list[str]] = None, + roles: Optional[List[Role]] = None, + group_ids: Optional[List[str]] = None, ) -> Iterator["Member"]: """ Gets all members in Labelbox. diff --git a/libs/labelbox/tests/integration/schema/test_members.py b/libs/labelbox/tests/integration/schema/test_members.py index 2878f9041..8dd7e6496 100644 --- a/libs/labelbox/tests/integration/schema/test_members.py +++ b/libs/labelbox/tests/integration/schema/test_members.py @@ -1,9 +1,6 @@ -from uuid import uuid4 - import faker from labelbox.schema.member import Member, ProjectMembership import pytest - from labelbox.exceptions import ( ResourceNotFoundError, ) @@ -18,8 +15,8 @@ def current_member(client): @pytest.fixture -def member(client, current_member): - members = list(Member(client).get_members()) +def test_member(client, current_member): + members = list(Member(client=client).get_members()) test_member = None for member in members: if member.id != current_member.id: @@ -30,7 +27,7 @@ def member(client, current_member): @pytest.fixture def user_group(client): group_name = data.name() - user_group = UserGroup(client) + user_group = UserGroup(client=client) user_group.name = group_name user_group.color = UserGroupColor.BLUE @@ -40,18 +37,11 @@ def user_group(client): def test_get_member(current_member, client): - current_member_eq = Member(client) - current_member_eq.get() + current_member_eq = Member(client=client).get() assert current_member_eq.id == current_member.id assert current_member_eq.email == current_member.email -def test_throw_error_cannot_get_user_group_with_invalid_id(client): - Member = UserGroup(Member=client, id=str(uuid4())) - with pytest.raises(ResourceNotFoundError): - Member.get() - - def test_throw_error_when_deleting_self(current_member, client): with pytest.raises(ValueError): current_member.delete() @@ -93,15 +83,17 @@ def test_update_member(client, test_member, project_pack, user_group): def test_get_members(test_member, current_member, client): - members = list(Member(client).get_members(search=current_member.email)) - assert current_member in members members = list( - Member(client).get_members(roles=[current_member.default_role]) + Member(client=client).get_members(search=current_member.email) ) assert current_member in members - members = list(Member(client).get_members()) - assert test_member in members + members = list( + Member(client=client).get_members(roles=[current_member.default_role]) + ) assert current_member in members + member_ids = [member.id for member in Member(client=client).get_members()] + assert test_member.id in member_ids + assert current_member.id in member_ids if __name__ == "__main__": From 2ced39c93b3def0aacc6ea0813cbc345f7dabbe5 Mon Sep 17 00:00:00 2001 From: Gabefire <33893811+Gabefire@users.noreply.github.com> Date: Thu, 10 Oct 2024 16:42:16 -0500 Subject: [PATCH 06/18] added better tests --- .../tests/integration/schema/test_members.py | 61 ++++++++++++------- 1 file changed, 40 insertions(+), 21 deletions(-) diff --git a/libs/labelbox/tests/integration/schema/test_members.py b/libs/labelbox/tests/integration/schema/test_members.py index 8dd7e6496..8fb7a551d 100644 --- a/libs/labelbox/tests/integration/schema/test_members.py +++ b/libs/labelbox/tests/integration/schema/test_members.py @@ -9,22 +9,13 @@ data = faker.Faker() -@pytest.fixture +@pytest.fixture(scope="session") def current_member(client): - yield Member(client=client).get() + current_member = Member(client=client).get() + yield current_member -@pytest.fixture -def test_member(client, current_member): - members = list(Member(client=client).get_members()) - test_member = None - for member in members: - if member.id != current_member.id: - test_member = member - return test_member - - -@pytest.fixture +@pytest.fixture(scope="session") def user_group(client): group_name = data.name() user_group = UserGroup(client=client) @@ -36,6 +27,21 @@ def user_group(client): user_group.delete() +@pytest.fixture(scope="module") +def test_member(client, current_member, user_group): + members = list(Member(client=client).get_members()) + test_member = None + for member in members: + if member.id != current_member.id: + test_member = member + test_member.user_group_ids.add(user_group.id) + updated_member = test_member.update() + yield updated_member + # remove from any user_groups as clean up + updated_member.user_group_ids = set() + updated_member.update() + + def test_get_member(current_member, client): current_member_eq = Member(client=client).get() assert current_member_eq.id == current_member.id @@ -58,6 +64,7 @@ def test_update_member(client, test_member, project_pack, user_group): test_member.user_group_ids.add(user_group.id) test_member.can_access_all_projects = False updated_member = test_member.update() + updated_member = updated_member.get() # Verify that the member was updated successfully assert test_member.email == updated_member.email @@ -69,12 +76,22 @@ def test_update_member(client, test_member, project_pack, user_group): assert user_group.id in updated_member.user_group_ids assert updated_member.default_role == reviewer_role + # update project role for one of the projects + project = project_pack[0] + project_membership = ProjectMembership( + project_id=project.uid, role=reviewer_role + ) + updated_member.project_memberships.add(project_membership) + updated_member = updated_member.update() + assert project_membership in updated_member.get().project_memberships + # Remove memberships and check if updated updated_member.project_memberships = set() updated_member.user_group_ids = set() updated_member.default_role = labeler_role updated_member.can_access_all_projects = True updated_member = updated_member.update() + updated_member = updated_member.get() assert updated_member.project_memberships == set() assert updated_member.user_group_ids == set() @@ -83,14 +100,16 @@ def test_update_member(client, test_member, project_pack, user_group): def test_get_members(test_member, current_member, client): - members = list( - Member(client=client).get_members(search=current_member.email) - ) - assert current_member in members - members = list( - Member(client=client).get_members(roles=[current_member.default_role]) - ) - assert current_member in members + member_ids = [ + member.id + for member in Member(client=client).get_members( + search=test_member.email + ) + ] + assert test_member.id in member_ids + + # TODO: can not search for roles or groups as it is too flaky will need to add in once user groups are harden + member_ids = [member.id for member in Member(client=client).get_members()] assert test_member.id in member_ids assert current_member.id in member_ids From e1cdd9dc63174e2732b27c78d58fe8ebbf692888 Mon Sep 17 00:00:00 2001 From: Gabefire <33893811+Gabefire@users.noreply.github.com> Date: Thu, 10 Oct 2024 19:52:14 -0500 Subject: [PATCH 07/18] added in admin_client --- .../tests/integration/schema/test_members.py | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/libs/labelbox/tests/integration/schema/test_members.py b/libs/labelbox/tests/integration/schema/test_members.py index 8fb7a551d..1dd388cd6 100644 --- a/libs/labelbox/tests/integration/schema/test_members.py +++ b/libs/labelbox/tests/integration/schema/test_members.py @@ -6,6 +6,9 @@ ) from labelbox.schema.user_group import UserGroup, UserGroupColor +from libs.labelbox.tests.conftest import AdminClient +import os + data = faker.Faker() @@ -28,18 +31,18 @@ def user_group(client): @pytest.fixture(scope="module") -def test_member(client, current_member, user_group): +def test_member(client, current_member, admin_client: AdminClient): + admin_client._create_user(client.get_organization().uid) members = list(Member(client=client).get_members()) test_member = None for member in members: if member.id != current_member.id: test_member = member - test_member.user_group_ids.add(user_group.id) - updated_member = test_member.update() - yield updated_member - # remove from any user_groups as clean up - updated_member.user_group_ids = set() - updated_member.update() + if test_member is None: + raise ValueError("Valid member was not found") + yield test_member + # delete member for clean up + test_member.delete() def test_get_member(current_member, client): @@ -48,11 +51,12 @@ def test_get_member(current_member, client): assert current_member_eq.email == current_member.email -def test_throw_error_when_deleting_self(current_member, client): +def test_throw_error_when_deleting_self(current_member): with pytest.raises(ValueError): current_member.delete() +@pytest.mark.skipif(condition=os.environ["LABELBOX_TEST_ENVIRON"] != "staging") def test_update_member(client, test_member, project_pack, user_group): labeler_role = client.get_roles()["LABELER"] reviewer_role = client.get_roles()["REVIEWER"] @@ -99,6 +103,7 @@ def test_update_member(client, test_member, project_pack, user_group): assert updated_member.can_access_all_projects +@pytest.mark.skipif(condition=os.environ["LABELBOX_TEST_ENVIRON"] != "staging") def test_get_members(test_member, current_member, client): member_ids = [ member.id @@ -115,6 +120,17 @@ def test_get_members(test_member, current_member, client): assert current_member.id in member_ids +@pytest.mark.skipif(condition=os.environ["LABELBOX_TEST_ENVIRON"] != "staging") +def test_delete_member(test_member, current_member, client): + email = test_member.email + id = test_member.id + test_member.delete() + member_ids = [ + member.id for member in Member(client=client).get_members(search=email) + ] + assert id not in member_ids + + if __name__ == "__main__": import subprocess From 673139ad60b7dda69e977a84f86e381cc82be4ca Mon Sep 17 00:00:00 2001 From: Gabefire <33893811+Gabefire@users.noreply.github.com> Date: Thu, 10 Oct 2024 20:07:58 -0500 Subject: [PATCH 08/18] Added reason for skipping --- .../tests/integration/schema/test_members.py | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/libs/labelbox/tests/integration/schema/test_members.py b/libs/labelbox/tests/integration/schema/test_members.py index 1dd388cd6..1e78e7ead 100644 --- a/libs/labelbox/tests/integration/schema/test_members.py +++ b/libs/labelbox/tests/integration/schema/test_members.py @@ -6,7 +6,7 @@ ) from labelbox.schema.user_group import UserGroup, UserGroupColor -from libs.labelbox.tests.conftest import AdminClient +from libs.labelbox.tests.conftest import AdminClient, Environ import os data = faker.Faker() @@ -32,8 +32,9 @@ def user_group(client): @pytest.fixture(scope="module") def test_member(client, current_member, admin_client: AdminClient): + admin_client = admin_client(Environ.STAGING) admin_client._create_user(client.get_organization().uid) - members = list(Member(client=client).get_members()) + members = list(Member(client=client).get_members(search="email@email.com")) test_member = None for member in members: if member.id != current_member.id: @@ -56,7 +57,10 @@ def test_throw_error_when_deleting_self(current_member): current_member.delete() -@pytest.mark.skipif(condition=os.environ["LABELBOX_TEST_ENVIRON"] != "staging") +@pytest.mark.skipif( + condition=os.environ["LABELBOX_TEST_ENVIRON"] != "staging", + reason="admin client only works in staging", +) def test_update_member(client, test_member, project_pack, user_group): labeler_role = client.get_roles()["LABELER"] reviewer_role = client.get_roles()["REVIEWER"] @@ -103,7 +107,10 @@ def test_update_member(client, test_member, project_pack, user_group): assert updated_member.can_access_all_projects -@pytest.mark.skipif(condition=os.environ["LABELBOX_TEST_ENVIRON"] != "staging") +@pytest.mark.skipif( + condition=os.environ["LABELBOX_TEST_ENVIRON"] != "staging", + reason="admin client only works in staging", +) def test_get_members(test_member, current_member, client): member_ids = [ member.id @@ -120,13 +127,16 @@ def test_get_members(test_member, current_member, client): assert current_member.id in member_ids -@pytest.mark.skipif(condition=os.environ["LABELBOX_TEST_ENVIRON"] != "staging") -def test_delete_member(test_member, current_member, client): +@pytest.mark.skipif( + condition=os.environ["LABELBOX_TEST_ENVIRON"] != "staging", + reason="admin client only works in staging", +) +def test_delete_member(test_member, current_member): email = test_member.email id = test_member.id test_member.delete() member_ids = [ - member.id for member in Member(client=client).get_members(search=email) + member.id for member in current_member.get_members(search=email) ] assert id not in member_ids From 1e8f3d1bc2624d66f1e9c89cd400eaff7aa6a85b Mon Sep 17 00:00:00 2001 From: Gabefire <33893811+Gabefire@users.noreply.github.com> Date: Thu, 10 Oct 2024 12:24:35 -0500 Subject: [PATCH 09/18] Add members model --- libs/labelbox/src/labelbox/schema/member.py | 419 ++++++++++++++++++++ 1 file changed, 419 insertions(+) create mode 100644 libs/labelbox/src/labelbox/schema/member.py diff --git a/libs/labelbox/src/labelbox/schema/member.py b/libs/labelbox/src/labelbox/schema/member.py new file mode 100644 index 000000000..41b808a28 --- /dev/null +++ b/libs/labelbox/src/labelbox/schema/member.py @@ -0,0 +1,419 @@ +from typing import Optional +from labelbox.exceptions import ( + MalformedQueryException, + ResourceNotFoundError, + UnprocessableEntityError, +) +from typing import Set, Iterator, Any +from pydantic import ( + Field, + field_validator, + model_serializer, + model_validator, + PrivateAttr, +) +from labelbox.utils import _CamelCaseMixin +from labelbox.schema.role import Role +from labelbox import Client + + +class ProjectMembership(_CamelCaseMixin): + project_id: str + role: Role + + def __hash__(self) -> int: + return self.project_id.__hash__() + + @model_serializer() + def serialize_model(self): + return { + "projectId": self.project_id, + "roleId": None if self.role.name is None else self.role.id, + } + + +class Member(_CamelCaseMixin): + """ + Represents a member in Labelbox. + + Attributes: + id (str, frozen): The ID of the member. Defaults to current users id. + updated_at (str, frozen): Last time member was updated. + created_at (str, frozen): When member was created. + email (str, frozen): Email of member. + name (str, frozen): Name of the member. + nickname (str, frozen): Nickname of the member. + picture (str, frozen): Picture of the member. + is_viewer (bool, frozen): Indicates if member is a viewer of org. + is_external_user (bool, frozen): Indicates if member is a external user of org + accessible_project_count (int, frozen): Them amount of projects the user can access + project_memberships (Set[ProjectMembership]): The current projects with role the user has access towards + can_access_all_projects (bool): If member can access all projects inside org. + default_role (Optional[Role]): Shows the members default role. None means the member does not have a default role. + user_group_ids (Set[str]): The user group ids the member is associated with. + client (Client): The Labelbox client object + + Methods: + get(self) -> "Member" + update(self) -> "Member" + delete(self) -> bool + get_user_groups(self, search: str, roles: Optional[list[Role]], group_ids: Optional[list[str]]) -> Iterator["Member"] + + Raises: + RuntimeError: If the experimental feature is not enabled in the client. + """ + + id: str = Field(frozen=True) + updated_at: str = Field(default="", frozen=True) + created_at: str = Field(default="", frozen=True) + email: str = Field(default="", frozen=True) + name: Optional[str] = Field(default=None, frozen=True) + nickname: Optional[str] = Field(default=None, frozen=True) + picture: Optional[str] = Field(default=None, frozen=True) + is_viewer: bool = Field(default=False, frozen=True) + is_external_user: bool = Field(default=False, frozen=True) + accessible_project_count: Optional[int] = Field(default=None, frozen=True) + project_memberships: Set[ProjectMembership] = Field(default=set()) + can_access_all_projects: bool = Field(default=False) + default_role: Optional[Role] = Field(default=None) + user_group_ids: Set[str] = Field(default=set()) + client: Client + _current_user_id: str = PrivateAttr() + + @field_validator("client", mode="before") + @classmethod + def experimental(cls, v: Client): + if not v.enable_experimental: + raise RuntimeError( + "Please enable experimental in client to use Members" + ) + return v + + @model_validator(mode="before") + def current_user(data: Any) -> Any: + data["_current_user_id"] = data["client"].get_user.uid + if "id" not in data: + data["id"] = data["_current_user_id"] + return data + + def get(self) -> "Member": + """ + Reloads the members information from the server. + + This method sends a GraphQL query to the server to fetch the latest information + about the members, The fetched + information is then used to update the corresponding attributes of the `Member` object. + + Returns: + Member: The updated `Member` object. + + Raises: + ResourceNotFoundError: If the query fails to fetch the member information. + """ + + if not self.id: + raise ValueError("Id is required") + query = """ + query GetMemberReworkPyApi($userId: ID!) { + user(where: { id: $userId }) { + id + updatedAt + createdAt + name + nickname + email + defaultRole { + id + name + } + picture + isViewer + isExternalUser + canAccessAllProjects + accessibleProjectCount + userGroups { + nodes { + id + } + } + __typename + } + projectMemberships(userId: $userId) { + role { + id + name + } + project { + id + } + } + } + """ + params = {"userId": self.id} + + result = self.client.execute(query, params, experimental=True) + if not result: + raise ResourceNotFoundError( + message="Failed to find user as user does not exist" + ) + + user = { + **result["user"], + "client": self.client, + "userGroups": [], + "projectMemberships": [], + "defaultRole": None, + } + model = self.model_copy(update=Member(**user).model_dump()) + + for userGroup in result["user"]["userGroups"]["nodes"]: + model.user_group_ids.add(userGroup["id"]) + + model.default_role = Role( + self.client, + field_values={ + "id": result["user"]["defaultRole"]["id"], + "name": result["user"]["defaultRole"]["name"], + }, + ) + + for project_membership in result["projectMemberships"]: + project_membership["role"] = Role( + self.client, + field_values={ + "id": project_membership["role"]["id"], + "name": project_membership["role"]["name"], + }, + ) + project_membership["projectId"] = project_membership["project"][ + "id" + ] + model.project_memberships.add( + ProjectMembership(**project_membership) + ) + + return model + + def update(self) -> "Member": + """ + Updates the member in Labelbox. + + Returns: + Member: The updated Member object. (self) + + Raises: + ResourceNotFoundError: If the update fails due to unknown member + UnprocessableEntityError: If the update fails due to a malformed input + """ + query = """ + mutation SetUserAccessPyApi($id: ID!, $roleId: ID!, $canAccessAllProjects: Boolean!, $groupIds: [String!], $projectMemberships: [ProjectMembershipsInput!]) { + setUserAccess( + where: {id: $id} + data: {roleId: $roleId, canAccessAllProjects: $canAccessAllProjects, groupIds: $groupIds, projectMemberships: $projectMemberships} + ) { + id + __typename + } + } + """ + params = { + "id": self.id, + "roleId": self.default_role.uid if self.default_role else None, + "canAccessAllProjects": self.can_access_all_projects, + "groupIds": self.user_group_ids, + "projectMemberships": [ + project_membership.model_dump() + for project_membership in self.project_memberships + ], + } + + try: + result = self.client.execute(query, params, experimental=True) + if not result: + raise ResourceNotFoundError( + message="Failed to update member as member does not exist" + ) + except MalformedQueryException as e: + raise UnprocessableEntityError("Failed to update member") from e + return self + + def delete(self) -> bool: + """ + Deletes the member from Labelbox. + + This method sends a mutation request to the Labelbox API to delete the member + with the specified ID. If the deletion is successful, it returns True. Otherwise, + it raises an UnprocessableEntityError and returns False. + + Returns: + bool: True if the member was successfully deleted, False otherwise. + + Raises: + ResourceNotFoundError: If the deletion of the member fails due to not existing + ValueError: If the member id is current users id. + """ + + query = """ + mutation DeleteMemberPyApi($id: ID!) { + updateUser(where: { id: $id }, data: { deleted: true }) { + id + deleted + } + } + """ + + if self.id == self.client.get_user().uid: + raise ValueError("Unable to delete self") + + params = {"id": self.id} + + result = self.client.execute(query, params, experimental=True) + if not result: + raise ResourceNotFoundError( + message="Failed to delete member as member does not exist" + ) + return result["data"]["updateUser"]["deleted"] + + def _get_project_memberships(self, user_id: str) -> Set[ProjectMembership]: + """ + Retrieves a set of project members from the given user_id. + + Args: + user_id (str): User id you are getting project memberships on. + + Returns: + set: A set of project members. + """ + query = """ + query GetMemberReworkPyApi($userId: ID!) { + projectMemberships(userId: $userId) { + role { + id + name + } + project { + id + } + } + } + """ + + params = {"userId": user_id} + result = self.client.execute(query, params) + + project_memberships = set() + for project_membership in result["projectMemberships"]: + project_membership["role"] = Role( + self.client, + field_values={ + "id": project_membership["role"]["id"], + "name": project_membership["role"]["name"], + }, + ) + project_membership["projectId"] = project_membership["project"][ + "id" + ] + + project_memberships.add(ProjectMembership(**project_membership)) + + return project_memberships + + def get_members( + self, + search: str = "", + roles: Optional[list[Role]] = None, + group_ids: Optional[list[str]] = None, + ) -> Iterator["Member"]: + """ + Gets all members in Labelbox. + + Args: + search (str): Email of user you are looking to receive. + roles (Optional[list[Role]]): Role of the users you are wanting to receive. + group_ids (Optional[list[str]]): List of user group ids. + + Returns: + Iterator[UserGroup]: An iterator over the user groups. + """ + query = """ + query GetOrganizationMembersPyApi( + $first: PageSize! + $skip: Int + $search: String + $roleIds: [String!] + $groupIds: [String!] + $complexFilters: [ComplexFilter!] + ) { + users( + where: { + email_contains: $search + organizationRoleId_in: $roleIds + groupId_in: $groupIds + complexFilters: $complexFilters + } + first: $first + skip: $skip + ) { + id + updatedAt + createdAt + name + nickname + email + defaultRole { + id + name + } + picture + isViewer + isExternalUser + canAccessAllProjects + accessibleProjectCount + userGroups { + nodes { + id + } + } + } + } + """ + previous_batch = 0 + batch_size = 100 + while True: + params = { + "first": batch_size, + "skip": previous_batch, + "search": search, + "roleIds": [role.uid for role in roles] if roles else None, + "groupIds": group_ids, + "complexFilters": None, + } + members = self.client.execute(query, params, experimental=True)[ + "users" + ] + + if not members: + return + yield + + for member in members: + member["projectMemberships"] = self._get_project_memberships( + member["id"] + ) + user_groups = set() + + for user_group in member["userGroups"]["nodes"]: + user_groups.add(user_group["id"]) + member["userGroupIds"] = user_groups + + member["defaultRole"] = Role( + self.client, + field_values={ + "id": member["defaultRole"]["id"], + "name": member["defaultRole"]["name"], + }, + ) + + yield Member(client=self.client, **member) + + previous_batch += batch_size From c76727aa728e168ad01b5c2bbf4981349969fcf8 Mon Sep 17 00:00:00 2001 From: Gabefire <33893811+Gabefire@users.noreply.github.com> Date: Thu, 10 Oct 2024 12:58:48 -0500 Subject: [PATCH 10/18] Typos --- libs/labelbox/src/labelbox/schema/member.py | 28 +++++++++++++++------ 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/libs/labelbox/src/labelbox/schema/member.py b/libs/labelbox/src/labelbox/schema/member.py index 41b808a28..ae03f3e4d 100644 --- a/libs/labelbox/src/labelbox/schema/member.py +++ b/libs/labelbox/src/labelbox/schema/member.py @@ -6,6 +6,7 @@ ) from typing import Set, Iterator, Any from pydantic import ( + ConfigDict, Field, field_validator, model_serializer, @@ -78,7 +79,12 @@ class Member(_CamelCaseMixin): default_role: Optional[Role] = Field(default=None) user_group_ids: Set[str] = Field(default=set()) client: Client - _current_user_id: str = PrivateAttr() + _current_user_id: str + + def __init__(self, **data): + """set private attribute""" + super().__init__(**data) + self._current_user_id = self.client.get_user().uid @field_validator("client", mode="before") @classmethod @@ -91,9 +97,8 @@ def experimental(cls, v: Client): @model_validator(mode="before") def current_user(data: Any) -> Any: - data["_current_user_id"] = data["client"].get_user.uid if "id" not in data: - data["id"] = data["_current_user_id"] + data["id"] = data["client"].get_user().uid return data def get(self) -> "Member": @@ -250,7 +255,7 @@ def delete(self) -> bool: Raises: ResourceNotFoundError: If the deletion of the member fails due to not existing - ValueError: If the member id is current users id. + ValueError: If the member id is current member id. """ query = """ @@ -262,7 +267,7 @@ def delete(self) -> bool: } """ - if self.id == self.client.get_user().uid: + if self.id == self._current_user_id: raise ValueError("Unable to delete self") params = {"id": self.id} @@ -276,13 +281,13 @@ def delete(self) -> bool: def _get_project_memberships(self, user_id: str) -> Set[ProjectMembership]: """ - Retrieves a set of project members from the given user_id. + Retrieves a set of project membership objects from the given user_id. Args: user_id (str): User id you are getting project memberships on. Returns: - set: A set of project members. + set: A set of project memberships. """ query = """ query GetMemberReworkPyApi($userId: ID!) { @@ -417,3 +422,12 @@ def get_members( yield Member(client=self.client, **member) previous_batch += batch_size + + +if __name__ == "__main__": + api_key = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJjbG9vcmRpaGUwMDkyMDcza2Nvcm5jajdnIiwib3JnYW5pemF0aW9uSWQiOiJjbG9vcmRpZ3cwMDkxMDcza2M2cG9oeWFiIiwiYXBpS2V5SWQiOiJjbTIzbDEyZncwYjN2MDd4ZDlqNmthMTBjIiwic2VjcmV0IjoiZWNlZTQ1YzQ1YmU1NTlkOGNkNDgxOTJkMDgxZGIyYjMiLCJpYXQiOjE3Mjg1ODE4OTUsImV4cCI6MjM1OTczMzg5NX0.xm3Yvg0Zub0HKJzSFf66iClaAvBxJpTBo4dxiruW7SA" + client = Client(api_key) + client.enable_experimental = True + member = Member(client=client) + print(member) + print(member.delete()) From 1475495b29df64a095305516d846be8261905302 Mon Sep 17 00:00:00 2001 From: Gabefire <33893811+Gabefire@users.noreply.github.com> Date: Thu, 10 Oct 2024 12:59:40 -0500 Subject: [PATCH 11/18] remove key --- libs/labelbox/src/labelbox/schema/member.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/libs/labelbox/src/labelbox/schema/member.py b/libs/labelbox/src/labelbox/schema/member.py index ae03f3e4d..6456d6925 100644 --- a/libs/labelbox/src/labelbox/schema/member.py +++ b/libs/labelbox/src/labelbox/schema/member.py @@ -422,12 +422,3 @@ def get_members( yield Member(client=self.client, **member) previous_batch += batch_size - - -if __name__ == "__main__": - api_key = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJjbG9vcmRpaGUwMDkyMDcza2Nvcm5jajdnIiwib3JnYW5pemF0aW9uSWQiOiJjbG9vcmRpZ3cwMDkxMDcza2M2cG9oeWFiIiwiYXBpS2V5SWQiOiJjbTIzbDEyZncwYjN2MDd4ZDlqNmthMTBjIiwic2VjcmV0IjoiZWNlZTQ1YzQ1YmU1NTlkOGNkNDgxOTJkMDgxZGIyYjMiLCJpYXQiOjE3Mjg1ODE4OTUsImV4cCI6MjM1OTczMzg5NX0.xm3Yvg0Zub0HKJzSFf66iClaAvBxJpTBo4dxiruW7SA" - client = Client(api_key) - client.enable_experimental = True - member = Member(client=client) - print(member) - print(member.delete()) From e25738e2ffb91187facf07704639330945f088b9 Mon Sep 17 00:00:00 2001 From: Gabefire <33893811+Gabefire@users.noreply.github.com> Date: Thu, 10 Oct 2024 14:30:47 -0500 Subject: [PATCH 12/18] Typos and wrote tests --- libs/labelbox/src/labelbox/schema/member.py | 13 ++- .../tests/integration/schema/test_members.py | 110 ++++++++++++++++++ 2 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 libs/labelbox/tests/integration/schema/test_members.py diff --git a/libs/labelbox/src/labelbox/schema/member.py b/libs/labelbox/src/labelbox/schema/member.py index 6456d6925..a412619e7 100644 --- a/libs/labelbox/src/labelbox/schema/member.py +++ b/libs/labelbox/src/labelbox/schema/member.py @@ -6,12 +6,10 @@ ) from typing import Set, Iterator, Any from pydantic import ( - ConfigDict, Field, field_validator, model_serializer, model_validator, - PrivateAttr, ) from labelbox.utils import _CamelCaseMixin from labelbox.schema.role import Role @@ -19,8 +17,15 @@ class ProjectMembership(_CamelCaseMixin): + """Represents a members project role + + Args: + project_id (str): id of the project you want the member included + role (Optional[Role]): Members role for the project. None represents the member having a default role. + """ + project_id: str - role: Role + role: Optional[Role] = None def __hash__(self) -> int: return self.project_id.__hash__() @@ -29,7 +34,7 @@ def __hash__(self) -> int: def serialize_model(self): return { "projectId": self.project_id, - "roleId": None if self.role.name is None else self.role.id, + "roleId": None if self.role is None else self.role.id, } diff --git a/libs/labelbox/tests/integration/schema/test_members.py b/libs/labelbox/tests/integration/schema/test_members.py new file mode 100644 index 000000000..2878f9041 --- /dev/null +++ b/libs/labelbox/tests/integration/schema/test_members.py @@ -0,0 +1,110 @@ +from uuid import uuid4 + +import faker +from labelbox.schema.member import Member, ProjectMembership +import pytest + +from labelbox.exceptions import ( + ResourceNotFoundError, +) +from labelbox.schema.user_group import UserGroup, UserGroupColor + +data = faker.Faker() + + +@pytest.fixture +def current_member(client): + yield Member(client=client).get() + + +@pytest.fixture +def member(client, current_member): + members = list(Member(client).get_members()) + test_member = None + for member in members: + if member.id != current_member.id: + test_member = member + return test_member + + +@pytest.fixture +def user_group(client): + group_name = data.name() + user_group = UserGroup(client) + user_group.name = group_name + user_group.color = UserGroupColor.BLUE + + yield user_group.create() + + user_group.delete() + + +def test_get_member(current_member, client): + current_member_eq = Member(client) + current_member_eq.get() + assert current_member_eq.id == current_member.id + assert current_member_eq.email == current_member.email + + +def test_throw_error_cannot_get_user_group_with_invalid_id(client): + Member = UserGroup(Member=client, id=str(uuid4())) + with pytest.raises(ResourceNotFoundError): + Member.get() + + +def test_throw_error_when_deleting_self(current_member, client): + with pytest.raises(ValueError): + current_member.delete() + + +def test_update_member(client, test_member, project_pack, user_group): + labeler_role = client.get_roles()["LABELER"] + reviewer_role = client.get_roles()["REVIEWER"] + for project in project_pack: + test_member.project_memberships.add( + ProjectMembership(project_id=project.uid, role=labeler_role) + ) + test_member.default_role = reviewer_role + test_member.user_group_ids.add(user_group.id) + test_member.can_access_all_projects = False + updated_member = test_member.update() + + # Verify that the member was updated successfully + assert test_member.email == updated_member.email + for project in project_pack: + assert ( + ProjectMembership(project_id=project.uid, role=labeler_role) + in updated_member.project_memberships + ) + assert user_group.id in updated_member.user_group_ids + assert updated_member.default_role == reviewer_role + + # Remove memberships and check if updated + updated_member.project_memberships = set() + updated_member.user_group_ids = set() + updated_member.default_role = labeler_role + updated_member.can_access_all_projects = True + updated_member = updated_member.update() + + assert updated_member.project_memberships == set() + assert updated_member.user_group_ids == set() + assert updated_member.default_role == labeler_role + assert updated_member.can_access_all_projects + + +def test_get_members(test_member, current_member, client): + members = list(Member(client).get_members(search=current_member.email)) + assert current_member in members + members = list( + Member(client).get_members(roles=[current_member.default_role]) + ) + assert current_member in members + members = list(Member(client).get_members()) + assert test_member in members + assert current_member in members + + +if __name__ == "__main__": + import subprocess + + subprocess.call(["pytest", "-v", __file__]) From 4753ecb8368edb31a0658635608f338930330e37 Mon Sep 17 00:00:00 2001 From: Gabefire <33893811+Gabefire@users.noreply.github.com> Date: Thu, 10 Oct 2024 15:36:30 -0500 Subject: [PATCH 13/18] Fixed tests made improvements --- libs/labelbox/src/labelbox/schema/member.py | 18 ++++++----- .../tests/integration/schema/test_members.py | 30 +++++++------------ 2 files changed, 22 insertions(+), 26 deletions(-) diff --git a/libs/labelbox/src/labelbox/schema/member.py b/libs/labelbox/src/labelbox/schema/member.py index a412619e7..d331c5abc 100644 --- a/libs/labelbox/src/labelbox/schema/member.py +++ b/libs/labelbox/src/labelbox/schema/member.py @@ -4,7 +4,7 @@ ResourceNotFoundError, UnprocessableEntityError, ) -from typing import Set, Iterator, Any +from typing import Set, Iterator, Any, List from pydantic import ( Field, field_validator, @@ -25,7 +25,7 @@ class ProjectMembership(_CamelCaseMixin): """ project_id: str - role: Optional[Role] = None + role: Optional[Role] = Field(default=None) def __hash__(self) -> int: return self.project_id.__hash__() @@ -34,7 +34,7 @@ def __hash__(self) -> int: def serialize_model(self): return { "projectId": self.project_id, - "roleId": None if self.role is None else self.role.id, + "roleId": None if self.role is None else self.role.uid, } @@ -214,6 +214,7 @@ def update(self) -> "Member": Raises: ResourceNotFoundError: If the update fails due to unknown member UnprocessableEntityError: If the update fails due to a malformed input + ValueError: If the member id is current member id. """ query = """ mutation SetUserAccessPyApi($id: ID!, $roleId: ID!, $canAccessAllProjects: Boolean!, $groupIds: [String!], $projectMemberships: [ProjectMembershipsInput!]) { @@ -230,13 +231,16 @@ def update(self) -> "Member": "id": self.id, "roleId": self.default_role.uid if self.default_role else None, "canAccessAllProjects": self.can_access_all_projects, - "groupIds": self.user_group_ids, + "groupIds": list(self.user_group_ids), "projectMemberships": [ project_membership.model_dump() for project_membership in self.project_memberships ], } + if self.id == self._current_user_id: + raise ValueError("Unable to update self") + try: result = self.client.execute(query, params, experimental=True) if not result: @@ -282,7 +286,7 @@ def delete(self) -> bool: raise ResourceNotFoundError( message="Failed to delete member as member does not exist" ) - return result["data"]["updateUser"]["deleted"] + return result["updateUser"]["deleted"] def _get_project_memberships(self, user_id: str) -> Set[ProjectMembership]: """ @@ -331,8 +335,8 @@ def _get_project_memberships(self, user_id: str) -> Set[ProjectMembership]: def get_members( self, search: str = "", - roles: Optional[list[Role]] = None, - group_ids: Optional[list[str]] = None, + roles: Optional[List[Role]] = None, + group_ids: Optional[List[str]] = None, ) -> Iterator["Member"]: """ Gets all members in Labelbox. diff --git a/libs/labelbox/tests/integration/schema/test_members.py b/libs/labelbox/tests/integration/schema/test_members.py index 2878f9041..8dd7e6496 100644 --- a/libs/labelbox/tests/integration/schema/test_members.py +++ b/libs/labelbox/tests/integration/schema/test_members.py @@ -1,9 +1,6 @@ -from uuid import uuid4 - import faker from labelbox.schema.member import Member, ProjectMembership import pytest - from labelbox.exceptions import ( ResourceNotFoundError, ) @@ -18,8 +15,8 @@ def current_member(client): @pytest.fixture -def member(client, current_member): - members = list(Member(client).get_members()) +def test_member(client, current_member): + members = list(Member(client=client).get_members()) test_member = None for member in members: if member.id != current_member.id: @@ -30,7 +27,7 @@ def member(client, current_member): @pytest.fixture def user_group(client): group_name = data.name() - user_group = UserGroup(client) + user_group = UserGroup(client=client) user_group.name = group_name user_group.color = UserGroupColor.BLUE @@ -40,18 +37,11 @@ def user_group(client): def test_get_member(current_member, client): - current_member_eq = Member(client) - current_member_eq.get() + current_member_eq = Member(client=client).get() assert current_member_eq.id == current_member.id assert current_member_eq.email == current_member.email -def test_throw_error_cannot_get_user_group_with_invalid_id(client): - Member = UserGroup(Member=client, id=str(uuid4())) - with pytest.raises(ResourceNotFoundError): - Member.get() - - def test_throw_error_when_deleting_self(current_member, client): with pytest.raises(ValueError): current_member.delete() @@ -93,15 +83,17 @@ def test_update_member(client, test_member, project_pack, user_group): def test_get_members(test_member, current_member, client): - members = list(Member(client).get_members(search=current_member.email)) - assert current_member in members members = list( - Member(client).get_members(roles=[current_member.default_role]) + Member(client=client).get_members(search=current_member.email) ) assert current_member in members - members = list(Member(client).get_members()) - assert test_member in members + members = list( + Member(client=client).get_members(roles=[current_member.default_role]) + ) assert current_member in members + member_ids = [member.id for member in Member(client=client).get_members()] + assert test_member.id in member_ids + assert current_member.id in member_ids if __name__ == "__main__": From bc91543153655a5eee063355b84959e3eb224c5b Mon Sep 17 00:00:00 2001 From: Gabefire <33893811+Gabefire@users.noreply.github.com> Date: Thu, 10 Oct 2024 16:42:16 -0500 Subject: [PATCH 14/18] added better tests --- .../tests/integration/schema/test_members.py | 61 ++++++++++++------- 1 file changed, 40 insertions(+), 21 deletions(-) diff --git a/libs/labelbox/tests/integration/schema/test_members.py b/libs/labelbox/tests/integration/schema/test_members.py index 8dd7e6496..8fb7a551d 100644 --- a/libs/labelbox/tests/integration/schema/test_members.py +++ b/libs/labelbox/tests/integration/schema/test_members.py @@ -9,22 +9,13 @@ data = faker.Faker() -@pytest.fixture +@pytest.fixture(scope="session") def current_member(client): - yield Member(client=client).get() + current_member = Member(client=client).get() + yield current_member -@pytest.fixture -def test_member(client, current_member): - members = list(Member(client=client).get_members()) - test_member = None - for member in members: - if member.id != current_member.id: - test_member = member - return test_member - - -@pytest.fixture +@pytest.fixture(scope="session") def user_group(client): group_name = data.name() user_group = UserGroup(client=client) @@ -36,6 +27,21 @@ def user_group(client): user_group.delete() +@pytest.fixture(scope="module") +def test_member(client, current_member, user_group): + members = list(Member(client=client).get_members()) + test_member = None + for member in members: + if member.id != current_member.id: + test_member = member + test_member.user_group_ids.add(user_group.id) + updated_member = test_member.update() + yield updated_member + # remove from any user_groups as clean up + updated_member.user_group_ids = set() + updated_member.update() + + def test_get_member(current_member, client): current_member_eq = Member(client=client).get() assert current_member_eq.id == current_member.id @@ -58,6 +64,7 @@ def test_update_member(client, test_member, project_pack, user_group): test_member.user_group_ids.add(user_group.id) test_member.can_access_all_projects = False updated_member = test_member.update() + updated_member = updated_member.get() # Verify that the member was updated successfully assert test_member.email == updated_member.email @@ -69,12 +76,22 @@ def test_update_member(client, test_member, project_pack, user_group): assert user_group.id in updated_member.user_group_ids assert updated_member.default_role == reviewer_role + # update project role for one of the projects + project = project_pack[0] + project_membership = ProjectMembership( + project_id=project.uid, role=reviewer_role + ) + updated_member.project_memberships.add(project_membership) + updated_member = updated_member.update() + assert project_membership in updated_member.get().project_memberships + # Remove memberships and check if updated updated_member.project_memberships = set() updated_member.user_group_ids = set() updated_member.default_role = labeler_role updated_member.can_access_all_projects = True updated_member = updated_member.update() + updated_member = updated_member.get() assert updated_member.project_memberships == set() assert updated_member.user_group_ids == set() @@ -83,14 +100,16 @@ def test_update_member(client, test_member, project_pack, user_group): def test_get_members(test_member, current_member, client): - members = list( - Member(client=client).get_members(search=current_member.email) - ) - assert current_member in members - members = list( - Member(client=client).get_members(roles=[current_member.default_role]) - ) - assert current_member in members + member_ids = [ + member.id + for member in Member(client=client).get_members( + search=test_member.email + ) + ] + assert test_member.id in member_ids + + # TODO: can not search for roles or groups as it is too flaky will need to add in once user groups are harden + member_ids = [member.id for member in Member(client=client).get_members()] assert test_member.id in member_ids assert current_member.id in member_ids From ae7faa390f7cbcee862bcc75bf4d22b5ef32c1df Mon Sep 17 00:00:00 2001 From: Gabefire <33893811+Gabefire@users.noreply.github.com> Date: Thu, 10 Oct 2024 19:52:14 -0500 Subject: [PATCH 15/18] added in admin_client --- .../tests/integration/schema/test_members.py | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/libs/labelbox/tests/integration/schema/test_members.py b/libs/labelbox/tests/integration/schema/test_members.py index 8fb7a551d..1dd388cd6 100644 --- a/libs/labelbox/tests/integration/schema/test_members.py +++ b/libs/labelbox/tests/integration/schema/test_members.py @@ -6,6 +6,9 @@ ) from labelbox.schema.user_group import UserGroup, UserGroupColor +from libs.labelbox.tests.conftest import AdminClient +import os + data = faker.Faker() @@ -28,18 +31,18 @@ def user_group(client): @pytest.fixture(scope="module") -def test_member(client, current_member, user_group): +def test_member(client, current_member, admin_client: AdminClient): + admin_client._create_user(client.get_organization().uid) members = list(Member(client=client).get_members()) test_member = None for member in members: if member.id != current_member.id: test_member = member - test_member.user_group_ids.add(user_group.id) - updated_member = test_member.update() - yield updated_member - # remove from any user_groups as clean up - updated_member.user_group_ids = set() - updated_member.update() + if test_member is None: + raise ValueError("Valid member was not found") + yield test_member + # delete member for clean up + test_member.delete() def test_get_member(current_member, client): @@ -48,11 +51,12 @@ def test_get_member(current_member, client): assert current_member_eq.email == current_member.email -def test_throw_error_when_deleting_self(current_member, client): +def test_throw_error_when_deleting_self(current_member): with pytest.raises(ValueError): current_member.delete() +@pytest.mark.skipif(condition=os.environ["LABELBOX_TEST_ENVIRON"] != "staging") def test_update_member(client, test_member, project_pack, user_group): labeler_role = client.get_roles()["LABELER"] reviewer_role = client.get_roles()["REVIEWER"] @@ -99,6 +103,7 @@ def test_update_member(client, test_member, project_pack, user_group): assert updated_member.can_access_all_projects +@pytest.mark.skipif(condition=os.environ["LABELBOX_TEST_ENVIRON"] != "staging") def test_get_members(test_member, current_member, client): member_ids = [ member.id @@ -115,6 +120,17 @@ def test_get_members(test_member, current_member, client): assert current_member.id in member_ids +@pytest.mark.skipif(condition=os.environ["LABELBOX_TEST_ENVIRON"] != "staging") +def test_delete_member(test_member, current_member, client): + email = test_member.email + id = test_member.id + test_member.delete() + member_ids = [ + member.id for member in Member(client=client).get_members(search=email) + ] + assert id not in member_ids + + if __name__ == "__main__": import subprocess From 34d15b5e828aa340fca9561aa5736ab2e007b7d3 Mon Sep 17 00:00:00 2001 From: Gabefire <33893811+Gabefire@users.noreply.github.com> Date: Thu, 10 Oct 2024 20:07:58 -0500 Subject: [PATCH 16/18] Added reason for skipping --- .../tests/integration/schema/test_members.py | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/libs/labelbox/tests/integration/schema/test_members.py b/libs/labelbox/tests/integration/schema/test_members.py index 1dd388cd6..1e78e7ead 100644 --- a/libs/labelbox/tests/integration/schema/test_members.py +++ b/libs/labelbox/tests/integration/schema/test_members.py @@ -6,7 +6,7 @@ ) from labelbox.schema.user_group import UserGroup, UserGroupColor -from libs.labelbox.tests.conftest import AdminClient +from libs.labelbox.tests.conftest import AdminClient, Environ import os data = faker.Faker() @@ -32,8 +32,9 @@ def user_group(client): @pytest.fixture(scope="module") def test_member(client, current_member, admin_client: AdminClient): + admin_client = admin_client(Environ.STAGING) admin_client._create_user(client.get_organization().uid) - members = list(Member(client=client).get_members()) + members = list(Member(client=client).get_members(search="email@email.com")) test_member = None for member in members: if member.id != current_member.id: @@ -56,7 +57,10 @@ def test_throw_error_when_deleting_self(current_member): current_member.delete() -@pytest.mark.skipif(condition=os.environ["LABELBOX_TEST_ENVIRON"] != "staging") +@pytest.mark.skipif( + condition=os.environ["LABELBOX_TEST_ENVIRON"] != "staging", + reason="admin client only works in staging", +) def test_update_member(client, test_member, project_pack, user_group): labeler_role = client.get_roles()["LABELER"] reviewer_role = client.get_roles()["REVIEWER"] @@ -103,7 +107,10 @@ def test_update_member(client, test_member, project_pack, user_group): assert updated_member.can_access_all_projects -@pytest.mark.skipif(condition=os.environ["LABELBOX_TEST_ENVIRON"] != "staging") +@pytest.mark.skipif( + condition=os.environ["LABELBOX_TEST_ENVIRON"] != "staging", + reason="admin client only works in staging", +) def test_get_members(test_member, current_member, client): member_ids = [ member.id @@ -120,13 +127,16 @@ def test_get_members(test_member, current_member, client): assert current_member.id in member_ids -@pytest.mark.skipif(condition=os.environ["LABELBOX_TEST_ENVIRON"] != "staging") -def test_delete_member(test_member, current_member, client): +@pytest.mark.skipif( + condition=os.environ["LABELBOX_TEST_ENVIRON"] != "staging", + reason="admin client only works in staging", +) +def test_delete_member(test_member, current_member): email = test_member.email id = test_member.id test_member.delete() member_ids = [ - member.id for member in Member(client=client).get_members(search=email) + member.id for member in current_member.get_members(search=email) ] assert id not in member_ids From 358ab50d5cad50d8196fb8cb7698cf31dd90d7ae Mon Sep 17 00:00:00 2001 From: Gabefire <33893811+Gabefire@users.noreply.github.com> Date: Mon, 14 Oct 2024 12:17:07 -0500 Subject: [PATCH 17/18] Feedback --- libs/labelbox/src/labelbox/schema/member.py | 42 ++++++++++----------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/libs/labelbox/src/labelbox/schema/member.py b/libs/labelbox/src/labelbox/schema/member.py index d331c5abc..eb59e04e3 100644 --- a/libs/labelbox/src/labelbox/schema/member.py +++ b/libs/labelbox/src/labelbox/schema/member.py @@ -1,8 +1,6 @@ from typing import Optional -from labelbox.exceptions import ( - MalformedQueryException, +from lbox.exceptions import ( ResourceNotFoundError, - UnprocessableEntityError, ) from typing import Set, Iterator, Any, List from pydantic import ( @@ -161,11 +159,12 @@ def get(self) -> "Member": """ params = {"userId": self.id} - result = self.client.execute(query, params, experimental=True) - if not result: - raise ResourceNotFoundError( - message="Failed to find user as user does not exist" - ) + result = self.client.execute( + query, + params, + experimental=True, + raise_return_resource_not_found=True, + ) user = { **result["user"], @@ -241,14 +240,13 @@ def update(self) -> "Member": if self.id == self._current_user_id: raise ValueError("Unable to update self") - try: - result = self.client.execute(query, params, experimental=True) - if not result: - raise ResourceNotFoundError( - message="Failed to update member as member does not exist" - ) - except MalformedQueryException as e: - raise UnprocessableEntityError("Failed to update member") from e + self.client.execute( + query, + params, + experimental=True, + raise_return_resource_not_found=True, + ) + return self def delete(self) -> bool: @@ -281,11 +279,13 @@ def delete(self) -> bool: params = {"id": self.id} - result = self.client.execute(query, params, experimental=True) - if not result: - raise ResourceNotFoundError( - message="Failed to delete member as member does not exist" - ) + result = self.client.execute( + query, + params, + experimental=True, + raise_return_resource_not_found=True, + ) + return result["updateUser"]["deleted"] def _get_project_memberships(self, user_id: str) -> Set[ProjectMembership]: From 6389ce306683aad5650e399ff8224d266b3436d3 Mon Sep 17 00:00:00 2001 From: Gabefire <33893811+Gabefire@users.noreply.github.com> Date: Mon, 14 Oct 2024 12:43:13 -0500 Subject: [PATCH 18/18] removed lbox --- libs/labelbox/tests/integration/schema/test_members.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/libs/labelbox/tests/integration/schema/test_members.py b/libs/labelbox/tests/integration/schema/test_members.py index 1e78e7ead..456326eca 100644 --- a/libs/labelbox/tests/integration/schema/test_members.py +++ b/libs/labelbox/tests/integration/schema/test_members.py @@ -1,9 +1,6 @@ import faker from labelbox.schema.member import Member, ProjectMembership import pytest -from labelbox.exceptions import ( - ResourceNotFoundError, -) from labelbox.schema.user_group import UserGroup, UserGroupColor from libs.labelbox.tests.conftest import AdminClient, Environ