Skip to content

Commit 2dc1bba

Browse files
authored
Merge pull request #406 from mesozoic/release-2.3.6
Fix enterprise.info and enterprise.users after new API params
2 parents ed7ca18 + 52266ad commit 2dc1bba

File tree

8 files changed

+182
-10
lines changed

8 files changed

+182
-10
lines changed

docs/source/changelog.rst

+8
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22
Changelog
33
=========
44

5+
2.3.6 (2024-11-11)
6+
------------------------
7+
8+
* Fix for `#404 <https://github.com/gtalarico/pyairtable/issues/404>`_
9+
related to `enterprise endpoint changes <https://airtable.com/developers/web/api/changelog#anchor-2024-11-11>`__.
10+
- `PR #405 <https://github.com/gtalarico/pyairtable/pull/405>`_,
11+
`PR #406 <https://github.com/gtalarico/pyairtable/pull/406>`_
12+
513
2.3.5 (2024-10-29)
614
------------------------
715

pyairtable/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
__version__ = "2.3.5"
1+
__version__ = "2.3.6"
22

33
from .api import Api, Base, Table
44
from .api.enterprise import Enterprise

pyairtable/api/enterprise.py

+52-5
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,26 @@ def url(self) -> str:
3232
return self.api.build_url("meta/enterpriseAccounts", self.id)
3333

3434
@cache_unless_forced
35-
def info(self) -> EnterpriseInfo:
35+
def info(
36+
self,
37+
*,
38+
aggregated: bool = False,
39+
descendants: bool = False,
40+
) -> EnterpriseInfo:
3641
"""
3742
Retrieve basic information about the enterprise, caching the result.
43+
Calls `Get enterprise <https://airtable.com/developers/web/api/get-enterprise>`__.
44+
45+
Args:
46+
aggregated: if ``True``, include aggregated values across the enterprise.
47+
descendants: if ``True``, include information about the enterprise's descendant orgs.
3848
"""
39-
params = {"include": ["collaborators", "inviteLinks"]}
49+
include = []
50+
if aggregated:
51+
include.append("aggregated")
52+
if descendants:
53+
include.append("descendants")
54+
params = {"include": include}
4055
response = self.api.get(self.url, params=params)
4156
return EnterpriseInfo.from_api(response, self.api)
4257

@@ -54,21 +69,41 @@ def group(self, group_id: str, collaborations: bool = True) -> UserGroup:
5469
payload = self.api.get(url, params=params)
5570
return UserGroup.parse_obj(payload)
5671

57-
def user(self, id_or_email: str, collaborations: bool = True) -> UserInfo:
72+
def user(
73+
self,
74+
id_or_email: str,
75+
*,
76+
collaborations: bool = True,
77+
aggregated: bool = False,
78+
descendants: bool = False,
79+
) -> UserInfo:
5880
"""
5981
Retrieve information on a single user with the given ID or email.
6082
6183
Args:
6284
id_or_email: A user ID (``usrQBq2RGdihxl3vU``) or email address.
6385
collaborations: If ``False``, no collaboration data will be requested
6486
from Airtable. This may result in faster responses.
87+
aggregated: If ``True``, includes the user's aggregated values
88+
across this enterprise account and its descendants.
89+
descendants: If ``True``, includes information about the user
90+
in a ``dict`` keyed per descendant enterprise account.
6591
"""
66-
return self.users([id_or_email], collaborations=collaborations)[0]
92+
users = self.users(
93+
[id_or_email],
94+
collaborations=collaborations,
95+
aggregated=aggregated,
96+
descendants=descendants,
97+
)
98+
return users[0]
6799

