Skip to content

Commit 5654eb2

Browse files
authored
Add users invite batch support (#323)
1 parent c019102 commit 5654eb2

File tree

5 files changed

+204
-3
lines changed

5 files changed

+204
-3
lines changed

.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -130,3 +130,5 @@ dmypy.json
130130

131131
# Mac OS
132132
.DS_Store
133+
134+
.history

README.md

+18
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,24 @@ descope_client.mgmt.user.invite(
557557
],
558558
)
559559

560+
# Batch invite
561+
descope_client.mgmt.user.invite_batch(
562+
users=[
563+
UserObj(
564+
login_id="[email protected]",
565+
566+
display_name="Desmond Copeland",
567+
user_tenants=[
568+
AssociatedTenant("my-tenant-id", ["role-name1"]),
569+
],
570+
custom_attributes={"ak": "av"},
571+
)
572+
],
573+
invite_url="invite.me",
574+
send_mail=True,
575+
send_sms=True,
576+
)
577+
560578
# Update will override all fields as is. Use carefully.
561579
descope_client.mgmt.user.update(
562580
login_id="[email protected]",

descope/management/common.py

+1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ class MgmtV1:
1212

1313
# user
1414
user_create_path = "/v1/mgmt/user/create"
15+
user_create_batch_path = "/v1/mgmt/user/create/batch"
1516
user_update_path = "/v1/mgmt/user/update"
1617
user_delete_path = "/v1/mgmt/user/delete"
1718
user_logout_path = "/v1/mgmt/user/logout"

descope/management/user.py

+111-3
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,40 @@
1111
)
1212

1313

