Skip to content

Commit 2a47bfa

Browse files
Gabefireclaude
andcommitted
[PLT-4662] Add, remove and list user group members, and read the group audit log
UserGroup.add_members, remove_members and list_members change or read one group's members without replacing the whole group the way update() does. UserGroup.audit_events and UserGroup.get_audit_events read the group audit log: who added, removed or changed the role of a member, attached a group to a project or detached it, or deleted a group, and whether a person or an automation did it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 1970c2c commit 2a47bfa

6 files changed

Lines changed: 838 additions & 1 deletion

File tree

‎docs/labelbox/index.rst‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,5 +60,6 @@ Labelbox Python SDK Documentation
6060
task-assignment-status
6161
task-queue
6262
user
63+
user-group-audit
6364
user-group-v2
6465
webhook

‎docs/labelbox/user-group-audit.rst‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
User Group Audit
2+
===============================================================================================
3+
4+
.. automodule:: labelbox.schema.user_group_audit
5+
:members: UserGroupAuditEvent, UserGroupAuditEventType, UserGroupAuditActorKind, UserGroupAuditChannel
6+
:show-inheritance:

‎libs/labelbox/src/labelbox/__init__.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,12 @@
117117
LabelVersionAction,
118118
LabelVersionStage,
119119
)
120+
from labelbox.schema.user_group_audit import (
121+
UserGroupAuditActorKind,
122+
UserGroupAuditChannel,
123+
UserGroupAuditEvent,
124+
UserGroupAuditEventType,
125+
)
120126
from labelbox.schema.performance import (
121127
PerformanceCursorRows,
122128
PerformanceDeletedLabels,

‎libs/labelbox/src/labelbox/schema/user_group.py‎

Lines changed: 278 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from dataclasses import dataclass
1111
from enum import Enum
1212
import uuid
13-
from typing import Any, Dict, Iterator, List, Optional, Set
13+
from typing import Any, Dict, Iterable, Iterator, List, Optional, Set, Union
1414

1515
from lbox.exceptions import (
1616
InvalidQueryError,
@@ -23,11 +23,18 @@
2323
from pydantic import BaseModel, ConfigDict, Field
2424

2525
from labelbox import Client
26+
from labelbox.pagination import PaginatedCollection
2627
from labelbox.schema.media_type import MediaType
2728
from labelbox.schema.ontology_kind import EditorTaskType
2829
from labelbox.schema.project import Project
2930
from labelbox.schema.role import Role
3031
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+
)
3138

3239
# Constants for UserGroup role restrictions
3340
INVALID_USERGROUP_ROLES = frozenset(["NONE", "TENANT_ADMIN"])
@@ -439,6 +446,276 @@ def delete(self) -> bool:
439446
)
440447
return result["deleteUserGroup"]["success"]
441448

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+
442719
@staticmethod
443720
def get_user_groups(
444721
client: Client, page_size: int = 100

0 commit comments

Comments
 (0)