users: Add API endpoint to update_user_backend by real email.

The old endpoint for updating a user worked only via user id. Now we add
a different entry to this functionality, fetching the user by
.delivery_email.

update_user_backend becomes the main function handling all the logic,
invoked by the two endpoints.
This commit is contained in:
Mateusz Mandera 2024-09-05 01:51:50 +02:00 committed by Tim Abbott
parent 389b851f81
commit 77e7a2d30f
7 changed files with 232 additions and 62 deletions

View File

@ -66,6 +66,7 @@
* [Get all users](/api/get-users) * [Get all users](/api/get-users)
* [Create a user](/api/create-user) * [Create a user](/api/create-user)
* [Update a user](/api/update-user) * [Update a user](/api/update-user)
* [Update a user by email](/api/update-user-by-email)
* [Deactivate a user](/api/deactivate-user) * [Deactivate a user](/api/deactivate-user)
* [Deactivate own user](/api/deactivate-own-user) * [Deactivate own user](/api/deactivate-own-user)
* [Reactivate a user](/api/reactivate-user) * [Reactivate a user](/api/reactivate-user)

View File

@ -34,7 +34,7 @@ DESKTOP_WARNING_VERSION = "5.9.3"
# new level means in api_docs/changelog.md, as well as "**Changes**" # new level means in api_docs/changelog.md, as well as "**Changes**"
# entries in the endpoint's documentation in `zulip.yaml`. # entries in the endpoint's documentation in `zulip.yaml`.
API_FEATURE_LEVEL = 313 # Last bumped for adding `new_email` to /users/{user_id} API_FEATURE_LEVEL = 313 # Last bumped for adding `new_email` to /users/{user_id} and the new PATCH /users/{email} endpoint
# Bump the minor PROVISION_VERSION to indicate that folks should provision # Bump the minor PROVISION_VERSION to indicate that folks should provision
# only when going from an old version of the code to a newer version. Bump # only when going from an old version of the code to a newer version. Bump

View File

