diff --git a/templates/zerver/api/changelog.md b/templates/zerver/api/changelog.md
index 68bce39bab..a2a002e790 100644
--- a/templates/zerver/api/changelog.md
+++ b/templates/zerver/api/changelog.md
@@ -16,6 +16,9 @@ below features are supported.
the user; use the `user_id` field instead. Previously, some (but
not all) events of these types contained an `email` key in addition to
to `user_id`) for identifying the modified user.
+* [`PATCH /users/{user_id}`](/api/update-user): The `is_admin` and
+ `is_guest` parameters were removed in favor of the more general
+ `role` parameter for specifying a change in user role.
**Feature level 6**
* [`GET /events`](/api/get-events-from-queue): `realm_user` events to
diff --git a/zerver/models.py b/zerver/models.py
index 32315b1a2a..dcfa0b2b1c 100644
--- a/zerver/models.py
+++ b/zerver/models.py
@@ -880,6 +880,12 @@ class UserProfile(AbstractBaseUser, PermissionsMixin):
ROLE_GUEST = 600
role: int = models.PositiveSmallIntegerField(default=ROLE_MEMBER, db_index=True)
+ ROLE_TYPES = [
+ ROLE_REALM_ADMINISTRATOR,
+ ROLE_MEMBER,
+ ROLE_GUEST,
+ ]
+
# Whether the user has been "soft-deactivated" due to weeks of inactivity.
# For these users we avoid doing UserMessage table work, as an optimization
# for large Zulip organizations with lots of single-visit users.
diff --git a/zerver/openapi/zulip.yaml b/zerver/openapi/zulip.yaml
index c413900a87..457b99d839 100644
--- a/zerver/openapi/zulip.yaml
+++ b/zerver/openapi/zulip.yaml
@@ -1579,21 +1579,24 @@ paths:
type: string
example: NewName
required: false
- - name: is_admin
+ - name: role
in: query
description: |
- Whether the target user is an administrator.
+ New [role](/help/roles-and-permissions) for the user. Supported roles include:
+ * Organization owner: 100
+ * Organization administrator: 200
+ * Member: 400
+ * Guest: 600
+
+ Only organization owners can add or remove the owner permission.
+
+ The owner permission cannot be removed from the only organization owner.
+
+ **Changes**: New in Zulip 2.2 (feature level 8), replacing the previous
+ pair of `is_admin` and `is_guest` boolean parameters.
schema:
- type: boolean
- example: false
- required: false
- - name: is_guest
- in: query
- description: |
- Whether the target user is a guest.
- schema:
- type: boolean
- example: true
+ type: integer
+ example: 400
required: false
- name: profile_data
in: query
diff --git a/zerver/tests/test_users.py b/zerver/tests/test_users.py
index 4a4ac70577..f687282620 100644
--- a/zerver/tests/test_users.py
+++ b/zerver/tests/test_users.py
@@ -138,7 +138,7 @@ class PermissionTest(ZulipTestCase):
self.assertFalse(othello_dict['is_admin'])
# Giveth
- req = dict(is_admin=ujson.dumps(True))
+ req = dict(role=ujson.dumps(UserProfile.ROLE_REALM_ADMINISTRATOR))
events: List[Mapping[str, Any]] = []
with tornado_redirected_to_list(events):
@@ -151,7 +151,7 @@ class PermissionTest(ZulipTestCase):
self.assertEqual(person['is_admin'], True)
# Taketh away
- req = dict(is_admin=ujson.dumps(False))
+ req = dict(role=ujson.dumps(UserProfile.ROLE_MEMBER))
events = []
with tornado_redirected_to_list(events):
result = self.client_patch('/json/users/{}'.format(othello.id), req)
@@ -164,7 +164,7 @@ class PermissionTest(ZulipTestCase):
# Cannot take away from last admin
self.login('iago')
- req = dict(is_admin=ujson.dumps(False))
+ req = dict(role=ujson.dumps(UserProfile.ROLE_MEMBER))
events = []
with tornado_redirected_to_list(events):
result = self.client_patch('/json/users/{}'.format(desdemona.id), req)
@@ -355,15 +355,7 @@ class PermissionTest(ZulipTestCase):
hamlet = self.example_user("hamlet")
self.assertFalse(hamlet.is_guest)
- # Test failure of making user both admin and guest
- req = dict(is_guest=ujson.dumps(True), is_admin=ujson.dumps(True))
- result = self.client_patch('/json/users/{}'.format(hamlet.id), req)
- self.assert_json_error(result, 'Guests cannot be organization administrators')
- self.assertFalse(hamlet.is_guest)
- self.assertFalse(hamlet.is_realm_admin)
- hamlet = self.example_user("hamlet")
-
- req = dict(is_guest=ujson.dumps(True))
+ req = dict(role=ujson.dumps(UserProfile.ROLE_GUEST))
events: List[Mapping[str, Any]] = []
with tornado_redirected_to_list(events):
result = self.client_patch('/json/users/{}'.format(hamlet.id), req)
@@ -382,7 +374,7 @@ class PermissionTest(ZulipTestCase):
polonius = self.example_user("polonius")
self.assertTrue(polonius.is_guest)
- req = dict(is_guest=ujson.dumps(False))
+ req = dict(role=ujson.dumps(UserProfile.ROLE_MEMBER))
events: List[Mapping[str, Any]] = []
with tornado_redirected_to_list(events):
result = self.client_patch('/json/users/{}'.format(polonius.id), req)
@@ -402,15 +394,10 @@ class PermissionTest(ZulipTestCase):
self.assertFalse(hamlet.is_guest)
self.assertTrue(hamlet.is_realm_admin)
- # Test failure of making a admin to guest without revoking admin status
- req = dict(is_guest=ujson.dumps(True))
- result = self.client_patch('/json/users/{}'.format(hamlet.id), req)
- self.assert_json_error(result, 'Guests cannot be organization administrators')
-
# Test changing a user from admin to guest and revoking admin status
hamlet = self.example_user("hamlet")
self.assertFalse(hamlet.is_guest)
- req = dict(is_admin=ujson.dumps(False), is_guest=ujson.dumps(True))
+ req = dict(role=ujson.dumps(UserProfile.ROLE_GUEST))
events: List[Mapping[str, Any]] = []
with tornado_redirected_to_list(events):
result = self.client_patch('/json/users/{}'.format(hamlet.id), req)
@@ -435,15 +422,10 @@ class PermissionTest(ZulipTestCase):
self.assertTrue(polonius.is_guest)
self.assertFalse(polonius.is_realm_admin)
- # Test failure of making a guest to admin without revoking guest status
- req = dict(is_admin=ujson.dumps(True))
- result = self.client_patch('/json/users/{}'.format(polonius.id), req)
- self.assert_json_error(result, 'Guests cannot be organization administrators')
-
# Test changing a user from guest to admin and revoking guest status
polonius = self.example_user("polonius")
self.assertFalse(polonius.is_realm_admin)
- req = dict(is_admin=ujson.dumps(True), is_guest=ujson.dumps(False))
+ req = dict(role=ujson.dumps(UserProfile.ROLE_REALM_ADMINISTRATOR))
events: List[Mapping[str, Any]] = []
with tornado_redirected_to_list(events):
result = self.client_patch('/json/users/{}'.format(polonius.id), req)
diff --git a/zerver/views/users.py b/zerver/views/users.py
index c2807cdb74..ce3c101a34 100644
--- a/zerver/views/users.py
+++ b/zerver/views/users.py
@@ -24,7 +24,8 @@ from zerver.lib.request import has_request_variables, REQ
from zerver.lib.response import json_error, json_success
from zerver.lib.streams import access_stream_by_name
from zerver.lib.upload import upload_avatar_image
-from zerver.lib.validator import check_bool, check_string, check_int, check_url, check_dict, check_list
+from zerver.lib.validator import check_bool, check_string, check_int, check_url, check_dict, check_list, \
+ check_int_in
from zerver.lib.url_encoding import add_query_arg_to_redirect_url
from zerver.lib.users import check_valid_bot_type, check_bot_creation_policy, \
check_full_name, check_short_name, check_valid_interface_type, check_valid_bot_config, \
@@ -80,36 +81,16 @@ def reactivate_user_backend(request: HttpRequest, user_profile: UserProfile,
@has_request_variables
def update_user_backend(request: HttpRequest, user_profile: UserProfile, user_id: int,
full_name: Optional[str]=REQ(default="", validator=check_string),
- is_admin: Optional[bool]=REQ(default=None, validator=check_bool),
- is_guest: Optional[bool]=REQ(default=None, validator=check_bool),
+ role: Optional[int]=REQ(default=None, validator=check_int_in(
+ UserProfile.ROLE_TYPES)),
profile_data: Optional[List[Dict[str, Union[int, str, List[int]]]]]=
REQ(default=None,
validator=check_list(check_dict([('id', check_int)])))) -> HttpResponse:
target = access_user_by_id(user_profile, user_id, allow_deactivated=True, allow_bots=True)
- # Historically, UserProfile had two fields, is_guest and is_realm_admin.
- # This condition protected against situations where update_user_backend
- # could cause both is_guest and is_realm_admin to be set.
- # Once we update the frontend to just send a 'role' value, we can remove this check.
- if (((is_guest is None and target.is_guest) or is_guest) and
- ((is_admin is None and target.is_realm_admin) or is_admin)):
- return json_error(_("Guests cannot be organization administrators"))
-
- role = None
- if is_admin is not None and target.is_realm_admin != is_admin:
- if not is_admin and check_last_admin(user_profile):
- return json_error(_('Cannot remove the only organization administrator'))
- role = UserProfile.ROLE_MEMBER
- if is_admin:
- role = UserProfile.ROLE_REALM_ADMINISTRATOR
-
- if is_guest is not None and target.is_guest != is_guest:
- if is_guest:
- role = UserProfile.ROLE_GUEST
- if role is None:
- role = UserProfile.ROLE_MEMBER
-
if role is not None and target.role != role:
+ if target.role == UserProfile.ROLE_REALM_ADMINISTRATOR and check_last_admin(user_profile):
+ return json_error(_('Cannot remove the only organization administrator'))
do_change_user_role(target, role)
if (full_name is not None and target.full_name != full_name and