Repository: gtalarico/pyairtable
Pull request #373 (2024-05-15 15:08:37)

Add support for grant/revoke admin access
This branch adds support and tests for `Enterprise.grant_admin()` and `Enterprise.revoke_admin()` to support two new endpoints that were [added in April](https://airtable.com/developers/web/api/changelog).


Patch:
diff --git a/docs/source/enterprise.rst b/docs/source/enterprise.rst
index a1d5c43..d43a229 100644
--- a/docs/source/enterprise.rst
+++ b/docs/source/enterprise.rst
@@ -158,3 +158,15 @@ via the following methods.
 `Delete users by email <https://airtable.com/developers/web/api/delete-users-by-email>`__
 
     >>> enterprise.delete_users(["foo@example.com", "bar@example.com"])
+
+`Grant admin access <https://airtable.com/developers/web/api/grant-admin-access>`__
+
+    >>> enterprise.grant_admin("usrUserId")
+    >>> enterprise.grant_admin("user@example.com")
+    >>> enterprise.grant_admin(enterprise.user("usrUserId"))
+
+`Revoke admin access <https://airtable.com/developers/web/api/revoke-admin-access>`__
+
+    >>> enterprise.revoke_admin("usrUserId")
+    >>> enterprise.revoke_admin("user@example.com")
+    >>> enterprise.revoke_admin(enterprise.user("usrUserId"))
diff --git a/docs/source/migrations.rst b/docs/source/migrations.rst
index 271d71a..79245b4 100644
--- a/docs/source/migrations.rst
+++ b/docs/source/migrations.rst
@@ -73,6 +73,18 @@ The 3.0 release has changed the API for retrieving ORM model configuration:
     * - ``Model._get_meta(name)``
       - ``Model.meta.get(name)``
 
+Miscellaneous name changes
+---------------------------------------------
+
+.. list-table::
+    :header-rows: 1
+
+    * - Old name
+      - New name
+    * - :class:`~pyairtable.api.enterprise.ClaimUsersResponse`
+      - :class:`~pyairtable.api.enterprise.ManageUsersResponse`
+
+
 Migrating from 2.2 to 2.3
 ============================
 
diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py
index 5eaa711..5dee672 100644
--- a/pyairtable/api/enterprise.py
+++ b/pyairtable/api/enterprise.py
@@ -1,6 +1,7 @@
 import datetime
 from typing import Any, Dict, Iterable, Iterator, List, Literal, Optional, Union
 
+from pyairtable._compat import pydantic
 from pyairtable.models._base import AirtableModel, update_forward_refs
 from pyairtable.models.audit import AuditLogResponse
 from pyairtable.models.schema import EnterpriseInfo, UserGroup, UserInfo
@@ -253,7 +254,7 @@ class Enterprise:
 
     def claim_users(
         self, users: Dict[str, Literal["managed", "unmanaged"]]
-    ) -> "ClaimUsersResponse":
+    ) -> "ManageUsersResponse":
         """
         Batch manage organizations enterprise account users. This endpoint allows you
         to change a user's membership status from being unmanaged to being an
@@ -276,7 +277,7 @@ class Enterprise:
             ]
         }
         response = self.api.post(f"{self.url}/users/claim", json=payload)
-        return ClaimUsersResponse.from_api(response, self.api, context=self)
+        return ManageUsersResponse.from_api(response, self.api, context=self)
 
     def delete_users(self, emails: Iterable[str]) -> "DeleteUsersResponse":
         """
@@ -288,6 +289,41 @@ class Enterprise:
         response = self.api.delete(f"{self.url}/users", params={"email": list(emails)})
         return DeleteUsersResponse.from_api(response, self.api, context=self)
 