14+
class UserObj:
15+
def __init__(
16+
self,
17+
login_id: str,
18+
email: Optional[str] = None,
19+
phone: Optional[str] = None,
20+
display_name: Optional[str] = None,
21+
given_name: Optional[str] = None,
22+
middle_name: Optional[str] = None,
23+
family_name: Optional[str] = None,
24+
role_names: Optional[List[str]] = None,
25+
user_tenants: Optional[List[AssociatedTenant]] = None,
26+
picture: Optional[str] = None,
27+
custom_attributes: Optional[dict] = None,
28+
verified_email: Optional[bool] = None,
29+
verified_phone: Optional[bool] = None,
30+
additional_login_ids: Optional[List[str]] = None,
31+
):
32+
self.login_id = login_id
33+
self.email = email
34+
self.phone = phone
35+
self.display_name = display_name
36+
self.given_name = given_name
37+
self.middle_name = middle_name
38+
self.family_name = family_name
39+
self.role_names = role_names
40+
self.user_tenants = user_tenants
41+
self.picture = picture
42+
self.custom_attributes = custom_attributes
43+
self.verified_email = verified_email
44+
self.verified_phone = verified_phone
45+
self.additional_login_ids = additional_login_ids
46+
47+
1448
class User(AuthBase):
1549
def create(
1650
self,
@@ -181,13 +215,13 @@ def invite(
181215
additional_login_ids: Optional[List[str]] = None,
182216
) -> dict:
183217
"""
184-
Create a new user and invite them via an email message.
218+
Create a new user and invite them via an email / text message.
185219
186220
Functions exactly the same as the `create` function with the additional invitation
187221
behavior. See the documentation above for the general creation behavior.
188222
189-
IMPORTANT: Since the invitation is sent by email, make sure either
190-
the email is explicitly set, or the login_id itself is an email address.
223+
IMPORTANT: Since the invitation is sent by email / phone, make sure either
224+
the email / phone is explicitly set, or the login_id itself is an email address / phone number.
191225
You must configure the invitation URL in the Descope console prior to
192226
calling the method.
193227
"""
@@ -221,6 +255,41 @@ def invite(
221255
)
222256
return response.json()
223257

258+
def invite_batch(
259+
self,
260+
users: List[UserObj],
261+
invite_url: Optional[str] = None,
262+
send_mail: Optional[
263+
bool
264+
] = None, # send invite via mail, default is according to project settings
265+
send_sms: Optional[
266+
bool
267+
] = None, # send invite via text message, default is according to project settings
268+
) -> dict:
269+
"""
270+
Create users in batch and invite them via an email / text message.
271+
272+
Functions exactly the same as the `create` function with the additional invitation
273+
behavior. See the documentation above for the general creation behavior.
274+
275+
IMPORTANT: Since the invitation is sent by email / phone, make sure either
276+
the email / phone is explicitly set, or the login_id itself is an email address / phone number.
277+
You must configure the invitation URL in the Descope console prior to
278+
calling the method.
279+
"""
280+
281+
response = self._auth.do_post(
282+
MgmtV1.user_create_batch_path,
283+
User._compose_create_batch_body(
284+
users,
285+
invite_url,
286+
send_mail,
287+
send_sms,
288+
),
289+
pswd=self._auth.management_key,
290+
)
291+
return response.json()
292+
224293
def update(
225294
self,
226295
login_id: str,
@@ -1193,6 +1262,45 @@ def _compose_create_body(
11931262
body["sendSMS"] = send_sms
11941263
return body
11951264

1265+
@staticmethod
1266+
def _compose_create_batch_body(
1267+
users: List[UserObj],
1268+
invite_url: Optional[str],
1269+
send_mail: Optional[bool],
1270+
send_sms: Optional[bool],
1271+
) -> dict:
1272+
usersBody = []
1273+
for user in users:
1274+
role_names = [] if user.role_names is None else user.role_names
1275+
user_tenants = [] if user.user_tenants is None else user.user_tenants
1276+
uBody = User._compose_update_body(
1277+
login_id=user.login_id,
1278+
email=user.email,
1279+
phone=user.phone,
1280+
display_name=user.display_name,
1281+
given_name=user.given_name,
1282+
middle_name=user.middle_name,
1283+
family_name=user.family_name,
1284+
role_names=role_names,
1285+
user_tenants=user_tenants,
1286+
picture=user.picture,
1287+
custom_attributes=user.custom_attributes,
1288+
additional_login_ids=user.additional_login_ids,
1289+
verified_email=user.verified_email,
1290+
verified_phone=user.verified_phone,
1291+
test=False,
1292+
)
1293+
usersBody.append(uBody)
1294+
1295+
body = {"users": usersBody, "invite": True}
1296+
if invite_url is not None:
1297+
body["inviteUrl"] = invite_url
1298+
if send_mail is not None:
1299+
body["sendMail"] = send_mail
1300+
if send_sms is not None:
1301+
body["sendSMS"] = send_sms
1302+
return body
1303+
11961304
@staticmethod
11971305
def _compose_update_body(
11981306
login_id: str,

tests/management/test_user.py

+72
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from descope import AssociatedTenant, AuthException, DescopeClient
66
from descope.common import DEFAULT_TIMEOUT_SECONDS, DeliveryMethod, LoginOptions
77
from descope.management.common import MgmtV1
8+
from descope.management.user import UserObj
89

910
from .. import common
1011

@@ -256,6 +257,77 @@ def test_invite(self):
256257
timeout=DEFAULT_TIMEOUT_SECONDS,
257258
)
258259

260+
def test_invite_batch(self):
261+
# Test failed flows
262+
with patch("requests.post") as mock_post:
263+
mock_post.return_value.ok = False
264+
self.assertRaises(
265+
AuthException,
266+
self.client.mgmt.user.invite_batch,
267+
[],
268+
)
269+
270+
# Test success flow
271+
with patch("requests.post") as mock_post:
272+
network_resp = mock.Mock()
273+
network_resp.ok = True
274+
network_resp.json.return_value = json.loads("""{"users": [{"id": "u1"}]}""")
275+
mock_post.return_value = network_resp
276+
resp = self.client.mgmt.user.invite_batch(
277+
users=[
278+
UserObj(
279+
login_id="[email protected]",
280+
281+
display_name="Name",
282+
user_tenants=[
283+
AssociatedTenant("tenant1"),
284+
AssociatedTenant("tenant2", ["role1", "role2"]),
285+
],
286+
custom_attributes={"ak": "av"},
287+
)
288+
],
289+
invite_url="invite.me",
290+
send_sms=True,
291+
)
292+
users = resp["users"]
293+
self.assertEqual(users[0]["id"], "u1")
294+
mock_post.assert_called_with(
295+
f"{common.DEFAULT_BASE_URL}{MgmtV1.user_create_batch_path}",
296+
headers={
297+
**common.default_headers,
298+
"Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}",
299+
},
300+
params=None,
301+
json={
302+
"users": [
303+
{
304+
"loginId": "[email protected]",
305+
"email": "[email protected]",
306+
"phone": None,
307+
"displayName": "Name",
308+
"roleNames": [],
309+
"userTenants": [
310+
{"tenantId": "tenant1", "roleNames": []},
311+
{
312+
"tenantId": "tenant2",
313+
"roleNames": ["role1", "role2"],
314+
},
315+
],
316+
"test": False,
317+
"picture": None,
318+
"customAttributes": {"ak": "av"},
319+
"additionalLoginIds": None,
320+
}
321+
],
322+
"invite": True,
323+
"inviteUrl": "invite.me",
324+
"sendSMS": True,
325+
},
326+
allow_redirects=False,
327+
verify=True,
328+
timeout=DEFAULT_TIMEOUT_SECONDS,
329+
)
330+
259331
def test_update(self):
260332
# Test failed flows
261333
with patch("requests.post") as mock_post:

0 commit comments

Comments
 (0)