@ -23,7 +23,7 @@ from zerver.lib.upload import upload_message_attachment
from zerver.lib.users import get_api_key from zerver.lib.users import get_api_key
from zerver.models import Client, Message, NamedUserGroup, UserPresence from zerver.models import Client, Message, NamedUserGroup, UserPresence
from zerver.models.realms import get_realm from zerver.models.realms import get_realm
from zerver.models.users import get_user from zerver.models.users import UserProfile, get_user
from zerver.openapi.openapi import Parameter from zerver.openapi.openapi import Parameter
GENERATOR_FUNCTIONS: dict[str, Callable[[], dict[str, object]]] = {} GENERATOR_FUNCTIONS: dict[str, Callable[[], dict[str, object]]] = {}
@ -252,6 +252,19 @@ def create_user() -> dict[str, object]:
} }
@openapi_param_value_generator(["/users/{email]:patch", "/users/{user_id}:patch"])
def new_email_value() -> dict[str, object]:
count = 0
exists = True
while exists:
email = f"new{count}@zulip.com"
exists = UserProfile.objects.filter(delivery_email=email).exists()
count += 1
return {
"new_email": email,
}
@openapi_param_value_generator(["/user_groups/create:post"]) @openapi_param_value_generator(["/user_groups/create:post"])
def create_user_group_data() -> dict[str, object]: def create_user_group_data() -> dict[str, object]:
return { return {

View File

@ -12634,6 +12634,52 @@ paths:
"delivery_email": null, "delivery_email": null,
}, },
} }
patch:
operationId: update-user-by-email
summary: Update a user by email
tags: ["users"]
x-requires-administrator: true
description: |
Administrative endpoint to update the details of another user in the organization by their email address.
Works the same way as [`PATCH /users/{user_id}`](/api/update-user) but fetching the target user by their
real email address.
The requester needs to have permission to view the target user's real email address, subject to the
user's email address visibility setting. Otherwise, the dummy address of the format
`user{id}@{realm.host}` needs be used. This follows the same rules as `GET /users/{email}`.
**Changes**: New in Zulip 10.0 (feature level 313).
parameters:
- name: email
in: path
required: true
description: |
The email address of the user, specified following the same rules as
[`GET /users/{email}`](/api/get-user-by-email).
schema:
type: string
example: hamlet@zulip.com
requestBody:
$ref: "#/components/requestBodies/UpdateUser"
responses:
"200":
$ref: "#/components/responses/SimpleSuccess"
"400":
description: Bad request.
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/CodedError"
- example:
{
"result": "error",
"msg": "Guests cannot be organization administrators",
"code": "BAD_REQUEST",
}
description: |
A typical unsuccessful JSON response:
/users/{user_id}: /users/{user_id}:
get: get:
operationId: get-user operationId: get-user
@ -12738,62 +12784,7 @@ paths:
parameters: parameters:
- $ref: "#/components/parameters/UserId" - $ref: "#/components/parameters/UserId"
requestBody: requestBody:
required: false $ref: "#/components/requestBodies/UpdateUser"
content:
application/x-www-form-urlencoded:
schema:
type: object
properties:
full_name:
description: |
The user's full name.
**Changes**: Removed unnecessary JSON-encoding of this parameter in
Zulip 5.0 (feature level 106).
type: string
example: NewName
role:
description: |
New [role](/api/roles-and-permissions) for the user. Roles are encoded as:
- Organization owner: 100
- Organization administrator: 200
- Organization moderator: 300
- Member: 400
- Guest: 600
Only organization owners can add or remove the owner role.
The owner role cannot be removed from the only organization owner.
**Changes**: New in Zulip 3.0 (feature level 8), replacing the previous
pair of `is_admin` and `is_guest` boolean parameters. Organization moderator
role added in Zulip 4.0 (feature level 60).
type: integer
example: 400
profile_data:
description: |
A dictionary containing the to be updated custom profile field data for the user.
type: array
items:
type: object
example:
[{"id": 4, "value": "0"}, {"id": 5, "value": "1909-04-05"}]
new_email:
description: |
New email address for the user. Requires the user making
the request to be an organization administrator and
additionally have the `.can_change_user_emails` special
permission.
**Changes**: New in Zulip 10.0 (feature level 313).
type: string
example: username@example.com
encoding:
role:
contentType: application/json
profile_data:
contentType: application/json
responses: responses:
"200": "200":
@ -24475,6 +24466,67 @@ components:
allOf: allOf:
- $ref: "#/components/schemas/IgnoredParametersSuccess" - $ref: "#/components/schemas/IgnoredParametersSuccess"
################
# Shared request bodies
################
requestBodies:
UpdateUser:
required: false
content:
application/x-www-form-urlencoded:
schema:
type: object
properties:
full_name:
description: |
The user's full name.
**Changes**: Removed unnecessary JSON-encoding of this parameter in
Zulip 5.0 (feature level 106).
type: string
example: NewName
role:
description: |
New [role](/api/roles-and-permissions) for the user. Roles are encoded as:
- Organization owner: 100
- Organization administrator: 200
- Organization moderator: 300
- Member: 400
- Guest: 600
Only organization owners can add or remove the owner role.
The owner role cannot be removed from the only organization owner.
**Changes**: New in Zulip 3.0 (feature level 8), replacing the previous
pair of `is_admin` and `is_guest` boolean parameters. Organization moderator
role added in Zulip 4.0 (feature level 60).
type: integer
example: 400
profile_data:
description: |
A dictionary containing the updated custom profile field data for the user.
type: array
items:
type: object
example:
[{"id": 4, "value": "0"}, {"id": 5, "value": "1909-04-05"}]
new_email:
description: |
New email address for the user. Requires the user making the request
to be an organization owner and additionally have the `.can_change_user_emails`
special permission.
**Changes**: New in Zulip 10.0 (feature level 285).
type: string
example: username@example.com
encoding:
role:
contentType: application/json
profile_data:
contentType: application/json
#################### ####################
# Shared parameters # Shared parameters
#################### ####################

View File

@ -1121,6 +1121,63 @@ class BulkCreateUserTest(ZulipTestCase):
) )
class UpdateUserByEmailEndpointTest(ZulipTestCase):
def test_update_user_by_email(self) -> None:
self.login("iago")
hamlet = self.example_user("hamlet")
do_change_user_setting(
hamlet,
"email_address_visibility",
UserProfile.EMAIL_ADDRESS_VISIBILITY_EVERYONE,
acting_user=None,
)
result = self.client_patch(
f"/json/users/{hamlet.delivery_email}",
dict(full_name="Newname"),
)
self.assert_json_success(result)
hamlet.refresh_from_db()
self.assertEqual(hamlet.full_name, "Newname")
do_change_user_setting(
hamlet,
"email_address_visibility",
UserProfile.EMAIL_ADDRESS_VISIBILITY_MEMBERS,
acting_user=None,
)
result = self.client_patch(
f"/json/users/{hamlet.delivery_email}",
dict(full_name="Newname2"),
)
self.assert_json_success(result)
hamlet.refresh_from_db()
self.assertEqual(hamlet.full_name, "Newname2")
do_change_user_setting(
hamlet,
"email_address_visibility",
UserProfile.EMAIL_ADDRESS_VISIBILITY_NOBODY,
acting_user=None,
)
result = self.client_patch(
f"/json/users/{hamlet.delivery_email}",
dict(full_name="Newname2"),
)
self.assert_json_error(result, "No such user")
# The dummy email can be used, when we don't have access
# to the target's email address.
dummy_email = hamlet.email
result = self.client_patch(
f"/json/users/{dummy_email}",
dict(full_name="Newname3"),
)
self.assert_json_success(result)
hamlet.refresh_from_db()
self.assertEqual(hamlet.full_name, "Newname3")
class AdminChangeUserEmailTest(ZulipTestCase): class AdminChangeUserEmailTest(ZulipTestCase):
def test_change_user_email_backend(self) -> None: def test_change_user_email_backend(self) -> None:
cordelia = self.example_user("cordelia") cordelia = self.example_user("cordelia")

