Repository: gtalarico/pyairtable
Pull request #407 (2024-11-13 00:18:54)

Add support for new enterprise API endpoints
Yesterday Airtable announced [new enterprise API endpoints](https://airtable.com/developers/web/api/changelog#anchor-2024-11-11). This branch attempts to add support for them ahead of the library's 3.0 release (aiming for this Friday). 

In some cases the API documentation does not provide concrete examples of the return values, so I've had to infer the structure from the documentation and may be off somewhere. @marks FYI, please take a look and LMK if it appears anything is missing or wrong.


Patch:
diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py
index d897129..557097e 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 Enterprise:
         #: 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 @@ class Enterprise:
         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 @@ class Enterprise:
         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 @@ class Enterprise:
                 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 @@ class Enterprise:
         )
         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 <https://airtable.com/developers/web/api/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 <https://airtable.com/developers/web/api/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 <https://airtable.com/developers/web/api/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 UserRemoved(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 UserRemoved(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 UserRemoved(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 ManageUsersResponse(AirtableModel):
         message: str
 
 
+class MoveError(AirtableModel):
+    id: str
+    type: str
+    message: str
+
+
+class MoveGroupsResponse(AirtableModel):
+    """
+    Returned by `Move user groups <https://airtable.com/developers/web/api/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 <https://airtable.com/developers/web/api/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 b82d7d6..4c66135 100644
--- a/scripts/find_model_changes.py
+++ b/scripts/find_model_changes.py
@@ -19,6 +19,21 @@ API_INTRO = f"{API_PREFIX}/introduction"
 INITDATA_RE = r"<script[^>]*>\s*window\.initData = (\{.*\})\s*</script>"
 
 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",


Test patch:
diff --git a/tests/test_api_enterprise.py b/tests/test_api_enterprise.py
index f202039..68e6e83 100644
--- a/tests/test_api_enterprise.py
+++ b/tests/test_api_enterprise.py
@@ -3,7 +3,11 @@ from unittest.mock import Mock, call, patch
 
 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)