diff --git a/docs/labelbox/index.rst b/docs/labelbox/index.rst index b37787a9d..fff0f08eb 100644 --- a/docs/labelbox/index.rst +++ b/docs/labelbox/index.rst @@ -60,5 +60,6 @@ Labelbox Python SDK Documentation task-assignment-status task-queue user + user-group-audit user-group-v2 webhook diff --git a/docs/labelbox/user-group-audit.rst b/docs/labelbox/user-group-audit.rst new file mode 100644 index 000000000..743b7cc00 --- /dev/null +++ b/docs/labelbox/user-group-audit.rst @@ -0,0 +1,6 @@ +User Group Audit +=============================================================================================== + +.. automodule:: labelbox.schema.user_group_audit + :members: UserGroupAuditEvent, UserGroupAuditEventType, UserGroupAuditActorKind, UserGroupAuditChannel + :show-inheritance: diff --git a/libs/labelbox/src/labelbox/__init__.py b/libs/labelbox/src/labelbox/__init__.py index f3a072626..59e82164d 100644 --- a/libs/labelbox/src/labelbox/__init__.py +++ b/libs/labelbox/src/labelbox/__init__.py @@ -117,6 +117,12 @@ LabelVersionAction, LabelVersionStage, ) +from labelbox.schema.user_group_audit import ( + UserGroupAuditActorKind, + UserGroupAuditChannel, + UserGroupAuditEvent, + UserGroupAuditEventType, +) from labelbox.schema.performance import ( PerformanceCursorRows, PerformanceDeletedLabels, diff --git a/libs/labelbox/src/labelbox/schema/user_group.py b/libs/labelbox/src/labelbox/schema/user_group.py index 9b98b588a..be7dd63af 100644 --- a/libs/labelbox/src/labelbox/schema/user_group.py +++ b/libs/labelbox/src/labelbox/schema/user_group.py @@ -10,7 +10,7 @@ from dataclasses import dataclass from enum import Enum import uuid -from typing import Any, Dict, Iterator, List, Optional, Set +from typing import Any, Dict, Iterable, Iterator, List, Optional, Set, Union from lbox.exceptions import ( InvalidQueryError, @@ -23,11 +23,18 @@ from pydantic import BaseModel, ConfigDict, Field from labelbox import Client +from labelbox.pagination import PaginatedCollection from labelbox.schema.media_type import MediaType from labelbox.schema.ontology_kind import EditorTaskType from labelbox.schema.project import Project from labelbox.schema.role import Role from labelbox.schema.user import User +from labelbox.schema.user_group_audit import ( + DateTimeLike, + UserGroupAuditActorKind, + UserGroupAuditEventType, + get_user_group_audit_events, +) # Constants for UserGroup role restrictions INVALID_USERGROUP_ROLES = frozenset(["NONE", "TENANT_ADMIN"]) @@ -439,6 +446,276 @@ def delete(self) -> bool: ) return result["deleteUserGroup"]["success"] + def add_members( + self, + members: Iterable[UserGroupMember], + notify_members: bool = False, + ) -> List[str]: + """Add people to the group, each with a role in it. + + Unlike :meth:`update`, this only adds: the group's other members and + its projects are left as they are. Each person gains access to every + project the group is attached to. Recorded in the group audit log as + an API change made for the API key's owner. + + Args: + members: The people to add, each with their role in the group. + notify_members: Email the added people. Defaults to False. + + Returns: + IDs of the people added. People already in the group are left + unchanged and not listed. + + Raises: + ValueError: If group ID is not set, no members are given, or a + person appears more than once. + UnprocessableEntityError: If the people could not be added. + """ + if not self.id: + raise ValueError("Group id is required") + members = list(members) + if not members: + raise ValueError("At least one member is required") + user_ids = [member.user.uid for member in members] + if len(set(user_ids)) != len(user_ids): + raise ValueError("Each user may appear only once") + + query = """ + mutation AddUserGroupMembersPyApi($id: ID!, $userRoles: [UserRoleInput!], $notifyMembers: Boolean) { + addUsersToGroupsV2( + where: { ids: [$id] } + data: { userRoles: $userRoles, notifyMembers: $notifyMembers } + ) { + results { + success + added + error + batchJob { batchJobId isComplete } + } + } + } + """ + result = self.client.execute( + query, + { + "id": self.id, + "userRoles": [ + {"userId": member.user.uid, "roleId": member.role.uid} + for member in members + ], + "notifyMembers": notify_members, + }, + )["addUsersToGroupsV2"]["results"][0] + if not result["success"]: + raise UnprocessableEntityError( + f"Cannot add members to user group: {result.get('error')}" + ) + + # Large additions finish in batches the caller has to drive. + batch_job = result.get("batchJob") + while batch_job and not batch_job["isComplete"]: + batch_job = self.client.execute( + """ + mutation ContinueAddUserGroupMembersPyApi($batchJobId: ID!) { + continueAddUsersToGroupBatch(batchJobId: $batchJobId) { + batchJob { batchJobId isComplete } + } + } + """, + {"batchJobId": batch_job["batchJobId"]}, + )["continueAddUsersToGroupBatch"]["batchJob"] + + return result.get("added") or [] + + def remove_members( + self, + users: Iterable[User], + notify_users: bool = True, + reason: Optional[str] = None, + notification_reason: Optional[str] = None, + ) -> bool: + """Remove people from the group. + + Each person loses the access they had through the group to every + project it is attached to. Recorded in the group audit log as an API + change made for the API key's owner. + + Args: + users: The people to remove. + notify_users: Email the people who lose project access. Defaults + to True. + reason: Note for your team, shown if the people are added to a + group again. Never sent to them. + notification_reason: Included in the email to the people who lose + project access, when ``notify_users`` is True. + + Returns: + True if the people were removed. + + Raises: + ValueError: If group ID is not set or no users are given. + ResourceNotFoundError: If the group is not found. + """ + if not self.id: + raise ValueError("Group id is required") + user_ids = list(dict.fromkeys(user.uid for user in users)) + if not user_ids: + raise ValueError("At least one user is required") + + query = """ + mutation RemoveUserGroupMembersPyApi($id: ID!, $data: RemoveUsersFromGroupInput!) { + removeUsersFromGroupV2(where: { id: $id }, data: $data) { + success + } + } + """ + data: Dict[str, Any] = { + "userIds": user_ids, + "notifyUsers": notify_users, + } + if reason is not None: + data["reason"] = reason + if notification_reason is not None: + data["notificationReason"] = notification_reason + + result = self.client.execute( + query, + {"id": self.id, "data": data}, + raise_return_resource_not_found=True, + ) + return result["removeUsersFromGroupV2"]["success"] + + def list_members(self) -> List[UserGroupMember]: + """Read the group's current members from the server. + + Unlike :meth:`get`, this leaves the object's other fields, including + any unsaved changes, as they are. + + Returns: + The members, each with their role in the group. + + Raises: + ValueError: If group ID is not set. + ResourceNotFoundError: If the group is not found. + """ + if not self.id: + raise ValueError("Group id is required") + + query = """ + query ListUserGroupMembersPyApi($id: ID!) { + userGroupV2(where: {id: $id}) { + members { + nodes { id email } + userGroupRoles { userId roleId } + } + } + } + """ + result = self.client.execute(query, {"id": self.id}) + if not result or not result.get("userGroupV2"): + raise ResourceNotFoundError(message="User group not found") + return list(self._get_members_set(result["userGroupV2"]["members"])) + + def audit_events( + self, + member: Optional[Union[User, str]] = None, + actor: Optional[Union[User, str]] = None, + actor_kind: Optional[Union[UserGroupAuditActorKind, str]] = None, + types: Optional[Iterable[Union[UserGroupAuditEventType, str]]] = None, + project: Optional[Union[Project, str]] = None, + occurred_after: Optional[DateTimeLike] = None, + occurred_before: Optional[DateTimeLike] = None, + ) -> PaginatedCollection: + """Read this group's audit log, newest first. + + See :meth:`get_audit_events` for what the log records, who may read + it and what each filter does. + + Returns: + PaginatedCollection of UserGroupAuditEvent. + + Raises: + ValueError: If group ID is not set or a filter is not valid. + + Example: + >>> for event in group.audit_events(types=["MEMBER_REMOVED"]): + >>> print(event.occurred_at, event.member_user_id, + >>> event.actor_kind, event.channel, event.actor_user_id) + """ + if not self.id: + raise ValueError("Group id is required") + return get_user_group_audit_events( + self.client, + group=self.id, + member=member, + actor=actor, + actor_kind=actor_kind, + types=types, + project=project, + occurred_after=occurred_after, + occurred_before=occurred_before, + ) + + @staticmethod + def get_audit_events( + client: Client, + group: Optional[Union[UserGroup, str]] = None, + member: Optional[Union[User, str]] = None, + actor: Optional[Union[User, str]] = None, + actor_kind: Optional[Union[UserGroupAuditActorKind, str]] = None, + types: Optional[Iterable[Union[UserGroupAuditEventType, str]]] = None, + project: Optional[Union[Project, str]] = None, + occurred_after: Optional[DateTimeLike] = None, + occurred_before: Optional[DateTimeLike] = None, + ) -> PaginatedCollection: + """Read the user group audit log, newest first. + + The log records who added people to groups, removed them or changed + their roles, attached groups to projects or detached them, and + deleted groups, and whether a person or an automation did it. Only + groups you can manage are included; without ``group``, every group + you can manage, including deleted ones. The log starts when it + shipped; earlier changes are not in it. + + Args: + client: Labelbox client for API communication. + group: Only events of this group. + member: Only events about this person: added, removed or given a + new role. + actor: Only changes this person made, or automations acting for + them. + actor_kind: Only changes made by a person (``"PERSON"``), or only + by automations (``"AUTOMATION"``). + types: Only these kinds of events. + project: Only events of this project: a group attached to or + detached from it. + occurred_after: Only events at or after this moment. + occurred_before: Only events before this moment. + + Returns: + PaginatedCollection of UserGroupAuditEvent. + + Raises: + ValueError: If a filter is not valid. + + Example: + >>> events = UserGroup.get_audit_events( + >>> client, member="cl1...", occurred_after="2026-10-01") + >>> for event in events: + >>> print(event.occurred_at, event.type, event.group_name) + """ + return get_user_group_audit_events( + client, + group=group, + member=member, + actor=actor, + actor_kind=actor_kind, + types=types, + project=project, + occurred_after=occurred_after, + occurred_before=occurred_before, + ) + @staticmethod def get_user_groups( client: Client, page_size: int = 100 diff --git a/libs/labelbox/src/labelbox/schema/user_group_audit.py b/libs/labelbox/src/labelbox/schema/user_group_audit.py new file mode 100644 index 000000000..c29267aaa --- /dev/null +++ b/libs/labelbox/src/labelbox/schema/user_group_audit.py @@ -0,0 +1,252 @@ +"""User group audit log. + +Labelbox records an event each time a person is added to a user group, +removed from it or given a new role in it, each time the group is attached to +or detached from a project, and when the group is deleted. Each event says who +made the change, whether a person or an automation made it, and how it reached +Labelbox (the app, the API, the Labelbox MCP, a CSV upload, a background task +or Labelbox itself). Events are never changed or deleted. The log starts when +it shipped; earlier changes are not in it. + +Only people who can manage a group can read its events. + +The methods on :class:`labelbox.schema.user_group.UserGroup` that return the +classes below are thin wrappers over the function at the bottom of this module. +""" + +from datetime import datetime, timezone +from enum import Enum +from typing import TYPE_CHECKING, Any, Dict, Iterable, Optional, Union + +from labelbox.pagination import PaginatedCollection +from labelbox.utils import _CamelCaseMixin + +if TYPE_CHECKING: + from labelbox import Client + +# The API returns at most 100 events per page. +_MAX_PAGE_SIZE = 100 + +DateTimeLike = Union[datetime, str] +"""A moment: a ``datetime`` (naive ones are read as UTC) or an ISO 8601 string.""" + + +class UserGroupAuditEventType(str, Enum): + """What a group audit event records.""" + + #: A person was added to the group. + MEMBER_ADDED = "MEMBER_ADDED" + #: A person was removed from the group. + MEMBER_REMOVED = "MEMBER_REMOVED" + #: A member's role in the group changed. One event, not a removal and an + #: addition. + MEMBER_ROLE_CHANGED = "MEMBER_ROLE_CHANGED" + #: The group was attached to a project: its members gained access to it. + GROUP_ATTACHED_TO_PROJECT = "GROUP_ATTACHED_TO_PROJECT" + #: The group was detached from a project: its members lost access + #: through it. + GROUP_DETACHED_FROM_PROJECT = "GROUP_DETACHED_FROM_PROJECT" + #: The group was deleted. + GROUP_DELETED = "GROUP_DELETED" + + +class UserGroupAuditActorKind(str, Enum): + """Whether a person or an automation made a group change.""" + + #: A person, in the Labelbox app. + PERSON = "PERSON" + #: An automation: the API with an API key (the SDK and scripts), the + #: Labelbox MCP, a background task, or Labelbox itself. + AUTOMATION = "AUTOMATION" + + +class UserGroupAuditChannel(str, Enum): + """How a group change reached Labelbox.""" + + #: The Labelbox app. + APP = "APP" + #: The API with an API key: the SDK and scripts. + API = "API" + #: The Labelbox MCP. + MCP = "MCP" + #: A CSV of members uploaded to the group. + CSV_IMPORT = "CSV_IMPORT" + #: The Alignerr API. + ALIGNERR_API = "ALIGNERR_API" + #: A background task whose requester is not known. + BACKGROUND_TASK = "BACKGROUND_TASK" + #: Labelbox itself, with no requester: for example removing a deleted + #: user from their groups. + SYSTEM = "SYSTEM" + + +class UserGroupAuditEvent(_CamelCaseMixin): + """One change to a user group. + + Attributes: + id (str): Stable ID of the event. + group_id (str): The group's ID. + group_name (Optional[str]): The group's current name. ``None`` once + the group is deleted. + type (str): What changed (see :class:`UserGroupAuditEventType`). + Unknown values are kept as strings. + occurred_at (datetime): When the change was made. + actor_kind (str): Whether a person or an automation made the change + (see :class:`UserGroupAuditActorKind`). + channel (str): How the change reached Labelbox (see + :class:`UserGroupAuditChannel`). + actor_user_id (Optional[str]): The person who made the change, or the + user an automation acted for (the API key's owner, the MCP user, + the task's requester). ``None`` when Labelbox made the change + itself. + automation (Optional[str]): Which automation made the change: the API + key, MCP client, task or reason. ``None`` for a person. + member_user_id (Optional[str]): The person added, removed or given a + new role. ``None`` for project and group events. + previous_role_id (Optional[str]): The member's role before a removal + or role change. + previous_role_name (Optional[str]): That role's name. + role_id (Optional[str]): The member's role after an addition or role + change. + role_name (Optional[str]): That role's name. + project_id (Optional[str]): The project the group was attached to or + detached from. + project_name (Optional[str]): That project's name. + """ + + id: str + group_id: str + group_name: Optional[str] = None + type: Union[UserGroupAuditEventType, str] + occurred_at: datetime + actor_kind: Union[UserGroupAuditActorKind, str] + channel: Union[UserGroupAuditChannel, str] + actor_user_id: Optional[str] = None + automation: Optional[str] = None + member_user_id: Optional[str] = None + previous_role_id: Optional[str] = None + previous_role_name: Optional[str] = None + role_id: Optional[str] = None + role_name: Optional[str] = None + project_id: Optional[str] = None + project_name: Optional[str] = None + + +_FIELDS = """ + id groupId groupName type occurredAt actorKind channel actorUserId + automation memberUserId previousRoleId previousRoleName roleId roleName + projectId projectName +""" + + +def _to_id(name: str, value: Any) -> Optional[str]: + if value is None: + return None + uid = getattr(value, "uid", None) or getattr(value, "id", None) or value + if not isinstance(uid, str) or not uid: + raise ValueError(f"{name} must be an ID or an SDK object with one") + return uid + + +def _to_datetime( + name: str, value: Optional[DateTimeLike] +) -> Optional[datetime]: + if value is None: + return None + if isinstance(value, str): + try: + value = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as error: + raise ValueError( + f"{name} must be a datetime or an ISO 8601 string" + ) from error + if not isinstance(value, datetime): + raise ValueError(f"{name} must be a datetime or an ISO 8601 string") + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +def _iso(value: Optional[datetime]) -> Optional[str]: + return ( + value.isoformat(timespec="milliseconds").replace("+00:00", "Z") + if value + else None + ) + + +def _known(enum: Any, value: Any) -> Any: + return enum(value) if value in enum._value2member_map_ else value + + +def _to_event(result: Dict[str, Any]) -> UserGroupAuditEvent: + return UserGroupAuditEvent.model_validate( + { + **result, + "type": _known(UserGroupAuditEventType, result.get("type")), + "actorKind": _known( + UserGroupAuditActorKind, result.get("actorKind") + ), + "channel": _known(UserGroupAuditChannel, result.get("channel")), + } + ) + + +def get_user_group_audit_events( + client: "Client", + group: Any = None, + member: Any = None, + actor: Any = None, + actor_kind: Optional[Union[UserGroupAuditActorKind, str]] = None, + types: Optional[Iterable[Union[UserGroupAuditEventType, str]]] = None, + project: Any = None, + occurred_after: Optional[DateTimeLike] = None, + occurred_before: Optional[DateTimeLike] = None, + page_size: int = _MAX_PAGE_SIZE, +) -> PaginatedCollection: + if not isinstance(page_size, int) or not 1 <= page_size <= _MAX_PAGE_SIZE: + raise ValueError(f"page_size must be between 1 and {_MAX_PAGE_SIZE}") + after = _to_datetime("occurred_after", occurred_after) + before = _to_datetime("occurred_before", occurred_before) + if after and before and after >= before: + raise ValueError("occurred_after must be before occurred_before") + where = { + key: value + for key, value in { + "groupId": _to_id("group", group), + "memberUserId": _to_id("member", member), + "actorUserId": _to_id("actor", actor), + "actorKind": UserGroupAuditActorKind(actor_kind).value + if actor_kind is not None + else None, + "types": [UserGroupAuditEventType(t).value for t in types] + if types is not None + else None, + "projectId": _to_id("project", project), + "occurredAfter": _iso(after), + "occurredBefore": _iso(before), + }.items() + if value is not None + } + query = ( + """query GetUserGroupAuditEventsPyApi( + $where: UserGroupAuditEventsWhereInput, $first: Int, $from: String + ) { + userGroupAuditEvents(where: $where, first: $first, after: $from) { + nodes { %s } + nextCursor + } + }""" + % _FIELDS + ) + # The input object is a nested dict: PaginatedCollection only types + # scalars. + params: Dict[str, Any] = {"where": where, "first": page_size} + return PaginatedCollection( + client=client, + query=query, + params=params, + dereferencing=["userGroupAuditEvents", "nodes"], + obj_class=lambda _, result: _to_event(result), + cursor_path=["userGroupAuditEvents", "nextCursor"], + ) diff --git a/libs/labelbox/tests/unit/schema/test_user_group_members_audit.py b/libs/labelbox/tests/unit/schema/test_user_group_members_audit.py new file mode 100644 index 000000000..f9868986b --- /dev/null +++ b/libs/labelbox/tests/unit/schema/test_user_group_members_audit.py @@ -0,0 +1,295 @@ +from collections import defaultdict +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +import pytest +from lbox.exceptions import UnprocessableEntityError + +from labelbox import Client +from labelbox.schema.role import Role +from labelbox.schema.user import User +from labelbox.schema.user_group import UserGroup, UserGroupMember +from labelbox.schema.user_group_audit import ( + UserGroupAuditActorKind, + UserGroupAuditChannel, + UserGroupAuditEventType, +) + + +def make_user(uid): + values = defaultdict(lambda: None) + values["id"] = uid + values["email"] = f"{uid}@example.com" + return User(MagicMock(Client), values) + + +def make_role(uid, name="LABELER"): + return Role(MagicMock(Client), {"id": uid, "name": name}) + + +@pytest.fixture +def client(): + return MagicMock(Client) + + +@pytest.fixture +def group(client): + return UserGroup(client, id="group_id", name="Vendors") + + +def event_node(**overrides): + node = { + "id": "event_1", + "groupId": "group_id", + "groupName": "Vendors", + "type": "MEMBER_REMOVED", + "occurredAt": "2026-10-20T12:00:00.000Z", + "actorKind": "AUTOMATION", + "channel": "API", + "actorUserId": "admin_id", + "automation": "API key: sync", + "memberUserId": "user_1", + "previousRoleId": "labeler_id", + "previousRoleName": "Labeler", + "roleId": None, + "roleName": None, + "projectId": None, + "projectName": None, + } + node.update(overrides) + return node + + +class TestAddMembers: + def test_sends_user_roles_and_returns_added(self, group, client): + client.execute.return_value = { + "addUsersToGroupsV2": { + "results": [ + { + "success": True, + "added": ["user_1"], + "error": None, + "batchJob": None, + } + ] + } + } + + added = group.add_members( + [UserGroupMember(make_user("user_1"), make_role("labeler_id"))], + notify_members=True, + ) + + assert added == ["user_1"] + query, params = client.execute.call_args[0] + assert "addUsersToGroupsV2" in query + assert params == { + "id": "group_id", + "userRoles": [{"userId": "user_1", "roleId": "labeler_id"}], + "notifyMembers": True, + } + + def test_drives_batch_job_until_complete(self, group, client): + client.execute.side_effect = [ + { + "addUsersToGroupsV2": { + "results": [ + { + "success": True, + "added": ["user_1"], + "error": None, + "batchJob": { + "batchJobId": "job_1", + "isComplete": False, + }, + } + ] + } + }, + { + "continueAddUsersToGroupBatch": { + "batchJob": {"batchJobId": "job_1", "isComplete": False} + } + }, + { + "continueAddUsersToGroupBatch": { + "batchJob": {"batchJobId": "job_1", "isComplete": True} + } + }, + ] + + group.add_members( + [UserGroupMember(make_user("user_1"), make_role("labeler_id"))] + ) + + assert client.execute.call_count == 3 + assert client.execute.call_args[0][1] == {"batchJobId": "job_1"} + + def test_raises_when_server_rejects(self, group, client): + client.execute.return_value = { + "addUsersToGroupsV2": { + "results": [ + { + "success": False, + "added": None, + "error": "User not in workspace", + "batchJob": None, + } + ] + } + } + + with pytest.raises( + UnprocessableEntityError, match="User not in workspace" + ): + group.add_members( + [UserGroupMember(make_user("user_1"), make_role("labeler_id"))] + ) + + def test_rejects_duplicate_and_empty_members(self, group, client): + member = UserGroupMember(make_user("user_1"), make_role("labeler_id")) + with pytest.raises(ValueError, match="only once"): + group.add_members([member, member]) + with pytest.raises(ValueError, match="At least one"): + group.add_members([]) + client.execute.assert_not_called() + + def test_requires_group_id(self, client): + with pytest.raises(ValueError, match="Group id"): + UserGroup(client).add_members([]) + + +class TestRemoveMembers: + def test_sends_user_ids_and_options(self, group, client): + client.execute.return_value = { + "removeUsersFromGroupV2": {"success": True} + } + + assert group.remove_members( + [make_user("user_1"), make_user("user_2"), make_user("user_1")], + notify_users=False, + reason="Contract ended", + ) + + query, params = client.execute.call_args[0] + assert "removeUsersFromGroupV2" in query + assert params == { + "id": "group_id", + "data": { + "userIds": ["user_1", "user_2"], + "notifyUsers": False, + "reason": "Contract ended", + }, + } + assert ( + client.execute.call_args.kwargs["raise_return_resource_not_found"] + is True + ) + + def test_rejects_empty_users(self, group, client): + with pytest.raises(ValueError, match="At least one"): + group.remove_members([]) + client.execute.assert_not_called() + + +class TestListMembers: + def test_returns_members_with_group_roles(self, group, client): + client.execute.return_value = { + "userGroupV2": { + "members": { + "nodes": [ + {"id": "user_1", "email": "user_1@example.com"}, + {"id": "user_2", "email": "user_2@example.com"}, + ], + "userGroupRoles": [ + {"userId": "user_1", "roleId": "labeler_id"}, + {"userId": "user_2", "roleId": "reviewer_id"}, + ], + } + } + } + roles = { + "LABELER": make_role("labeler_id", "LABELER"), + "REVIEWER": make_role("reviewer_id", "REVIEWER"), + } + group.name = "Unsaved name" + + with patch("labelbox.schema.role.get_roles", return_value=roles): + members = group.list_members() + + assert {(m.user.uid, m.role.uid) for m in members} == { + ("user_1", "labeler_id"), + ("user_2", "reviewer_id"), + } + assert group.name == "Unsaved name" + + +class TestAuditEvents: + def test_group_audit_events_filters_and_parses(self, group, client): + client.execute.return_value = { + "userGroupAuditEvents": { + "nodes": [event_node()], + "nextCursor": None, + } + } + + events = list( + group.audit_events( + member=make_user("user_1"), + actor_kind="AUTOMATION", + types=[UserGroupAuditEventType.MEMBER_REMOVED], + occurred_after=datetime(2026, 10, 1), + ) + ) + + assert len(events) == 1 + event = events[0] + assert event.type is UserGroupAuditEventType.MEMBER_REMOVED + assert event.actor_kind is UserGroupAuditActorKind.AUTOMATION + assert event.channel is UserGroupAuditChannel.API + assert event.member_user_id == "user_1" + assert event.occurred_at == datetime( + 2026, 10, 20, 12, tzinfo=timezone.utc + ) + + query, params = client.execute.call_args[0] + assert "userGroupAuditEvents" in query + assert params["where"] == { + "groupId": "group_id", + "memberUserId": "user_1", + "actorKind": "AUTOMATION", + "types": ["MEMBER_REMOVED"], + "occurredAfter": "2026-10-01T00:00:00.000Z", + } + + def test_get_audit_events_without_group_and_unknown_values(self, client): + client.execute.return_value = { + "userGroupAuditEvents": { + "nodes": [ + event_node( + type="SOMETHING_NEW", + channel="NEW_CHANNEL", + groupName=None, + ) + ], + "nextCursor": None, + } + } + + events = list(UserGroup.get_audit_events(client, project="project_1")) + + assert events[0].type == "SOMETHING_NEW" + assert events[0].channel == "NEW_CHANNEL" + assert events[0].group_name is None + assert client.execute.call_args[0][1]["where"] == { + "projectId": "project_1" + } + + def test_rejects_bad_filters(self, group, client): + with pytest.raises(ValueError): + group.audit_events(actor_kind="ROBOT") + with pytest.raises(ValueError, match="before"): + group.audit_events( + occurred_after="2026-10-02", occurred_before="2026-10-01" + ) + client.execute.assert_not_called()