View File

@ -202,7 +202,7 @@ class ProfileDataElement(BaseModel):
@typed_endpoint @typed_endpoint
@transaction.atomic(durable=True) @transaction.atomic(durable=True)
def update_user_backend( def update_user_by_id_api(
request: HttpRequest, request: HttpRequest,
user_profile: UserProfile, user_profile: UserProfile,
*, *,
@ -215,7 +215,53 @@ def update_user_backend(
target = access_user_by_id( target = access_user_by_id(
user_profile, user_id, allow_deactivated=True, allow_bots=True, for_admin=True user_profile, user_id, allow_deactivated=True, allow_bots=True, for_admin=True
) )
return update_user_backend(
request,
user_profile,
target,
full_name=full_name,
role=role,
profile_data=profile_data,
new_email=new_email,
)
@typed_endpoint
@transaction.atomic(durable=True)
def update_user_by_email_api(
request: HttpRequest,
user_profile: UserProfile,
*,
email: PathOnly[str],
full_name: str | None = None,
role: Json[RoleParamType] | None = None,
profile_data: Json[list[ProfileDataElement]] | None = None,
new_email: str | None = None,
) -> HttpResponse:
target = access_user_by_email(
user_profile, email, allow_deactivated=True, allow_bots=True, for_admin=True
)
return update_user_backend(
request,
user_profile,
target,
full_name=full_name,
role=role,
profile_data=profile_data,
new_email=new_email,
)
def update_user_backend(
request: HttpRequest,
user_profile: UserProfile,
target: UserProfile,
*,
full_name: str | None = None,
role: Json[RoleParamType] | None = None,
profile_data: Json[list[ProfileDataElement]] | None = None,
new_email: str | None = None,
) -> HttpResponse:
if new_email is not None and ( if new_email is not None and (
not user_profile.can_change_user_emails or not user_profile.is_realm_admin not user_profile.can_change_user_emails or not user_profile.is_realm_admin
): ):

View File

@ -232,7 +232,8 @@ from zerver.views.users import (
patch_bot_backend, patch_bot_backend,
reactivate_user_backend, reactivate_user_backend,
regenerate_bot_api_key, regenerate_bot_api_key,
update_user_backend, update_user_by_email_api,
update_user_by_id_api,
) )
from zerver.views.video_calls import ( from zerver.views.video_calls import (
complete_zoom_user, complete_zoom_user,
@ -316,11 +317,11 @@ v1_api_and_json_patterns = [
rest_path( rest_path(
"users/<int:user_id>", "users/<int:user_id>",
GET=get_members_backend, GET=get_members_backend,
PATCH=update_user_backend, PATCH=update_user_by_id_api,
DELETE=deactivate_user_backend, DELETE=deactivate_user_backend,
), ),
rest_path("users/<int:user_id>/subscriptions/<int:stream_id>", GET=get_subscription_backend), rest_path("users/<int:user_id>/subscriptions/<int:stream_id>", GET=get_subscription_backend),
rest_path("users/<email>", GET=get_user_by_email), rest_path("users/<email>", GET=get_user_by_email, PATCH=update_user_by_email_api),
rest_path("bots", GET=get_bots_backend, POST=add_bot_backend), rest_path("bots", GET=get_bots_backend, POST=add_bot_backend),
rest_path("bots/<int:bot_id>/api_key/regenerate", POST=regenerate_bot_api_key), rest_path("bots/<int:bot_id>/api_key/regenerate", POST=regenerate_bot_api_key),
rest_path("bots/<int:bot_id>", PATCH=patch_bot_backend, DELETE=deactivate_bot_backend), rest_path("bots/<int:bot_id>", PATCH=patch_bot_backend, DELETE=deactivate_bot_backend),