Skip to content

Commit

Permalink
Merge pull request #407 from mesozoic/enterprise_api
Browse files Browse the repository at this point in the history
Add support for new enterprise API endpoints
  • Loading branch information
mesozoic authored Nov 16, 2024
2 parents 9bf990a + 5bb22bc commit 2012986
Show file tree
Hide file tree
Showing 3 changed files with 195 additions and 5 deletions.
121 changes: 117 additions & 4 deletions pyairtable/api/enterprise.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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)

Expand Down Expand Up @@ -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 <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):
"""
Expand All @@ -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"]
Expand All @@ -447,19 +530,25 @@ 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
base_id: str
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):
Expand Down Expand Up @@ -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 <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())


Expand Down
15 changes: 15 additions & 0 deletions scripts/find_model_changes.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,21 @@
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",
Expand Down
64 changes: 63 additions & 1 deletion tests/test_api_enterprise.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)

0 comments on commit 2012986

Please sign in to comment.