|
10 | 10 | from dataclasses import dataclass |
11 | 11 | from enum import Enum |
12 | 12 | import uuid |
13 | | -from typing import Any, Dict, Iterator, List, Optional, Set |
| 13 | +from typing import Any, Dict, Iterable, Iterator, List, Optional, Set, Union |
14 | 14 |
|
15 | 15 | from lbox.exceptions import ( |
16 | 16 | InvalidQueryError, |
|
23 | 23 | from pydantic import BaseModel, ConfigDict, Field |
24 | 24 |
|
25 | 25 | from labelbox import Client |
| 26 | +from labelbox.pagination import PaginatedCollection |
26 | 27 | from labelbox.schema.media_type import MediaType |
27 | 28 | from labelbox.schema.ontology_kind import EditorTaskType |
28 | 29 | from labelbox.schema.project import Project |
29 | 30 | from labelbox.schema.role import Role |
30 | 31 | from labelbox.schema.user import User |
| 32 | +from labelbox.schema.user_group_audit import ( |
| 33 | + DateTimeLike, |
| 34 | + UserGroupAuditActorKind, |
| 35 | + UserGroupAuditEventType, |
| 36 | + get_user_group_audit_events, |
| 37 | +) |
31 | 38 |
|
32 | 39 | # Constants for UserGroup role restrictions |
33 | 40 | INVALID_USERGROUP_ROLES = frozenset(["NONE", "TENANT_ADMIN"]) |
@@ -439,6 +446,276 @@ def delete(self) -> bool: |
439 | 446 | ) |
440 | 447 | return result["deleteUserGroup"]["success"] |
441 | 448 |
|
| 449 | + def add_members( |
| 450 | + self, |
| 451 | + members: Iterable[UserGroupMember], |
| 452 | + notify_members: bool = False, |
| 453 | + ) -> List[str]: |
| 454 | + """Add people to the group, each with a role in it. |
| 455 | +
|
| 456 | + Unlike :meth:`update`, this only adds: the group's other members and |
| 457 | + its projects are left as they are. Each person gains access to every |
| 458 | + project the group is attached to. Recorded in the group audit log as |
| 459 | + an API change made for the API key's owner. |
| 460 | +
|
| 461 | + Args: |
| 462 | + members: The people to add, each with their role in the group. |
| 463 | + notify_members: Email the added people. Defaults to False. |
| 464 | +
|
| 465 | + Returns: |
| 466 | + IDs of the people added. People already in the group are left |
| 467 | + unchanged and not listed. |
| 468 | +
|
| 469 | + Raises: |
| 470 | + ValueError: If group ID is not set, no members are given, or a |
| 471 | + person appears more than once. |
| 472 | + UnprocessableEntityError: If the people could not be added. |
| 473 | + """ |
| 474 | + if not self.id: |
| 475 | + raise ValueError("Group id is required") |
| 476 | + members = list(members) |
| 477 | + if not members: |
| 478 | + raise ValueError("At least one member is required") |
| 479 | + user_ids = [member.user.uid for member in members] |
| 480 | + if len(set(user_ids)) != len(user_ids): |
| 481 | + raise ValueError("Each user may appear only once") |
| 482 | + |
| 483 | + query = """ |
| 484 | + mutation AddUserGroupMembersPyApi($id: ID!, $userRoles: [UserRoleInput!], $notifyMembers: Boolean) { |
| 485 | + addUsersToGroupsV2( |
| 486 | + where: { ids: [$id] } |
| 487 | + data: { userRoles: $userRoles, notifyMembers: $notifyMembers } |
| 488 | + ) { |
| 489 | + results { |
| 490 | + success |
| 491 | + added |
| 492 | + error |
| 493 | + batchJob { batchJobId isComplete } |
| 494 | + } |
| 495 | + } |
| 496 | + } |
| 497 | + """ |
| 498 | + result = self.client.execute( |
| 499 | + query, |
| 500 | + { |
| 501 | + "id": self.id, |
| 502 | + "userRoles": [ |
| 503 | + {"userId": member.user.uid, "roleId": member.role.uid} |
| 504 | + for member in members |
| 505 | + ], |
| 506 | + "notifyMembers": notify_members, |
| 507 | + }, |
| 508 | + )["addUsersToGroupsV2"]["results"][0] |
| 509 | + if not result["success"]: |
| 510 | + raise UnprocessableEntityError( |
| 511 | + f"Cannot add members to user group: {result.get('error')}" |
| 512 | + ) |
| 513 | + |
| 514 | + # Large additions finish in batches the caller has to drive. |
| 515 | + batch_job = result.get("batchJob") |
| 516 | + while batch_job and not batch_job["isComplete"]: |
| 517 | + batch_job = self.client.execute( |
| 518 | + """ |
| 519 | + mutation ContinueAddUserGroupMembersPyApi($batchJobId: ID!) { |
| 520 | + continueAddUsersToGroupBatch(batchJobId: $batchJobId) { |
| 521 | + batchJob { batchJobId isComplete } |
| 522 | + } |
| 523 | + } |
| 524 | + """, |
| 525 | + {"batchJobId": batch_job["batchJobId"]}, |
| 526 | + )["continueAddUsersToGroupBatch"]["batchJob"] |
| 527 | + |
| 528 | + return result.get("added") or [] |
| 529 | + |
| 530 | + def remove_members( |
| 531 | + self, |
| 532 | + users: Iterable[User], |
| 533 | + notify_users: bool = True, |
| 534 | + reason: Optional[str] = None, |
| 535 | + notification_reason: Optional[str] = None, |
| 536 | + ) -> bool: |
| 537 | + """Remove people from the group. |
| 538 | +
|
| 539 | + Each person loses the access they had through the group to every |
| 540 | + project it is attached to. Recorded in the group audit log as an API |
| 541 | + change made for the API key's owner. |
| 542 | +
|
| 543 | + Args: |
| 544 | + users: The people to remove. |
| 545 | + notify_users: Email the people who lose project access. Defaults |
| 546 | + to True. |
| 547 | + reason: Note for your team, shown if the people are added to a |
| 548 | + group again. Never sent to them. |
| 549 | + notification_reason: Included in the email to the people who lose |
| 550 | + project access, when ``notify_users`` is True. |
| 551 | +
|
| 552 | + Returns: |
| 553 | + True if the people were removed. |
| 554 | +
|
| 555 | + Raises: |
| 556 | + ValueError: If group ID is not set or no users are given. |
| 557 | + ResourceNotFoundError: If the group is not found. |
| 558 | + """ |
| 559 | + if not self.id: |
| 560 | + raise ValueError("Group id is required") |
| 561 | + user_ids = list(dict.fromkeys(user.uid for user in users)) |
| 562 | + if not user_ids: |
| 563 | + raise ValueError("At least one user is required") |
| 564 | + |
| 565 | + query = """ |
| 566 | + mutation RemoveUserGroupMembersPyApi($id: ID!, $data: RemoveUsersFromGroupInput!) { |
| 567 | + removeUsersFromGroupV2(where: { id: $id }, data: $data) { |
| 568 | + success |
| 569 | + } |
| 570 | + } |
| 571 | + """ |
| 572 | + data: Dict[str, Any] = { |
| 573 | + "userIds": user_ids, |
| 574 | + "notifyUsers": notify_users, |
| 575 | + } |
| 576 | + if reason is not None: |
| 577 | + data["reason"] = reason |
| 578 | + if notification_reason is not None: |
| 579 | + data["notificationReason"] = notification_reason |
| 580 | + |
| 581 | + result = self.client.execute( |
| 582 | + query, |
| 583 | + {"id": self.id, "data": data}, |
| 584 | + raise_return_resource_not_found=True, |
| 585 | + ) |
| 586 | + return result["removeUsersFromGroupV2"]["success"] |
| 587 | + |
| 588 | + def list_members(self) -> List[UserGroupMember]: |
| 589 | + """Read the group's current members from the server. |
| 590 | +
|
| 591 | + Unlike :meth:`get`, this leaves the object's other fields, including |
| 592 | + any unsaved changes, as they are. |
| 593 | +
|
| 594 | + Returns: |
| 595 | + The members, each with their role in the group. |
| 596 | +
|
| 597 | + Raises: |
| 598 | + ValueError: If group ID is not set. |
| 599 | + ResourceNotFoundError: If the group is not found. |
| 600 | + """ |
| 601 | + if not self.id: |
| 602 | + raise ValueError("Group id is required") |
| 603 | + |
| 604 | + query = """ |
| 605 | + query ListUserGroupMembersPyApi($id: ID!) { |
| 606 | + userGroupV2(where: {id: $id}) { |
| 607 | + members { |
| 608 | + nodes { id email } |
| 609 | + userGroupRoles { userId roleId } |
| 610 | + } |
| 611 | + } |
| 612 | + } |
| 613 | + """ |
| 614 | + result = self.client.execute(query, {"id": self.id}) |
| 615 | + if not result or not result.get("userGroupV2"): |
| 616 | + raise ResourceNotFoundError(message="User group not found") |
| 617 | + return list(self._get_members_set(result["userGroupV2"]["members"])) |
| 618 | + |
| 619 | + def audit_events( |
| 620 | + self, |
| 621 | + member: Optional[Union[User, str]] = None, |
| 622 | + actor: Optional[Union[User, str]] = None, |
| 623 | + actor_kind: Optional[Union[UserGroupAuditActorKind, str]] = None, |
| 624 | + types: Optional[Iterable[Union[UserGroupAuditEventType, str]]] = None, |
| 625 | + project: Optional[Union[Project, str]] = None, |
| 626 | + occurred_after: Optional[DateTimeLike] = None, |
| 627 | + occurred_before: Optional[DateTimeLike] = None, |
| 628 | + ) -> PaginatedCollection: |
| 629 | + """Read this group's audit log, newest first. |
| 630 | +
|
| 631 | + See :meth:`get_audit_events` for what the log records, who may read |
| 632 | + it and what each filter does. |
| 633 | +
|
| 634 | + Returns: |
| 635 | + PaginatedCollection of UserGroupAuditEvent. |
| 636 | +
|
| 637 | + Raises: |
| 638 | + ValueError: If group ID is not set or a filter is not valid. |
| 639 | +
|
| 640 | + Example: |
| 641 | + >>> for event in group.audit_events(types=["MEMBER_REMOVED"]): |
| 642 | + >>> print(event.occurred_at, event.member_user_id, |
| 643 | + >>> event.actor_kind, event.channel, event.actor_user_id) |
| 644 | + """ |
| 645 | + if not self.id: |
| 646 | + raise ValueError("Group id is required") |
| 647 | + return get_user_group_audit_events( |
| 648 | + self.client, |
| 649 | + group=self.id, |
| 650 | + member=member, |
| 651 | + actor=actor, |
| 652 | + actor_kind=actor_kind, |
| 653 | + types=types, |
| 654 | + project=project, |
| 655 | + occurred_after=occurred_after, |
| 656 | + occurred_before=occurred_before, |
| 657 | + ) |
| 658 | + |
| 659 | + @staticmethod |
| 660 | + def get_audit_events( |
| 661 | + client: Client, |
| 662 | + group: Optional[Union[UserGroup, str]] = None, |
| 663 | + member: Optional[Union[User, str]] = None, |
| 664 | + actor: Optional[Union[User, str]] = None, |
| 665 | + actor_kind: Optional[Union[UserGroupAuditActorKind, str]] = None, |
| 666 | + types: Optional[Iterable[Union[UserGroupAuditEventType, str]]] = None, |
| 667 | + project: Optional[Union[Project, str]] = None, |
| 668 | + occurred_after: Optional[DateTimeLike] = None, |
| 669 | + occurred_before: Optional[DateTimeLike] = None, |
| 670 | + ) -> PaginatedCollection: |
| 671 | + """Read the user group audit log, newest first. |
| 672 | +
|
| 673 | + The log records who added people to groups, removed them or changed |
| 674 | + their roles, attached groups to projects or detached them, and |
| 675 | + deleted groups, and whether a person or an automation did it. Only |
| 676 | + groups you can manage are included; without ``group``, every group |
| 677 | + you can manage, including deleted ones. The log starts when it |
| 678 | + shipped; earlier changes are not in it. |
| 679 | +
|
| 680 | + Args: |
| 681 | + client: Labelbox client for API communication. |
| 682 | + group: Only events of this group. |
| 683 | + member: Only events about this person: added, removed or given a |
| 684 | + new role. |
| 685 | + actor: Only changes this person made, or automations acting for |
| 686 | + them. |
| 687 | + actor_kind: Only changes made by a person (``"PERSON"``), or only |
| 688 | + by automations (``"AUTOMATION"``). |
| 689 | + types: Only these kinds of events. |
| 690 | + project: Only events of this project: a group attached to or |
| 691 | + detached from it. |
| 692 | + occurred_after: Only events at or after this moment. |
| 693 | + occurred_before: Only events before this moment. |
| 694 | +
|
| 695 | + Returns: |
| 696 | + PaginatedCollection of UserGroupAuditEvent. |
| 697 | +
|
| 698 | + Raises: |
| 699 | + ValueError: If a filter is not valid. |
| 700 | +
|
| 701 | + Example: |
| 702 | + >>> events = UserGroup.get_audit_events( |
| 703 | + >>> client, member="cl1...", occurred_after="2026-10-01") |
| 704 | + >>> for event in events: |
| 705 | + >>> print(event.occurred_at, event.type, event.group_name) |
| 706 | + """ |
| 707 | + return get_user_group_audit_events( |
| 708 | + client, |
| 709 | + group=group, |
| 710 | + member=member, |
| 711 | + actor=actor, |
| 712 | + actor_kind=actor_kind, |
| 713 | + types=types, |
| 714 | + project=project, |
| 715 | + occurred_after=occurred_after, |
| 716 | + occurred_before=occurred_before, |
| 717 | + ) |
| 718 | + |
442 | 719 | @staticmethod |
443 | 720 | def get_user_groups( |
444 | 721 | client: Client, page_size: int = 100 |
|
0 commit comments