+    def grant_admin(self, *users: Union[str, UserInfo]) -> "ManageUsersResponse":
+        """
+        Grant admin access to one or more users.
+
+        Args:
+            users: One or more user IDs, email addresses, or instances of
+                :class:`~pyairtable.models.schema.UserInfo`.
+        """
+        return self._post_admin_access("grant", users)
+
+    def revoke_admin(self, *users: Union[str, UserInfo]) -> "ManageUsersResponse":
+        """
+        Revoke admin access to one or more users.
+
+        Args:
+            users: One or more user IDs, email addresses, or instances of
+                :class:`~pyairtable.models.schema.UserInfo`.
+        """
+        return self._post_admin_access("revoke", users)
+
+    def _post_admin_access(
+        self, action: str, users: Iterable[Union[str, UserInfo]]
+    ) -> "ManageUsersResponse":
+        response = self.api.post(
+            f"{self.url}/users/{action}AdminAccess",
+            json={
+                "users": [
+                    {"email": user_id} if "@" in user_id else {"id": user_id}
+                    for user in users
+                    for user_id in [user.id if isinstance(user, UserInfo) else user]
+                ]
+            },
+        )
+        return ManageUsersResponse.from_api(response, self.api, context=self)
+
 
 class UserRemoved(AirtableModel):
     """
@@ -352,13 +388,15 @@ class DeleteUsersResponse(AirtableModel):
         message: Optional[str] = None
 
 
-class ClaimUsersResponse(AirtableModel):
+class ManageUsersResponse(AirtableModel):
     """
-    Returned from the `Manage user membership <https://airtable.com/developers/web/api/manage-user-membership>`__
-    endpoint.
+    Returned from the `Manage user membership <https://airtable.com/developers/web/api/manage-user-membership>`__,
+    `Grant admin access <https://airtable.com/developers/web/api/grant-admin-access>`__, and
+    `Revoke admin access <https://airtable.com/developers/web/api/revoke-admin-access>`__
+    endpoints.
     """
 
-    errors: List["ClaimUsersResponse.Error"]
+    errors: List["ManageUsersResponse.Error"] = pydantic.Field(default_factory=list)
 
     class Error(AirtableModel):
         id: Optional[str] = None
diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py
index 1b4dd9f..214ca30 100644
--- a/pyairtable/models/schema.py
+++ b/pyairtable/models/schema.py
@@ -565,6 +565,8 @@ class UserInfo(
     enterprise_user_type: Optional[str]
     invited_to_airtable_by_user_id: Optional[str]
     is_managed: bool = False
+    is_admin: bool = False
+    is_super_admin: bool = False
     groups: List[NestedId] = _FL()
     collaborations: "Collaborations" = pydantic.Field(default_factory=Collaborations)
 


Test patch:
diff --git a/tests/test_api_enterprise.py b/tests/test_api_enterprise.py
index e52c676..27c3a1a 100644
--- a/tests/test_api_enterprise.py
+++ b/tests/test_api_enterprise.py
@@ -4,9 +4,9 @@ from unittest.mock import Mock, call, patch
 import pytest
 
 from pyairtable.api.enterprise import (
-    ClaimUsersResponse,
     DeleteUsersResponse,
     Enterprise,
+    ManageUsersResponse,
 )
 from pyairtable.models.schema import EnterpriseInfo, UserGroup, UserInfo
 from pyairtable.testing import fake_id
@@ -281,7 +281,7 @@ def test_claim_users(enterprise, enterprise_mocks):
             "someone@example.com": "unmanaged",
         }
     )
-    assert isinstance(result, ClaimUsersResponse)
+    assert isinstance(result, ManageUsersResponse)
     assert enterprise_mocks.claim_users.call_count == 1
     assert enterprise_mocks.claim_users.last_request.json() == {
         "users": [
@@ -310,3 +310,24 @@ def test_delete_users(enterprise, requests_mock):
     assert isinstance(parsed, DeleteUsersResponse)
     assert parsed.deleted_users[0].email == "foo@bar.com"
     assert parsed.errors[0].type == "INVALID_PERMISSIONS"
+
+
+@pytest.mark.parametrize("action", ["grant", "revoke"])
+def test_manage_admin_access(enterprise, enterprise_mocks, requests_mock, action):
+    user = enterprise.user(enterprise_mocks.user_id)
+    m = requests_mock.post(f"{enterprise.url}/users/{action}AdminAccess", json={})
+    method = getattr(enterprise, f"{action}_admin")
+    result = method(
+        fake_user_id := fake_id("usr"),
+        fake_email := "fake@example.com",
+        user,
+    )
+    assert isinstance(result, ManageUsersResponse)
+    assert m.call_count == 1
+    assert m.last_request.json() == {
+        "users": [
+            {"id": fake_user_id},
+            {"email": fake_email},
+            {"id": user.id},
+        ]
+    }