68100
def users(
69101
self,
70102
ids_or_emails: Iterable[str],
103+
*,
71104
collaborations: bool = True,
105+
aggregated: bool = False,
106+
descendants: bool = False,
72107
) -> List[UserInfo]:
73108
"""
74109
Retrieve information on the users with the given IDs or emails.
@@ -80,18 +115,30 @@ def users(
80115
or email addresses (or both).
81116
collaborations: If ``False``, no collaboration data will be requested
82117
from Airtable. This may result in faster responses.
118+
aggregated: If ``True``, includes the user's aggregated values
119+
across this enterprise account and its descendants.
120+
descendants: If ``True``, includes information about the user
121+
in a ``dict`` keyed per descendant enterprise account.
83122
"""
84123
user_ids: List[str] = []
85124
emails: List[str] = []
86125
for value in ids_or_emails:
87126
(emails if "@" in value else user_ids).append(value)
88127

128+
include = []
129+
if collaborations:
130+
include.append("collaborations")
131+
if aggregated:
132+
include.append("aggregated")
133+
if descendants:
134+
include.append("descendants")
135+
89136
response = self.api.get(
90137
url=f"{self.url}/users",
91138
params={
92139
"id": user_ids,
93140
"email": emails,
94-
"include": ["collaborations"] if collaborations else [],
141+
"include": include,
95142
},
96143
)
97144
# key by user ID to avoid returning duplicates

pyairtable/models/schema.py

+24
Original file line numberDiff line numberDiff line change
@@ -432,11 +432,20 @@ class EnterpriseInfo(AirtableModel):
432432
user_ids: List[str]
433433
workspace_ids: List[str]
434434
email_domains: List["EnterpriseInfo.EmailDomain"]
435+
root_enterprise_id: str = pydantic.Field(alias="rootEnterpriseAccountId")
436+
descendant_enterprise_ids: List[str] = _FL(alias="descendantEnterpriseAccountIds")
437+
aggregated: Optional["EnterpriseInfo.AggregatedIds"] = None
438+
descendants: Dict[str, "EnterpriseInfo.AggregatedIds"] = _FD()
435439

436440
class EmailDomain(AirtableModel):
437441
email_domain: str
438442
is_sso_required: bool
439443

444+
class AggregatedIds(AirtableModel):
445+
group_ids: List[str] = _FL()
446+
user_ids: List[str] = _FL()
447+
workspace_ids: List[str] = _FL()
448+
440449

441450
class WorkspaceCollaborators(_Collaborators, url="meta/workspaces/{self.id}"):
442451
"""
@@ -566,10 +575,25 @@ class UserInfo(
566575
is_managed: bool = False
567576
groups: List[NestedId] = _FL()
568577
collaborations: "Collaborations" = pydantic.Field(default_factory=Collaborations)
578+
descendants: Dict[str, "UserInfo.DescendantIds"] = _FD()
579+
aggregated: Optional["UserInfo.AggregatedIds"] = None
569580

570581
def logout(self) -> None:
571582
self._api.post(self._url + "/logout")
572583

584+
class DescendantIds(AirtableModel):
585+
last_activity_time: Optional[str] = None
586+
collaborations: Optional["Collaborations"] = None
587+
is_admin: bool = False
588+
is_managed: bool = False
589+
groups: List[NestedId] = _FL()
590+
591+
class AggregatedIds(AirtableModel):
592+
last_activity_time: Optional[str] = None
593+
collaborations: Optional["Collaborations"] = None
594+
is_admin: bool = False
595+
groups: List[NestedId] = _FL()
596+
573597

574598
class UserGroup(AirtableModel):
575599
"""

pyairtable/utils.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -210,9 +210,9 @@ def cache_unless_forced(func: Callable[[C], R]) -> FetchMethod[C, R]:
210210
attr = "_cached_" + attr.lstrip("_")
211211

212212
@wraps(func)
213-
def _inner(self: C, *, force: bool = False) -> R:
213+
def _inner(self: C, *, force: bool = False, **kwargs: Any) -> R:
214214
if force or getattr(self, attr, None) is None:
215-
setattr(self, attr, func(self))
215+
setattr(self, attr, func(self, **kwargs))
216216
return cast(R, getattr(self, attr))
217217

218218
_inner.__annotations__["force"] = bool

