Skip to content

Commit dfb4cc5

Browse files
authored
Add user patch batch (#659)
* Add status to patch request * Add user patch batch
1 parent 448adcc commit dfb4cc5

File tree

3 files changed

+277
-8
lines changed

3 files changed

+277
-8
lines changed

descope/management/common.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ class MgmtV1:
6868
user_create_batch_path = "/v1/mgmt/user/create/batch"
6969
user_update_path = "/v1/mgmt/user/update"
7070
user_patch_path = "/v1/mgmt/user/patch"
71+
user_patch_batch_path = "/v1/mgmt/user/patch/batch"
7172
user_delete_path = "/v1/mgmt/user/delete"
7273
user_logout_path = "/v1/mgmt/user/logout"
7374
user_delete_all_test_users_path = "/v1/mgmt/user/test/delete/all"

descope/management/user.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,7 @@ def patch(
423423
verified_email: Optional[bool] = None,
424424
verified_phone: Optional[bool] = None,
425425
sso_app_ids: Optional[List[str]] = None,
426+
status: Optional[str] = None,
426427
test: bool = False,
427428
) -> dict:
428429
"""
@@ -443,6 +444,7 @@ def patch(
443444
picture (str): Optional url for user picture
444445
custom_attributes (dict): Optional, set the different custom attributes values of the keys that were previously configured in Descope console app
445446
sso_app_ids (List[str]): Optional, list of SSO applications IDs to be associated with the user.
447+
status (str): Optional status field. Can be one of: "enabled", "disabled", "invited".
446448
test (bool, optional): Set to True to update a test user. Defaults to False.
447449
448450
Return value (dict):
@@ -453,6 +455,12 @@ def patch(
453455
Raise:
454456
AuthException: raised if patch operation fails
455457
"""
458+
if status is not None and status not in ["enabled", "disabled", "invited"]:
459+
raise AuthException(
460+
400,
461+
ERROR_TYPE_INVALID_ARGUMENT,
462+
f"Invalid status value: {status}. Must be one of: enabled, disabled, invited",
463+
)
456464
response = self._auth.do_patch(
457465
MgmtV1.user_patch_path,
458466
User._compose_patch_body(
@@ -470,12 +478,55 @@ def patch(
470478
verified_email,
471479
verified_phone,
472480
sso_app_ids,
481+
status,
473482
test,
474483
),
475484
pswd=self._auth.management_key,
476485
)
477486
return response.json()
478487

488+
def patch_batch(
489+
self,
490+
users: List[UserObj],
491+
test: bool = False,
492+
) -> dict:
493+
"""
494+
Patch users in batch. Only the provided fields will be updated for each user.
495+
496+
Args:
497+
users (List[UserObj]): A list of UserObj instances representing users to be patched.
498+
Each UserObj should have a login_id and the fields to be updated.
499+
test (bool, optional): Set to True to patch test users. Defaults to False.
500+
501+
Return value (dict):
502+
Return dict in the format
503+
{"patchedUsers": [...], "failedUsers": [...]}
504+
"patchedUsers" contains successfully patched users,
505+
"failedUsers" contains users that failed to be patched with error details.
506+
507+
Raise:
508+
AuthException: raised if patch batch operation fails
509+
"""
510+
# Validate status fields for all users
511+
for user in users:
512+
if user.status is not None and user.status not in [
513+
"enabled",
514+
"disabled",
515+
"invited",
516+
]:
517+
raise AuthException(
518+
400,
519+
ERROR_TYPE_INVALID_ARGUMENT,
520+
f"Invalid status value: {user.status} for user {user.login_id}. Must be one of: enabled, disabled, invited",
521+
)
522+
523+
response = self._auth.do_patch(
524+
MgmtV1.user_patch_batch_path,
525+
User._compose_patch_batch_body(users, test),
526+
pswd=self._auth.management_key,
527+
)
528+
return response.json()
529+
479530
def delete(
480531
self,
481532
login_id: str,
@@ -1968,6 +2019,7 @@ def _compose_patch_body(
19682019
verified_email: Optional[bool],
19692020
verified_phone: Optional[bool],
19702021
sso_app_ids: Optional[List[str]],
2022+
status: Optional[str],
19712023
test: bool = False,
19722024
) -> dict:
19732025
res: dict[str, Any] = {
@@ -1999,6 +2051,37 @@ def _compose_patch_body(
19992051
res["verifiedPhone"] = verified_phone
20002052
if sso_app_ids is not None:
20012053
res["ssoAppIds"] = sso_app_ids
2054+
if status is not None:
2055+
res["status"] = status
20022056
if test:
20032057
res["test"] = test
20042058
return res
2059+
2060+
@staticmethod
2061+
def _compose_patch_batch_body(
2062+
users: List[UserObj],
2063+
test: bool = False,
2064+
) -> dict:
2065+
users_body = []
2066+
for user in users:
2067+
user_body = User._compose_patch_body(
2068+
login_id=user.login_id,
2069+
email=user.email,
2070+
phone=user.phone,
2071+
display_name=user.display_name,
2072+
given_name=user.given_name,
2073+
middle_name=user.middle_name,
2074+
family_name=user.family_name,
2075+
role_names=user.role_names,
2076+
user_tenants=user.user_tenants,
2077+
picture=user.picture,
2078+
custom_attributes=user.custom_attributes,
2079+
verified_email=user.verified_email,
2080+
verified_phone=user.verified_phone,
2081+
sso_app_ids=user.sso_app_ids,
2082+
status=user.status,
2083+
test=test,
2084+
)
2085+
users_body.append(user_body)
2086+
2087+
return {"users": users_body}

tests/management/test_user.py

Lines changed: 193 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,175 @@ def test_patch(self):
634634
timeout=DEFAULT_TIMEOUT_SECONDS,
635635
)
636636

637+
def test_patch_with_status(self):
638+
# Test invalid status value
639+
with self.assertRaises(AuthException) as context:
640+
self.client.mgmt.user.patch("valid-id", status="invalid_status")
641+
642+
self.assertEqual(context.exception.status_code, 400)
643+
self.assertIn("Invalid status value: invalid_status", str(context.exception))
644+
645+
# Test valid status values
646+
valid_statuses = ["enabled", "disabled", "invited"]
647+
648+
for status in valid_statuses:
649+
with patch("requests.patch") as mock_patch:
650+
network_resp = mock.Mock()
651+
network_resp.ok = True
652+
network_resp.json.return_value = json.loads(
653+
"""{"user": {"id": "u1"}}"""
654+
)
655+
mock_patch.return_value = network_resp
656+
657+
resp = self.client.mgmt.user.patch("id", status=status)
658+
user = resp["user"]
659+
self.assertEqual(user["id"], "u1")
660+
661+
mock_patch.assert_called_with(
662+
f"{common.DEFAULT_BASE_URL}{MgmtV1.user_patch_path}",
663+
headers={
664+
**common.default_headers,
665+
"Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}",
666+
"x-descope-project-id": self.dummy_project_id,
667+
},
668+
params=None,
669+
json={
670+
"loginId": "id",
671+
"status": status,
672+
},
673+
allow_redirects=False,
674+
verify=True,
675+
timeout=DEFAULT_TIMEOUT_SECONDS,
676+
)
677+
678+
# Test that status is not included when None
679+
with patch("requests.patch") as mock_patch:
680+
network_resp = mock.Mock()
681+
network_resp.ok = True
682+
network_resp.json.return_value = json.loads("""{"user": {"id": "u1"}}""")
683+
mock_patch.return_value = network_resp
684+
685+
resp = self.client.mgmt.user.patch("id", display_name="test", status=None)
686+
user = resp["user"]
687+
self.assertEqual(user["id"], "u1")
688+
689+
# Verify that status is not in the JSON payload
690+
call_args = mock_patch.call_args
691+
json_payload = call_args[1]["json"]
692+
self.assertNotIn("status", json_payload)
693+
self.assertEqual(json_payload["displayName"], "test")
694+
695+
def test_patch_batch(self):
696+
# Test invalid status value in batch
697+
users_with_invalid_status = [
698+
UserObj(login_id="user1", status="invalid_status"),
699+
UserObj(login_id="user2", status="enabled"),
700+
]
701+
702+
with self.assertRaises(AuthException) as context:
703+
self.client.mgmt.user.patch_batch(users_with_invalid_status)
704+
705+
self.assertEqual(context.exception.status_code, 400)
706+
self.assertIn(
707+
"Invalid status value: invalid_status for user user1",
708+
str(context.exception),
709+
)
710+
711+
# Test successful batch patch
712+
users = [
713+
UserObj(login_id="user1", email="[email protected]", status="enabled"),
714+
UserObj(login_id="user2", display_name="User Two", status="disabled"),
715+
UserObj(login_id="user3", phone="+123456789", status="invited"),
716+
]
717+
718+
with patch("requests.patch") as mock_patch:
719+
network_resp = mock.Mock()
720+
network_resp.ok = True
721+
network_resp.json.return_value = json.loads(
722+
"""{"patchedUsers": [{"id": "u1"}, {"id": "u2"}, {"id": "u3"}], "failedUsers": []}"""
723+
)
724+
mock_patch.return_value = network_resp
725+
726+
resp = self.client.mgmt.user.patch_batch(users)
727+
728+
self.assertEqual(len(resp["patchedUsers"]), 3)
729+
self.assertEqual(len(resp["failedUsers"]), 0)
730+
731+
mock_patch.assert_called_with(
732+
f"{common.DEFAULT_BASE_URL}{MgmtV1.user_patch_batch_path}",
733+
headers={
734+
**common.default_headers,
735+
"Authorization": f"Bearer {self.dummy_project_id}:{self.dummy_management_key}",
736+
"x-descope-project-id": self.dummy_project_id,
737+
},
738+
params=None,
739+
json={
740+
"users": [
741+
{
742+
"loginId": "user1",
743+
"email": "[email protected]",
744+
"status": "enabled",
745+
},
746+
{
747+
"loginId": "user2",
748+
"displayName": "User Two",
749+
"status": "disabled",
750+
},
751+
{
752+
"loginId": "user3",
753+
"phone": "+123456789",
754+
"status": "invited",
755+
},
756+
]
757+
},
758+
allow_redirects=False,
759+
verify=True,
760+
timeout=DEFAULT_TIMEOUT_SECONDS,
761+
)
762+
763+
# Test batch with mixed success/failure response
764+
with patch("requests.patch") as mock_patch:
765+
network_resp = mock.Mock()
766+
network_resp.ok = True
767+
network_resp.json.return_value = json.loads(
768+
"""{"patchedUsers": [{"id": "u1"}], "failedUsers": [{"failure": "User not found", "user": {"loginId": "user2"}}]}"""
769+
)
770+
mock_patch.return_value = network_resp
771+
772+
resp = self.client.mgmt.user.patch_batch(
773+
[UserObj(login_id="user1"), UserObj(login_id="user2")]
774+
)
775+
776+
self.assertEqual(len(resp["patchedUsers"]), 1)
777+
self.assertEqual(len(resp["failedUsers"]), 1)
778+
self.assertEqual(resp["failedUsers"][0]["failure"], "User not found")
779+
780+
# Test failed batch operation
781+
with patch("requests.patch") as mock_patch:
782+
mock_patch.return_value.ok = False
783+
self.assertRaises(
784+
AuthException,
785+
self.client.mgmt.user.patch_batch,
786+
[UserObj(login_id="user1")],
787+
)
788+
789+
# Test with test users flag
790+
with patch("requests.patch") as mock_patch:
791+
network_resp = mock.Mock()
792+
network_resp.ok = True
793+
network_resp.json.return_value = json.loads(
794+
"""{"patchedUsers": [{"id": "u1"}], "failedUsers": []}"""
795+
)
796+
mock_patch.return_value = network_resp
797+
798+
resp = self.client.mgmt.user.patch_batch(
799+
[UserObj(login_id="test_user1")], test=True
800+
)
801+
802+
call_args = mock_patch.call_args
803+
json_payload = call_args[1]["json"]
804+
self.assertTrue(json_payload["users"][0]["test"])
805+
637806
def test_delete(self):
638807
# Test failed flows
639808
with patch("requests.post") as mock_post:
@@ -1056,8 +1225,12 @@ def test_search_all(self):
10561225
)
10571226
mock_post.return_value = network_resp
10581227
resp = self.client.mgmt.user.search_all(
1059-
tenant_role_ids={"tenant1": {"values": ["roleA", "roleB"], "and": True}},
1060-
tenant_role_names={"tenant2": {"values": ["admin", "user"], "and": False}},
1228+
tenant_role_ids={
1229+
"tenant1": {"values": ["roleA", "roleB"], "and": True}
1230+
},
1231+
tenant_role_names={
1232+
"tenant2": {"values": ["admin", "user"], "and": False}
1233+
},
10611234
)
10621235
users = resp["users"]
10631236
self.assertEqual(len(users), 2)
@@ -1078,8 +1251,12 @@ def test_search_all(self):
10781251
"page": 0,
10791252
"testUsersOnly": False,
10801253
"withTestUser": False,
1081-
"tenantRoleIds": {"tenant1": {"values": ["roleA", "roleB"], "and": True}},
1082-
"tenantRoleNames": {"tenant2": {"values": ["admin", "user"], "and": False}},
1254+
"tenantRoleIds": {
1255+
"tenant1": {"values": ["roleA", "roleB"], "and": True}
1256+
},
1257+
"tenantRoleNames": {
1258+
"tenant2": {"values": ["admin", "user"], "and": False}
1259+
},
10831260
},
10841261
allow_redirects=False,
10851262
verify=True,
@@ -1302,8 +1479,12 @@ def test_search_all_test_users(self):
13021479
)
13031480
mock_post.return_value = network_resp
13041481
resp = self.client.mgmt.user.search_all_test_users(
1305-
tenant_role_ids={"tenant1": {"values": ["roleA", "roleB"], "and": True}},
1306-
tenant_role_names={"tenant2": {"values": ["admin", "user"], "and": False}},
1482+
tenant_role_ids={
1483+
"tenant1": {"values": ["roleA", "roleB"], "and": True}
1484+
},
1485+
tenant_role_names={
1486+
"tenant2": {"values": ["admin", "user"], "and": False}
1487+
},
13071488
)
13081489
users = resp["users"]
13091490
self.assertEqual(len(users), 2)
@@ -1324,8 +1505,12 @@ def test_search_all_test_users(self):
13241505
"page": 0,
13251506
"testUsersOnly": True,
13261507
"withTestUser": True,
1327-
"tenantRoleIds": {"tenant1": {"values": ["roleA", "roleB"], "and": True}},
1328-
"tenantRoleNames": {"tenant2": {"values": ["admin", "user"], "and": False}},
1508+
"tenantRoleIds": {
1509+
"tenant1": {"values": ["roleA", "roleB"], "and": True}
1510+
},
1511+
"tenantRoleNames": {
1512+
"tenant2": {"values": ["admin", "user"], "and": False}
1513+
},
13291514
},
13301515
allow_redirects=False,
13311516
verify=True,

0 commit comments

Comments
 (0)