diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py index d897129d..557097e0 100644 --- a/pyairtable/api/enterprise.py +++ b/pyairtable/api/enterprise.py @@ -1,12 +1,13 @@ -import datetime +from datetime import date, datetime from functools import cached_property, partialmethod from typing import Any, Dict, Iterable, Iterator, List, Literal, Optional, Union import pydantic +from typing_extensions import Self from pyairtable.models._base import AirtableModel, rebuild_models from pyairtable.models.audit import AuditLogResponse -from pyairtable.models.schema import EnterpriseInfo, UserGroup, UserInfo +from pyairtable.models.schema import EnterpriseInfo, NestedId, UserGroup, UserInfo from pyairtable.utils import ( Url, UrlBuilder, @@ -43,6 +44,15 @@ class _urls(UrlBuilder): #: URL for retrieving audit log events. audit_log = meta / "auditLogEvents" + #: URL for managing descendant enterprise accounts. + descendants = meta / "descendants" + + #: URL for moving user groups between enterprise accounts. + move_groups = meta / "moveGroups" + + #: URL for moving workspaces between enterprise accounts. + move_workspaces = meta / "moveWorkspaces" + def user(self, user_id: str) -> Url: """ URL for retrieving information about a single user. @@ -205,8 +215,8 @@ def audit_log( sort_asc: Optional[bool] = False, previous: Optional[str] = None, next: Optional[str] = None, - start_time: Optional[Union[str, datetime.date, datetime.datetime]] = None, - end_time: Optional[Union[str, datetime.date, datetime.datetime]] = None, + start_time: Optional[Union[str, date, datetime]] = None, + end_time: Optional[Union[str, date, datetime]] = None, user_id: Optional[Union[str, Iterable[str]]] = None, event_type: Optional[Union[str, Iterable[str]]] = None, model_id: Optional[Union[str, Iterable[str]]] = None, @@ -323,6 +333,8 @@ def remove_user( self, user_id: str, replacement: Optional[str] = None, + *, + descendants: bool = False, ) -> "UserRemoved": """ Unshare a user from all enterprise workspaces, bases, and interfaces. @@ -337,11 +349,14 @@ def remove_user( specify a replacement user ID to be added as the new owner of such workspaces. If the user is not the sole owner of any workspaces, this is optional and will be ignored if provided. + descendants: If ``True``, removes the user from descendant enterprise accounts. """ url = self.urls.remove_user(user_id) payload: Dict[str, Any] = {"isDryRun": False} if replacement: payload["replacementOwnerId"] = replacement + if descendants: + payload["removeFromDescendants"] = True response = self.api.post(url, json=payload) return UserRemoved.from_api(response, self.api, context=self) @@ -417,6 +432,72 @@ def _post_admin_access( ) return ManageUsersResponse.from_api(response, self.api, context=self) + def create_descendant(self, name: str) -> Self: + """ + Creates a descendant enterprise account of the enterprise account. + Descendant enterprise accounts can only be created for root enterprise accounts + with the Enterprise Hub feature enabled. + + See `Create descendant enterprise `__. + + Args: + name: The name to give the new account. + """ + response = self.api.post(self.urls.descendants, json={"name": name}) + return self.__class__(self.api, response["id"]) + + def move_groups( + self, + group_ids: Iterable[str], + target: Union[str, Self], + ) -> "MoveGroupsResponse": + """ + Move one or more user groups from the current enterprise account + into a different enterprise account within the same organization. + + See `Move user groups `__. + + Args: + group_ids: User group IDs. + target: The ID of the target enterprise, or an instance of :class:`~pyairtable.Enterprise`. + """ + if isinstance(target, Enterprise): + target = target.id + response = self.api.post( + self.urls.move_groups, + json={ + "groupIds": group_ids, + "targetEnterpriseAccountId": target, + }, + ) + return MoveGroupsResponse.from_api(response, self.api, context=self) + + def move_workspaces( + self, + workspace_ids: Iterable[str], + target: Union[str, Self], + ) -> "MoveWorkspacesResponse": + """ + Move one or more workspaces from the current enterprise account + into a different enterprise account within the same organization. + + See `Move workspaces `__. + + Args: + workspace_ids: The list of workspace IDs. + target: The ID of the target enterprise, or an instance of :class:`~pyairtable.Enterprise`. + """ + if isinstance(target, Enterprise): + target = target.id + response = self.api.post( + self.urls.move_workspaces, + json={ + "workspaceIds": workspace_ids, + "targetEnterpriseAccountId": target, + }, + ) + return MoveWorkspacesResponse.from_api(response, self.api, context=self) + class UserRemoved(AirtableModel): """ @@ -436,6 +517,8 @@ class Workspace(AirtableModel): workspace_id: str workspace_name: str user_id: str = "" + deleted_time: Optional[datetime] = None + enterprise_account_id: Optional[str] = None class Unshared(AirtableModel): bases: List["UserRemoved.Unshared.Base"] @@ -447,6 +530,8 @@ class Base(AirtableModel): base_id: str base_name: str former_permission_level: str + deleted_time: Optional[datetime] = None + enterprise_account_id: Optional[str] = None class Interface(AirtableModel): user_id: str @@ -454,12 +539,16 @@ class Interface(AirtableModel): interface_id: str interface_name: str former_permission_level: str + deleted_time: Optional[datetime] = None + enterprise_account_id: Optional[str] = None class Workspace(AirtableModel): user_id: str former_permission_level: str workspace_id: str workspace_name: str + deleted_time: Optional[datetime] = None + enterprise_account_id: Optional[str] = None class DeleteUsersResponse(AirtableModel): @@ -498,6 +587,30 @@ class Error(AirtableModel): message: str +class MoveError(AirtableModel): + id: str + type: str + message: str + + +class MoveGroupsResponse(AirtableModel): + """ + Returned by `Move user groups `__. + """ + + moved_groups: List[NestedId] = pydantic.Field(default_factory=list) + errors: List[MoveError] = pydantic.Field(default_factory=list) + + +class MoveWorkspacesResponse(AirtableModel): + """ + Returned by `Move workspaces `__. + """ + + moved_workspaces: List[NestedId] = pydantic.Field(default_factory=list) + errors: List[MoveError] = pydantic.Field(default_factory=list) + + rebuild_models(vars()) diff --git a/scripts/find_model_changes.py b/scripts/find_model_changes.py index b82d7d60..4c661357 100644 --- a/scripts/find_model_changes.py +++ b/scripts/find_model_changes.py @@ -19,6 +19,21 @@ INITDATA_RE = r"]*>\s*window\.initData = (\{.*\})\s*" SCAN_MODELS = { + "pyairtable.api.enterprise:UserRemoved": "operations:remove-user-from-enterprise:response:schema", + "pyairtable.api.enterprise:UserRemoved.Shared": "operations:remove-user-from-enterprise:response:schema:@shared", + "pyairtable.api.enterprise:UserRemoved.Shared.Workspace": "operations:remove-user-from-enterprise:response:schema:@shared:@workspaces:items", + "pyairtable.api.enterprise:UserRemoved.Unshared": "operations:remove-user-from-enterprise:response:schema:@unshared", + "pyairtable.api.enterprise:UserRemoved.Unshared.Base": "operations:remove-user-from-enterprise:response:schema:@unshared:@bases:items", + "pyairtable.api.enterprise:UserRemoved.Unshared.Interface": "operations:remove-user-from-enterprise:response:schema:@unshared:@interfaces:items", + "pyairtable.api.enterprise:UserRemoved.Unshared.Workspace": "operations:remove-user-from-enterprise:response:schema:@unshared:@workspaces:items", + "pyairtable.api.enterprise:DeleteUsersResponse": "operations:delete-users-by-email:response:schema", + "pyairtable.api.enterprise:DeleteUsersResponse.UserInfo": "operations:delete-users-by-email:response:schema:@deletedUsers:items", + "pyairtable.api.enterprise:DeleteUsersResponse.Error": "operations:delete-users-by-email:response:schema:@errors:items", + "pyairtable.api.enterprise:ManageUsersResponse": "operations:manage-user-membership:response:schema", + "pyairtable.api.enterprise:ManageUsersResponse.Error": "operations:manage-user-membership:response:schema:@errors:items", + "pyairtable.api.enterprise:MoveError": "operations:move-workspaces:response:schema:@errors:items", + "pyairtable.api.enterprise:MoveGroupsResponse": "operations:move-user-groups:response:schema", + "pyairtable.api.enterprise:MoveWorkspacesResponse": "operations:move-workspaces:response:schema", "pyairtable.models.audit:AuditLogResponse": "operations:audit-log-events:response:schema", "pyairtable.models.audit:AuditLogEvent": "operations:audit-log-events:response:schema:@events:items", "pyairtable.models.audit:AuditLogEvent.Context": "operations:audit-log-events:response:schema:@events:items:@context", diff --git a/tests/test_api_enterprise.py b/tests/test_api_enterprise.py index f202039e..68e6e833 100644 --- a/tests/test_api_enterprise.py +++ b/tests/test_api_enterprise.py @@ -3,7 +3,11 @@ import pytest -from pyairtable.api.enterprise import DeleteUsersResponse, ManageUsersResponse +from pyairtable.api.enterprise import ( + DeleteUsersResponse, + Enterprise, + ManageUsersResponse, +) from pyairtable.models.schema import EnterpriseInfo, UserGroup, UserInfo from pyairtable.testing import fake_id @@ -58,6 +62,17 @@ def enterprise_mocks(enterprise, requests_mock, sample_json): f"{enterprise_url}/claim/users", json={"errors": []}, ) + m.create_descendants = requests_mock.post( + f"{enterprise_url}/descendants", json={"id": fake_id("ent")} + ) + m.move_groups_json = {} + m.move_groups = requests_mock.post( + f"{enterprise_url}/moveGroups", json=m.move_groups_json + ) + m.move_workspaces_json = {} + m.move_workspaces = requests_mock.post( + f"{enterprise_url}/moveWorkspaces", json=m.move_workspaces_json + ) return m @@ -321,6 +336,10 @@ def test_audit_log__sortorder( {"replacement": "otherUser"}, {"isDryRun": False, "replacementOwnerId": "otherUser"}, ), + ( + {"descendants": True}, + {"isDryRun": False, "removeFromDescendants": True}, + ), ], ) def test_remove_user(enterprise, enterprise_mocks, kwargs, expected): @@ -414,3 +433,46 @@ def test_manage_admin_access(enterprise, enterprise_mocks, requests_mock, action {"id": user.id}, ] } + + +def test_create_descendant(enterprise, enterprise_mocks): + descendant = enterprise.create_descendant("Some name") + assert enterprise_mocks.create_descendants.call_count == 1 + assert enterprise_mocks.create_descendants.last_request.json() == { + "name": "Some name" + } + assert isinstance(descendant, Enterprise) + + +def test_move_groups(api, enterprise, enterprise_mocks): + other_id = fake_id("ent") + group_ids = [fake_id("ugp") for _ in range(3)] + enterprise_mocks.move_groups_json["movedGroups"] = [ + {"id": group_id} for group_id in group_ids + ] + for target in [other_id, api.enterprise(other_id)]: + enterprise_mocks.move_groups.reset() + result = enterprise.move_groups(group_ids, target) + assert enterprise_mocks.move_groups.call_count == 1 + assert enterprise_mocks.move_groups.last_request.json() == { + "targetEnterpriseAccountId": other_id, + "groupIds": group_ids, + } + assert set(m.id for m in result.moved_groups) == set(group_ids) + + +def test_move_workspaces(api, enterprise, enterprise_mocks): + other_id = fake_id("ent") + workspace_ids = [fake_id("wsp") for _ in range(3)] + enterprise_mocks.move_workspaces_json["movedWorkspaces"] = [ + {"id": workspace_id} for workspace_id in workspace_ids + ] + for target in [other_id, api.enterprise(other_id)]: + enterprise_mocks.move_workspaces.reset() + result = enterprise.move_workspaces(workspace_ids, target) + assert enterprise_mocks.move_workspaces.call_count == 1 + assert enterprise_mocks.move_workspaces.last_request.json() == { + "targetEnterpriseAccountId": other_id, + "workspaceIds": workspace_ids, + } + assert set(m.id for m in result.moved_workspaces) == set(workspace_ids)