tests/sample_data/EnterpriseInfo.json

+4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
{
22
"createdTime": "2019-01-03T12:33:12.421Z",
3+
"descendantEnterpriseAccountIds": [
4+
"entJ9ZQ5vz9ZQ5vz9"
5+
],
36
"emailDomains": [
47
{
58
"emailDomain": "foobar.com",
@@ -11,6 +14,7 @@
1114
"ugpR8ZT9KtIgp8Bh3"
1215
],
1316
"id": "entUBq2RGdihxl3vU",
17+
"rootEnterpriseAccountId": "entUBq2RGdihxl3vU",
1418
"userIds": [
1519
"usrL2PNC5o3H4lBEi",
1620
"usrsOEchC9xuwRgKk",

tests/sample_data/UserInfo.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
{
1313
"baseId": "appLkNDICXNqxSDhG",
1414
"createdTime": "2019-01-03T12:33:12.421Z",
15-
"grantedByUserId": "usrqccqnMB2eHylqB",
15+
"grantedByUserId": "usrogvSbotRtzdtZW",
1616
"interfaceId": "pbdyGA3PsOziEHPDE",
1717
"permissionLevel": "edit"
1818
}
@@ -38,6 +38,7 @@
3838
],
3939
"id": "usrL2PNC5o3H4lBEi",
4040
"invitedToAirtableByUserId": "usrsOEchC9xuwRgKk",
41+
"isAdmin": true,
4142
"isManaged": true,
4243
"isSsoRequired": true,
4344
"isTwoFactorAuthEnabled": false,

tests/test_api_enterprise.py

+89-1
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,10 @@ def enterprise_mocks(enterprise, requests_mock, sample_json):
2727
m.json_user = sample_json("UserInfo")
2828
m.json_users = {"users": [m.json_user]}
2929
m.json_group = sample_json("UserGroup")
30+
m.json_enterprise = sample_json("EnterpriseInfo")
3031
m.user_id = m.json_user["id"]
3132
m.group_id = m.json_group["id"]
32-
m.get_info = requests_mock.get(enterprise.url, json=sample_json("EnterpriseInfo"))
33+
m.get_info = requests_mock.get(enterprise.url, json=m.json_enterprise)
3334
m.get_user = requests_mock.get(
3435
f"{enterprise.url}/users/{m.user_id}", json=m.json_user
3536
)
@@ -97,6 +98,35 @@ def test_info(enterprise, enterprise_mocks):
9798

9899
assert enterprise.info(force=True).id == "entUBq2RGdihxl3vU"
99100
assert enterprise_mocks.get_info.call_count == 2
101+
assert "aggregated" not in enterprise_mocks.get_info.last_request.qs
102+
assert "descendants" not in enterprise_mocks.get_info.last_request.qs
103+
104+
105+
def test_info__aggregated_descendants(enterprise, enterprise_mocks):
106+
enterprise_mocks.json_enterprise["aggregated"] = {
107+
"groupIds": ["ugp1mKGb3KXUyQfOZ"],
108+
"userIds": ["usrL2PNC5o3H4lBEi"],
109+
"workspaceIds": ["wspmhESAta6clCCwF"],
110+
}
111+
enterprise_mocks.json_enterprise["descendants"] = {
112+
(sub_ent_id := fake_id("ent")): {
113+
"groupIds": ["ugp1mKGb3KXUyDESC"],
114+
"userIds": ["usrL2PNC5o3H4DESC"],
115+
"workspaceIds": ["wspmhESAta6clDESC"],
116+
}
117+
}
118+
info = enterprise.info(aggregated=True, descendants=True)
119+
assert enterprise_mocks.get_info.call_count == 1
120+
assert enterprise_mocks.get_info.last_request.qs["include"] == [
121+
"aggregated",
122+
"descendants",
123+
]
124+
assert info.aggregated.group_ids == ["ugp1mKGb3KXUyQfOZ"]
125+
assert info.aggregated.user_ids == ["usrL2PNC5o3H4lBEi"]
126+
assert info.aggregated.workspace_ids == ["wspmhESAta6clCCwF"]
127+
assert info.descendants[sub_ent_id].group_ids == ["ugp1mKGb3KXUyDESC"]
128+
assert info.descendants[sub_ent_id].user_ids == ["usrL2PNC5o3H4DESC"]
129+
assert info.descendants[sub_ent_id].workspace_ids == ["wspmhESAta6clDESC"]
100130

101131

102132
def test_user(enterprise, enterprise_mocks):
@@ -122,6 +152,34 @@ def test_user__no_collaboration(enterprise, enterprise_mocks):
122152
assert not user.collaborations.workspaces
123153

124154

155+
def test_user__descendants(enterprise, enterprise_mocks):
156+
enterprise_mocks.json_users["users"][0]["descendants"] = {
157+
(other_ent_id := fake_id("ent")): {
158+
"lastActivityTime": "2021-01-01T12:34:56Z",
159+
"isAdmin": True,
160+
"groups": [{"id": (fake_group_id := fake_id("ugp"))}],
161+
}
162+
}
163+
user = enterprise.user(enterprise_mocks.user_id, descendants=True)
164+
d = user.descendants[other_ent_id]
165+
assert d.last_activity_time == "2021-01-01T12:34:56Z"
166+
assert d.is_admin is True
167+
assert d.groups[0].id == fake_group_id
168+
169+
170+
def test_user__aggregates(enterprise, enterprise_mocks):
171+
enterprise_mocks.json_users["users"][0]["aggregated"] = {
172+
"lastActivityTime": "2021-01-01T12:34:56Z",
173+
"isAdmin": True,
174+
"groups": [{"id": (fake_group_id := fake_id("ugp"))}],
175+
}
176+
user = enterprise.user(enterprise_mocks.user_id, aggregated=True)
177+
a = user.aggregated
178+
assert a.last_activity_time == "2021-01-01T12:34:56Z"
179+
assert a.is_admin is True
180+
assert a.groups[0].id == fake_group_id
181+
182+
125183
@pytest.mark.parametrize(
126184
"search_for",
127185
(
@@ -138,6 +196,36 @@ def test_users(enterprise, search_for):
138196
assert user.state == "provisioned"
139197

140198

199+
def test_users__descendants(enterprise, enterprise_mocks):
200+
enterprise_mocks.json_users["users"][0]["descendants"] = {
201+
(other_ent_id := fake_id("ent")): {
202+
"lastActivityTime": "2021-01-01T12:34:56Z",
203+
"isAdmin": True,
204+
"groups": [{"id": (fake_group_id := fake_id("ugp"))}],
205+
}
206+
}
207+
users = enterprise.users([enterprise_mocks.user_id], descendants=True)
208+
assert len(users) == 1
209+
d = users[0].descendants[other_ent_id]
210+
assert d.last_activity_time == "2021-01-01T12:34:56Z"
211+
assert d.is_admin is True
212+
assert d.groups[0].id == fake_group_id
213+
214+
215+
def test_users__aggregates(enterprise, enterprise_mocks):
216+
enterprise_mocks.json_users["users"][0]["aggregated"] = {
217+
"lastActivityTime": "2021-01-01T12:34:56Z",
218+
"isAdmin": True,
219+
"groups": [{"id": (fake_group_id := fake_id("ugp"))}],
220+
}
221+
users = enterprise.users([enterprise_mocks.user_id], aggregated=True)
222+
assert len(users) == 1
223+
a = users[0].aggregated
224+
assert a.last_activity_time == "2021-01-01T12:34:56Z"
225+
assert a.is_admin is True
226+
assert a.groups[0].id == fake_group_id
227+
228+
141229
def test_group(enterprise, enterprise_mocks):
142230
grp = enterprise.group("ugp1mKGb3KXUyQfOZ")
143231
assert enterprise_mocks.get_group.call_count == 1

0 commit comments

Comments
 (0)