From d4c44bec9e9e78feadf31b7f5923c79197c5f9bc Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 11 Nov 2024 22:30:18 -0800 Subject: [PATCH 1/5] Scan enterprise models for missing fields --- pyairtable/api/enterprise.py | 14 +++++++++++--- scripts/find_model_changes.py | 12 ++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py index d897129d..cf93fa78 100644 --- a/pyairtable/api/enterprise.py +++ b/pyairtable/api/enterprise.py @@ -1,4 +1,4 @@ -import datetime +from datetime import date, datetime from functools import cached_property, partialmethod from typing import Any, Dict, Iterable, Iterator, List, Literal, Optional, Union @@ -205,8 +205,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, @@ -436,6 +436,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 +449,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 +458,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): diff --git a/scripts/find_model_changes.py b/scripts/find_model_changes.py index b82d7d60..e2e0396c 100644 --- a/scripts/find_model_changes.py +++ b/scripts/find_model_changes.py @@ -19,6 +19,18 @@ 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.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", From 39386eb0fc4dbd90d15d730f331b998f5eb83996 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 11 Nov 2024 22:33:37 -0800 Subject: [PATCH 2/5] Add `descendants=` kwarg to Enterprise.remove_user --- pyairtable/api/enterprise.py | 5 +++++ tests/test_api_enterprise.py | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py index cf93fa78..d3e8ac52 100644 --- a/pyairtable/api/enterprise.py +++ b/pyairtable/api/enterprise.py @@ -323,6 +323,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 +339,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) diff --git a/tests/test_api_enterprise.py b/tests/test_api_enterprise.py index f202039e..2f11e1ef 100644 --- a/tests/test_api_enterprise.py +++ b/tests/test_api_enterprise.py @@ -321,6 +321,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): From 62b68b6f8f10be1a1554b66a30decd9f39cd8e32 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 11 Nov 2024 22:39:24 -0800 Subject: [PATCH 3/5] Enterprise.create_descendant --- pyairtable/api/enterprise.py | 18 ++++++++++++++++++ tests/test_api_enterprise.py | 15 ++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py index d3e8ac52..dcf87fdb 100644 --- a/pyairtable/api/enterprise.py +++ b/pyairtable/api/enterprise.py @@ -3,6 +3,7 @@ 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 @@ -43,6 +44,9 @@ class _urls(UrlBuilder): #: URL for retrieving audit log events. audit_log = meta / "auditLogEvents" + #: URL for managing descendant enterprise accounts. + descendants = meta / "descendants" + def user(self, user_id: str) -> Url: """ URL for retrieving information about a single user. @@ -422,6 +426,20 @@ 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"]) + class UserRemoved(AirtableModel): """ diff --git a/tests/test_api_enterprise.py b/tests/test_api_enterprise.py index 2f11e1ef..edc5294a 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 @@ -418,3 +422,12 @@ def test_manage_admin_access(enterprise, enterprise_mocks, requests_mock, action {"id": user.id}, ] } + + +def test_create_descendant(enterprise, requests_mock): + sub_ent_id = fake_id("ent") + m = requests_mock.post(enterprise.urls.descendants, json={"id": sub_ent_id}) + descendant = enterprise.create_descendant("Some name") + assert m.call_count == 1 + assert m.last_request.json() == {"name": "Some name"} + assert isinstance(descendant, Enterprise) From 8c331aad1ee86b8ce91d6ae7ba90795d08f45b2f Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 11 Nov 2024 22:55:13 -0800 Subject: [PATCH 4/5] Enterprise.move_groups --- pyairtable/api/enterprise.py | 46 +++++++++++++++++++++++++++++++++++- tests/test_api_enterprise.py | 38 +++++++++++++++++++++++++---- 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py index dcf87fdb..7b970cb0 100644 --- a/pyairtable/api/enterprise.py +++ b/pyairtable/api/enterprise.py @@ -7,7 +7,7 @@ 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, @@ -47,6 +47,9 @@ class _urls(UrlBuilder): #: URL for managing descendant enterprise accounts. descendants = meta / "descendants" + #: URL for moving user groups between enterprise accounts. + move_groups = meta / "moveGroups" + def user(self, user_id: str) -> Url: """ URL for retrieving information about a single user. @@ -440,6 +443,32 @@ def create_descendant(self, name: str) -> Self: 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) + class UserRemoved(AirtableModel): """ @@ -529,6 +558,21 @@ 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) + + rebuild_models(vars()) diff --git a/tests/test_api_enterprise.py b/tests/test_api_enterprise.py index edc5294a..93f8bad2 100644 --- a/tests/test_api_enterprise.py +++ b/tests/test_api_enterprise.py @@ -62,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 @@ -424,10 +435,27 @@ def test_manage_admin_access(enterprise, enterprise_mocks, requests_mock, action } -def test_create_descendant(enterprise, requests_mock): - sub_ent_id = fake_id("ent") - m = requests_mock.post(enterprise.urls.descendants, json={"id": sub_ent_id}) +def test_create_descendant(enterprise, enterprise_mocks): descendant = enterprise.create_descendant("Some name") - assert m.call_count == 1 - assert m.last_request.json() == {"name": "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) From 5bb22bc28317f61fc419e1ef561bade742297cb3 Mon Sep 17 00:00:00 2001 From: Alex Levy Date: Mon, 11 Nov 2024 22:59:44 -0800 Subject: [PATCH 5/5] Enterprise.move_workspaces --- pyairtable/api/enterprise.py | 38 +++++++++++++++++++++++++++++++++++ scripts/find_model_changes.py | 3 +++ tests/test_api_enterprise.py | 17 ++++++++++++++++ 3 files changed, 58 insertions(+) diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py index 7b970cb0..557097e0 100644 --- a/pyairtable/api/enterprise.py +++ b/pyairtable/api/enterprise.py @@ -50,6 +50,9 @@ class _urls(UrlBuilder): #: 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. @@ -469,6 +472,32 @@ def move_groups( ) 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): """ @@ -573,6 +602,15 @@ class MoveGroupsResponse(AirtableModel): 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 e2e0396c..4c661357 100644 --- a/scripts/find_model_changes.py +++ b/scripts/find_model_changes.py @@ -31,6 +31,9 @@ "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 93f8bad2..68e6e833 100644 --- a/tests/test_api_enterprise.py +++ b/tests/test_api_enterprise.py @@ -459,3 +459,20 @@ def test_move_groups(api, enterprise, enterprise_mocks): "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)