diff --git a/pyairtable/api/enterprise.py b/pyairtable/api/enterprise.py index 7fedcfd5..d897129d 100644 --- a/pyairtable/api/enterprise.py +++ b/pyairtable/api/enterprise.py @@ -81,11 +81,26 @@ def __init__(self, api: "pyairtable.api.api.Api", workspace_id: str): self._info: Optional[EnterpriseInfo] = None @cache_unless_forced - def info(self) -> EnterpriseInfo: + def info( + self, + *, + aggregated: bool = False, + descendants: bool = False, + ) -> EnterpriseInfo: """ Retrieve basic information about the enterprise, caching the result. + Calls `Get enterprise `__. + + Args: + aggregated: if ``True``, include aggregated values across the enterprise. + descendants: if ``True``, include information about the enterprise's descendant orgs. """ - params = {"include": ["collaborators", "inviteLinks"]} + include = [] + if aggregated: + include.append("aggregated") + if descendants: + include.append("descendants") + params = {"include": include} response = self.api.get(self.urls.meta, params=params) return EnterpriseInfo.from_api(response, self.api) @@ -102,7 +117,14 @@ def group(self, group_id: str, collaborations: bool = True) -> UserGroup: payload = self.api.get(self.urls.group(group_id), params=params) return UserGroup.model_validate(payload) - def user(self, id_or_email: str, collaborations: bool = True) -> UserInfo: + def user( + self, + id_or_email: str, + *, + collaborations: bool = True, + aggregated: bool = False, + descendants: bool = False, + ) -> UserInfo: """ Retrieve information on a single user with the given ID or email. @@ -110,13 +132,26 @@ def user(self, id_or_email: str, collaborations: bool = True) -> UserInfo: id_or_email: A user ID (``usrQBq2RGdihxl3vU``) or email address. collaborations: If ``False``, no collaboration data will be requested from Airtable. This may result in faster responses. + aggregated: If ``True``, includes the user's aggregated values + across this enterprise account and its descendants. + descendants: If ``True``, includes information about the user + in a ``dict`` keyed per descendant enterprise account. """ - return self.users([id_or_email], collaborations=collaborations)[0] + users = self.users( + [id_or_email], + collaborations=collaborations, + aggregated=aggregated, + descendants=descendants, + ) + return users[0] def users( self, ids_or_emails: Iterable[str], + *, collaborations: bool = True, + aggregated: bool = False, + descendants: bool = False, ) -> List[UserInfo]: """ Retrieve information on the users with the given IDs or emails. @@ -128,18 +163,30 @@ def users( or email addresses (or both). collaborations: If ``False``, no collaboration data will be requested from Airtable. This may result in faster responses. + aggregated: If ``True``, includes the user's aggregated values + across this enterprise account and its descendants. + descendants: If ``True``, includes information about the user + in a ``dict`` keyed per descendant enterprise account. """ user_ids: List[str] = [] emails: List[str] = [] for value in ids_or_emails: (emails if "@" in value else user_ids).append(value) + include = [] + if collaborations: + include.append("collaborations") + if aggregated: + include.append("aggregated") + if descendants: + include.append("descendants") + response = self.api.get( url=self.urls.users, params={ "id": user_ids, "email": emails, - "include": ["collaborations"] if collaborations else [], + "include": include, }, ) # key by user ID to avoid returning duplicates diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py index 4ac071bc..c7155ba7 100644 --- a/pyairtable/models/schema.py +++ b/pyairtable/models/schema.py @@ -440,11 +440,18 @@ class EnterpriseInfo(AirtableModel): email_domains: List["EnterpriseInfo.EmailDomain"] root_enterprise_id: str = pydantic.Field(alias="rootEnterpriseAccountId") descendant_enterprise_ids: List[str] = _FL(alias="descendantEnterpriseAccountIds") + aggregated: Optional["EnterpriseInfo.AggregatedIds"] = None + descendants: Dict[str, "EnterpriseInfo.AggregatedIds"] = _FD() class EmailDomain(AirtableModel): email_domain: str is_sso_required: bool + class AggregatedIds(AirtableModel): + group_ids: List[str] = _FL() + user_ids: List[str] = _FL() + workspace_ids: List[str] = _FL() + class WorkspaceCollaborators(_Collaborators, url="meta/workspaces/{self.id}"): """ @@ -577,10 +584,25 @@ class UserInfo( is_super_admin: bool = False groups: List[NestedId] = _FL() collaborations: "Collaborations" = _F("Collaborations") + descendants: Dict[str, "UserInfo.DescendantIds"] = _FD() + aggregated: Optional["UserInfo.AggregatedIds"] = None def logout(self) -> None: self._api.post(self._url + "/logout") + class DescendantIds(AirtableModel): + last_activity_time: Optional[datetime] = None + collaborations: Optional["Collaborations"] = None + is_admin: bool = False + is_managed: bool = False + groups: List[NestedId] = _FL() + + class AggregatedIds(AirtableModel): + last_activity_time: Optional[datetime] = None + collaborations: Optional["Collaborations"] = None + is_admin: bool = False + groups: List[NestedId] = _FL() + class UserGroup(AirtableModel): """ diff --git a/pyairtable/utils.py b/pyairtable/utils.py index 4b6e66fb..a08d0259 100644 --- a/pyairtable/utils.py +++ b/pyairtable/utils.py @@ -233,9 +233,9 @@ def cache_unless_forced(func: Callable[[C], R]) -> _FetchMethod[C, R]: attr = "_cached_" + attr.lstrip("_") @wraps(func) - def _inner(self: C, *, force: bool = False) -> R: + def _inner(self: C, *, force: bool = False, **kwargs: Any) -> R: if force or getattr(self, attr, None) is None: - setattr(self, attr, func(self)) + setattr(self, attr, func(self, **kwargs)) return cast(R, getattr(self, attr)) _inner.__annotations__["force"] = bool diff --git a/scripts/find_model_changes.py b/scripts/find_model_changes.py index 2988d7f6..b82d7d60 100644 --- a/scripts/find_model_changes.py +++ b/scripts/find_model_changes.py @@ -38,7 +38,7 @@ "pyairtable.models.schema:BaseCollaborators": "operations:get-base-collaborators:response:schema", "pyairtable.models.schema:BaseCollaborators.IndividualCollaborators": "operations:get-base-collaborators:response:schema:@individualCollaborators", "pyairtable.models.schema:BaseCollaborators.GroupCollaborators": "operations:get-base-collaborators:response:schema:@groupCollaborators", - "pyairtable.models.schema:BaseCollaborators.InterfaceCollaborators": "operations:get-base-collaborators:response:schema:@interfaces:additionalProperties", + "pyairtable.models.schema:BaseCollaborators.InterfaceCollaborators": "operations:get-base-collaborators:response:schema:@interfaces:*", "pyairtable.models.schema:BaseCollaborators.InviteLinks": "operations:get-base-collaborators:response:schema:@inviteLinks", "pyairtable.models.schema:BaseShares": "operations:list-shares:response:schema", "pyairtable.models.schema:BaseShares.Info": "operations:list-shares:response:schema:@shares:items", @@ -48,6 +48,7 @@ "pyairtable.models.schema:InterfaceInviteLink": "schemas:invite-link", "pyairtable.models.schema:EnterpriseInfo": "operations:get-enterprise:response:schema", "pyairtable.models.schema:EnterpriseInfo.EmailDomain": "operations:get-enterprise:response:schema:@emailDomains:items", + "pyairtable.models.schema:EnterpriseInfo.AggregatedIds": "operations:get-enterprise:response:schema:@aggregated", "pyairtable.models.schema:WorkspaceCollaborators": "operations:get-workspace-collaborators:response:schema", "pyairtable.models.schema:WorkspaceCollaborators.Restrictions": "operations:get-workspace-collaborators:response:schema:@workspaceRestrictions", "pyairtable.models.schema:WorkspaceCollaborators.GroupCollaborators": "operations:get-workspace-collaborators:response:schema:@groupCollaborators", @@ -63,6 +64,8 @@ "pyairtable.models.schema:Collaborations.InterfaceCollaboration": "schemas:collaborations:@interfaceCollaborations:items", "pyairtable.models.schema:Collaborations.WorkspaceCollaboration": "schemas:collaborations:@workspaceCollaborations:items", "pyairtable.models.schema:UserInfo": "operations:get-user-by-id:response:schema", + "pyairtable.models.schema:UserInfo.AggregatedIds": "operations:get-user-by-id:response:schema:@aggregated", + "pyairtable.models.schema:UserInfo.DescendantIds": "operations:get-user-by-id:response:schema:@descendants:*", "pyairtable.models.schema:UserGroup": "operations:get-user-group:response:schema", "pyairtable.models.schema:UserGroup.Member": "operations:get-user-group:response:schema:@members:items", "pyairtable.models.webhook:Webhook": "operations:list-webhooks:response:schema:@webhooks:items", @@ -71,15 +74,15 @@ "pyairtable.models.webhook:WebhookPayloads": "operations:list-webhook-payloads:response:schema", "pyairtable.models.webhook:WebhookPayload": "schemas:webhooks-payload", "pyairtable.models.webhook:WebhookPayload.ActionMetadata": "schemas:webhooks-action", - "pyairtable.models.webhook:WebhookPayload.FieldChanged": "schemas:webhooks-table-changed:@changedFieldsById:additionalProperties", - "pyairtable.models.webhook:WebhookPayload.FieldInfo": "schemas:webhooks-table-changed:@changedFieldsById:additionalProperties:@current", - "pyairtable.models.webhook:WebhookPayload.RecordChanged": "schemas:webhooks-changed-record:additionalProperties", - "pyairtable.models.webhook:WebhookPayload.RecordCreated": "schemas:webhooks-created-record:additionalProperties", + "pyairtable.models.webhook:WebhookPayload.FieldChanged": "schemas:webhooks-table-changed:@changedFieldsById:*", + "pyairtable.models.webhook:WebhookPayload.FieldInfo": "schemas:webhooks-table-changed:@changedFieldsById:*:@current", + "pyairtable.models.webhook:WebhookPayload.RecordChanged": "schemas:webhooks-changed-record:*", + "pyairtable.models.webhook:WebhookPayload.RecordCreated": "schemas:webhooks-created-record:*", "pyairtable.models.webhook:WebhookPayload.TableChanged": "schemas:webhooks-table-changed", "pyairtable.models.webhook:WebhookPayload.TableChanged.ChangedMetadata": "schemas:webhooks-table-changed:@changedMetadata", "pyairtable.models.webhook:WebhookPayload.TableInfo": "schemas:webhooks-table-changed:@changedMetadata:@current", "pyairtable.models.webhook:WebhookPayload.TableCreated": "schemas:webhooks-table-created", - "pyairtable.models.webhook:WebhookPayload.ViewChanged": "schemas:webhooks-table-changed:@changedViewsById:additionalProperties", + "pyairtable.models.webhook:WebhookPayload.ViewChanged": "schemas:webhooks-table-changed:@changedViewsById:*", "pyairtable.models.webhook:CreateWebhook": "operations:create-a-webhook:request:schema", "pyairtable.models.webhook:CreateWebhookResponse": "operations:create-a-webhook:response:schema", "pyairtable.models.webhook:WebhookSpecification": "operations:create-a-webhook:request:schema:@specification", @@ -115,6 +118,7 @@ def main() -> None: model_module = importlib.import_module(modname) model_cls = attrgetter(clsname)(model_module) initdata_path = initdata_path.replace(":@", ":properties:") + initdata_path = re.sub(r":\*(:|$)", r":additionalProperties\1", initdata_path) issues.extend(scan_schema(model_cls, initdata.get_nested(initdata_path))) if not issues: @@ -169,14 +173,16 @@ def get_nested(self, path: str, separator: str = ":") -> Any: """ get_from = self traversed = [] - while separator in path: - next_key, path = path.split(separator, 1) - traversed.append(next_key) - try: + try: + while separator in path: + next_key, path = path.split(separator, 1) + traversed.append(next_key) get_from = get_from[next_key] - except KeyError: - raise KeyError(*traversed) - return get_from[path] + traversed.append(path) + return get_from[path] + except KeyError as exc: + exc.args = tuple(traversed) + raise exc @cached_property def by_operation(self) -> Dict[str, Dict[str, Any]]: diff --git a/tests/sample_data/EnterpriseInfo.json b/tests/sample_data/EnterpriseInfo.json index 4ff1caa8..bd848286 100644 --- a/tests/sample_data/EnterpriseInfo.json +++ b/tests/sample_data/EnterpriseInfo.json @@ -1,5 +1,8 @@ { "createdTime": "2019-01-03T12:33:12.421Z", + "descendantEnterpriseAccountIds": [ + "entJ9ZQ5vz9ZQ5vz9" + ], "emailDomains": [ { "emailDomain": "foobar.com", diff --git a/tests/sample_data/UserInfo.json b/tests/sample_data/UserInfo.json index 359dee0b..71bf12f4 100644 --- a/tests/sample_data/UserInfo.json +++ b/tests/sample_data/UserInfo.json @@ -12,7 +12,7 @@ { "baseId": "appLkNDICXNqxSDhG", "createdTime": "2019-01-03T12:33:12.421Z", - "grantedByUserId": "usrqccqnMB2eHylqB", + "grantedByUserId": "usrogvSbotRtzdtZW", "interfaceId": "pbdyGA3PsOziEHPDE", "permissionLevel": "edit" } @@ -38,6 +38,7 @@ ], "id": "usrL2PNC5o3H4lBEi", "invitedToAirtableByUserId": "usrsOEchC9xuwRgKk", + "isAdmin": true, "isManaged": true, "isServiceAccount": false, "isSsoRequired": true, diff --git a/tests/test_api_enterprise.py b/tests/test_api_enterprise.py index 86555b4d..f202039e 100644 --- a/tests/test_api_enterprise.py +++ b/tests/test_api_enterprise.py @@ -1,4 +1,4 @@ -import datetime +from datetime import datetime, timezone from unittest.mock import Mock, call, patch import pytest @@ -17,11 +17,12 @@ def enterprise_mocks(enterprise, requests_mock, sample_json): m.json_user = sample_json("UserInfo") m.json_users = {"users": [m.json_user]} m.json_group = sample_json("UserGroup") + m.json_enterprise = sample_json("EnterpriseInfo") m.user_id = m.json_user["id"] m.group_id = m.json_group["id"] m.get_info = requests_mock.get( enterprise_url := "https://api.airtable.com/v0/meta/enterpriseAccounts/entUBq2RGdihxl3vU", - json=sample_json("EnterpriseInfo"), + json=m.json_enterprise, ) m.get_user = requests_mock.get( f"{enterprise_url}/users/usrL2PNC5o3H4lBEi", @@ -64,7 +65,7 @@ def fake_audit_log_events(counter, page_size=N_AUDIT_PAGE_SIZE): return [ { "id": str(counter * 1000 + n), - "timestamp": datetime.datetime.now().isoformat(), + "timestamp": datetime.now().isoformat(), "action": "viewBase", "actor": {"type": "anonymousUser"}, "modelId": (base_id := fake_id("app")), @@ -92,6 +93,35 @@ def test_info(enterprise, enterprise_mocks): assert enterprise.info(force=True).id == "entUBq2RGdihxl3vU" assert enterprise_mocks.get_info.call_count == 2 + assert "aggregated" not in enterprise_mocks.get_info.last_request.qs + assert "descendants" not in enterprise_mocks.get_info.last_request.qs + + +def test_info__aggregated_descendants(enterprise, enterprise_mocks): + enterprise_mocks.json_enterprise["aggregated"] = { + "groupIds": ["ugp1mKGb3KXUyQfOZ"], + "userIds": ["usrL2PNC5o3H4lBEi"], + "workspaceIds": ["wspmhESAta6clCCwF"], + } + enterprise_mocks.json_enterprise["descendants"] = { + (sub_ent_id := fake_id("ent")): { + "groupIds": ["ugp1mKGb3KXUyDESC"], + "userIds": ["usrL2PNC5o3H4DESC"], + "workspaceIds": ["wspmhESAta6clDESC"], + } + } + info = enterprise.info(aggregated=True, descendants=True) + assert enterprise_mocks.get_info.call_count == 1 + assert enterprise_mocks.get_info.last_request.qs["include"] == [ + "aggregated", + "descendants", + ] + assert info.aggregated.group_ids == ["ugp1mKGb3KXUyQfOZ"] + assert info.aggregated.user_ids == ["usrL2PNC5o3H4lBEi"] + assert info.aggregated.workspace_ids == ["wspmhESAta6clCCwF"] + assert info.descendants[sub_ent_id].group_ids == ["ugp1mKGb3KXUyDESC"] + assert info.descendants[sub_ent_id].user_ids == ["usrL2PNC5o3H4DESC"] + assert info.descendants[sub_ent_id].workspace_ids == ["wspmhESAta6clDESC"] def test_user(enterprise, enterprise_mocks): @@ -117,6 +147,34 @@ def test_user__no_collaboration(enterprise, enterprise_mocks): assert not user.collaborations.workspaces +def test_user__descendants(enterprise, enterprise_mocks): + enterprise_mocks.json_users["users"][0]["descendants"] = { + (other_ent_id := fake_id("ent")): { + "lastActivityTime": "2021-01-01T12:34:56Z", + "isAdmin": True, + "groups": [{"id": (fake_group_id := fake_id("ugp"))}], + } + } + user = enterprise.user(enterprise_mocks.user_id, descendants=True) + d = user.descendants[other_ent_id] + assert d.last_activity_time == datetime(2021, 1, 1, 12, 34, 56, tzinfo=timezone.utc) + assert d.is_admin is True + assert d.groups[0].id == fake_group_id + + +def test_user__aggregates(enterprise, enterprise_mocks): + enterprise_mocks.json_users["users"][0]["aggregated"] = { + "lastActivityTime": "2021-01-01T12:34:56Z", + "isAdmin": True, + "groups": [{"id": (fake_group_id := fake_id("ugp"))}], + } + user = enterprise.user(enterprise_mocks.user_id, aggregated=True) + a = user.aggregated + assert a.last_activity_time == datetime(2021, 1, 1, 12, 34, 56, tzinfo=timezone.utc) + assert a.is_admin is True + assert a.groups[0].id == fake_group_id + + @pytest.mark.parametrize( "search_for", ( @@ -133,6 +191,36 @@ def test_users(enterprise, search_for): assert user.state == "provisioned" +def test_users__descendants(enterprise, enterprise_mocks): + enterprise_mocks.json_users["users"][0]["descendants"] = { + (other_ent_id := fake_id("ent")): { + "lastActivityTime": "2021-01-01T12:34:56Z", + "isAdmin": True, + "groups": [{"id": (fake_group_id := fake_id("ugp"))}], + } + } + users = enterprise.users([enterprise_mocks.user_id], descendants=True) + assert len(users) == 1 + d = users[0].descendants[other_ent_id] + assert d.last_activity_time == datetime(2021, 1, 1, 12, 34, 56, tzinfo=timezone.utc) + assert d.is_admin is True + assert d.groups[0].id == fake_group_id + + +def test_users__aggregates(enterprise, enterprise_mocks): + enterprise_mocks.json_users["users"][0]["aggregated"] = { + "lastActivityTime": "2021-01-01T12:34:56Z", + "isAdmin": True, + "groups": [{"id": (fake_group_id := fake_id("ugp"))}], + } + users = enterprise.users([enterprise_mocks.user_id], aggregated=True) + assert len(users) == 1 + a = users[0].aggregated + assert a.last_activity_time == datetime(2021, 1, 1, 12, 34, 56, tzinfo=timezone.utc) + assert a.is_admin is True + assert a.groups[0].id == fake_group_id + + def test_group(enterprise, enterprise_mocks): grp = enterprise.group("ugp1mKGb3KXUyQfOZ") assert enterprise_mocks.get_group.call_count == 1