api: Add administrator endpoint for updating user status.

Fixes #33139.
This commit is contained in:
ducnb
2025-01-30 16:52:58 -05:00
committed by Tim Abbott
parent fdb35f2f81
commit 715d07c231
5 changed files with 324 additions and 5 deletions

View File

@@ -90,6 +90,7 @@
* [Reactivate a user](/api/reactivate-user)
* [Get a user's status](/api/get-user-status)
* [Update your status](/api/update-status)
* [Update user status](/api/update-status-for-user)
* [Set "typing" status](/api/set-typing-status)
* [Set "typing" status for message editing](/api/set-typing-status-for-message-edit)
* [Get a user's presence](/api/get-user-presence)

View File

@@ -10476,6 +10476,152 @@ paths:
$ref: "#/components/responses/SimpleSuccess"
/users/{user_id}/status:
post:
operationId: update-status-for-user
summary: Update user status
tags: ["users"]
parameters:
- $ref: "#/components/parameters/UserId"
description: |
Administrator endpoint for changing the [status](/help/status-and-availability) of
another user.
x-requires-administrator: true
requestBody:
required: false
content:
application/x-www-form-urlencoded:
schema:
type: object
properties:
status_text:
type: string
description: |
The text content of the status message. Sending the empty string
will clear the user's status.
**Note**: The limit on the size of the message is 60 characters.
example: on vacation
emoji_name:
type: string
description: |
The name for the emoji to associate with this status.
**Changes**: New in Zulip 5.0 (feature level 86).
example: car
emoji_code:
type: string
description: |
A unique identifier, defining the specific emoji codepoint requested,
within the namespace of the `reaction_type`.
**Changes**: New in Zulip 5.0 (feature level 86).
example: 1f697
reaction_type:
type: string
description: |
A string indicating the type of emoji. Each emoji `reaction_type`
has an independent namespace for values of `emoji_code`.
Must be one of the following values:
- `unicode_emoji` : In this namespace, `emoji_code` will be a
dash-separated hex encoding of the sequence of Unicode codepoints
that define this emoji in the Unicode specification.
- `realm_emoji` : In this namespace, `emoji_code` will be the ID of
the uploaded [custom emoji](/help/custom-emoji).
- `zulip_extra_emoji` : These are special emoji included with Zulip.
In this namespace, `emoji_code` will be the name of the emoji (e.g.
"zulip").
**Changes**: New in Zulip 5.0 (feature level 86).
example: unicode_emoji
encoding:
away:
contentType: application/json
responses:
"200":
$ref: "#/components/responses/SimpleSuccess"
"400":
description: Bad request.
content:
application/json:
schema:
oneOf:
- allOf:
- $ref: "#/components/schemas/CodedError"
- example:
{
"result": "error",
"msg": "Insufficient permission",
"code": "BAD_REQUEST",
}
description: |
An example JSON error response user making request does not have permission to update other user's status.:
- allOf:
- $ref: "#/components/schemas/CodedError"
- example:
{
"result": "error",
"msg": "Client did not pass any new values.",
"code": "BAD_REQUEST",
}
description: |
An example JSON error response when no changes were requested:
- allOf:
- $ref: "#/components/schemas/CodedError"
- example:
{
"result": "error",
"msg": "status_text is too long (limit: 60 characters)",
"code": "BAD_REQUEST",
}
description: |
An example JSON error response when the
`status_text` message exceeds the limit of
60 characters:
- allOf:
- $ref: "#/components/schemas/CodedError"
- example:
{
"result": "error",
"msg": "Client must pass emoji_name if they pass either emoji_code or reaction_type.",
"code": "BAD_REQUEST",
}
description: |
An example JSON error response when `emoji_name` is not specified
but `emoji_code` or `reaction_type` is specified:
- allOf:
- $ref: "#/components/schemas/CodedError"
- example:
{
"result": "error",
"msg": "Emoji 'invalid' does not exist",
"code": "BAD_REQUEST",
}
description: |
An example JSON error response when the emoji name does not exist:
- allOf:
- $ref: "#/components/schemas/CodedError"
- example:
{
"result": "error",
"msg": "Invalid emoji name.",
"code": "BAD_REQUEST",
}
description: |
An example JSON error response when the emoji name is invalid:
- allOf:
- $ref: "#/components/schemas/CodedError"
- example:
{
"result": "error",
"msg": "Invalid custom emoji.",
"code": "BAD_REQUEST",
}
description: |
An example JSON error response when the custom emoji is invalid:
get:
operationId: get-user-status
summary: Get a user's status

View File

@@ -181,10 +181,15 @@ class UserStatusTest(ZulipTestCase):
)
def update_status_and_assert_event(
self, payload: dict[str, Any], expected_event: dict[str, Any], num_events: int = 1
self,
*,
payload: dict[str, Any],
expected_event: dict[str, Any],
url: str = "/json/users/me/status",
num_events: int = 1,
) -> None:
with self.capture_send_event_calls(expected_num_events=num_events) as events:
result = self.client_post("/json/users/me/status", payload)
result = self.client_post(url, payload)
self.assert_json_success(result)
if num_events == 1:
self.assertEqual(events[0]["event"], expected_event)
@@ -193,6 +198,7 @@ class UserStatusTest(ZulipTestCase):
def test_endpoints(self) -> None:
hamlet = self.example_user("hamlet")
iago = self.example_user("iago")
realm = hamlet.realm
now = timezone_now()
@@ -260,7 +266,6 @@ class UserStatusTest(ZulipTestCase):
user_status_info(hamlet),
dict(away=True, status_text="on vacation"),
)
result = self.client_get(f"/json/users/{hamlet.id}/status")
result_dict = self.assert_json_success(result)
self.assertEqual(
@@ -430,3 +435,144 @@ class UserStatusTest(ZulipTestCase):
result_dict["status"],
{},
)
# No such user
result = self.client_post("/json/users/12345/status")
self.assert_json_error(result, "No such user")
payload = {
"status_text": "In a meeting",
"emoji_code": "1f4bb",
"emoji_name": "car",
"reaction_type": "realm_emoji",
}
# User does not have permission to set status for other users
self.login_user(hamlet)
result = self.client_post(f"/json/users/{iago.id}/status", payload)
self.assert_json_error(result, "Insufficient permission")
update_status_url = f"/json/users/{hamlet.id}/status"
# Login as admin Iago
self.login_user(iago)
# Server should remove emoji_code and reaction_type if emoji_name is empty.
self.update_status_and_assert_event(
payload=dict(
emoji_name="",
),
url=update_status_url,
expected_event=dict(
type="user_status",
user_id=hamlet.id,
emoji_name="",
emoji_code="",
reaction_type=UserStatus.UNICODE_EMOJI,
),
)
self.update_status_and_assert_event(
payload=dict(status_text=" at the beach "),
url=update_status_url,
expected_event=dict(type="user_status", user_id=hamlet.id, status_text="at the beach"),
)
self.assertEqual(
user_status_info(hamlet),
dict(status_text="at the beach", away=True),
)
result = self.client_post(update_status_url, {})
self.assert_json_error(result, "Client did not pass any new values.")
# Try to omit emoji_name parameter but passing emoji_code --this should be an error.
result = self.client_post(
update_status_url, {"status_text": "In a meeting", "emoji_code": "1f4bb"}
)
self.assert_json_error(
result, "Client must pass emoji_name if they pass either emoji_code or reaction_type."
)
# Invalid emoji requests fail.
result = self.client_post(
update_status_url,
{"status_text": "In a meeting", "emoji_code": "1f4bb", "emoji_name": "invalid"},
)
self.assert_json_error(result, "Emoji 'invalid' does not exist")
result = self.client_post(
update_status_url,
{"status_text": "In a meeting", "emoji_code": "1f4bb", "emoji_name": "car"},
)
self.assert_json_error(result, "Invalid emoji name.")
result = self.client_post(
update_status_url,
{
"status_text": "In a meeting",
"emoji_code": "1f4bb",
"emoji_name": "car",
"reaction_type": "realm_emoji",
},
)
self.assert_json_error(result, "Invalid custom emoji.")
# Try a long message--this should be an error.
long_text = "x" * 61
result = self.client_post(update_status_url, dict(status_text=long_text))
self.assert_json_error(result, "status_text is too long (limit: 60 characters)")
# Set "away" with a normal length message.
self.update_status_and_assert_event(
payload=dict(
away=orjson.dumps(True).decode(),
status_text="on vacation",
),
url=update_status_url,
expected_event=dict(
type="user_status", user_id=hamlet.id, away=True, status_text="on vacation"
),
num_events=3,
)
self.assertEqual(
user_status_info(hamlet),
dict(away=True, status_text="on vacation"),
)
result = self.client_get(f"/json/users/{hamlet.id}/status")
result_dict = self.assert_json_success(result)
self.assertEqual(
result_dict["status"],
dict(away=True, status_text="on vacation"),
)
# Setting away is a deprecated way of accessing a user's presence_enabled
# setting. Can be removed when clients migrate "away" (also referred to as
# "unavailable") feature to directly use the presence_enabled setting.
user = UserProfile.objects.get(id=hamlet.id)
self.assertEqual(user.presence_enabled, False)
# Server should fill emoji_code and reaction_type by emoji_name.
self.update_status_and_assert_event(
payload=dict(
emoji_name="car",
),
url=update_status_url,
expected_event=dict(
type="user_status",
user_id=hamlet.id,
emoji_name="car",
emoji_code="1f697",
reaction_type=UserStatus.UNICODE_EMOJI,
),
)
self.assertEqual(
user_status_info(hamlet),
dict(
away=True,
status_text="on vacation",
emoji_name="car",
emoji_code="1f697",
reaction_type=UserStatus.UNICODE_EMOJI,
),
)

View File

@@ -16,7 +16,7 @@ from zerver.lib.presence import get_presence_for_user, get_presence_response
from zerver.lib.request import RequestNotes
from zerver.lib.response import json_success
from zerver.lib.timestamp import datetime_to_timestamp
from zerver.lib.typed_endpoint import ApiParamConfig, typed_endpoint
from zerver.lib.typed_endpoint import ApiParamConfig, PathOnly, typed_endpoint
from zerver.lib.user_status import get_user_status
from zerver.lib.users import access_user_by_id, check_can_access_user
from zerver.models import UserActivity, UserPresence, UserProfile, UserStatus
@@ -147,6 +147,31 @@ def update_user_status_backend(
return json_success(request)
@human_users_only
@typed_endpoint
def update_user_status_admin(
request: HttpRequest,
user_profile: UserProfile,
*,
user_id: PathOnly[Json[int]],
status_text: Annotated[
str | None, StringConstraints(strip_whitespace=True, max_length=60)
] = None,
emoji_name: str | None = None,
emoji_code: str | None = None,
emoji_type: Annotated[str | None, ApiParamConfig("reaction_type")] = None,
) -> HttpResponse:
target_user = access_user_by_id(user_profile, user_id, for_admin=True)
return update_user_status_backend(
request,
user_profile=target_user,
status_text=status_text,
emoji_name=emoji_name,
emoji_code=emoji_code,
emoji_type=emoji_type,
)
@human_users_only
@typed_endpoint
def update_active_status_backend(

View File

@@ -104,6 +104,7 @@ from zerver.views.presence import (
get_status_backend,
get_statuses_for_realm,
update_active_status_backend,
update_user_status_admin,
update_user_status_backend,
)
from zerver.views.push_notifications import (
@@ -467,7 +468,7 @@ v1_api_and_json_patterns = [
rest_path("users/<user_id_or_email>/presence", GET=get_presence_backend),
rest_path("realm/presence", GET=get_statuses_for_realm),
rest_path("users/me/status", POST=update_user_status_backend),
rest_path("users/<int:user_id>/status", GET=get_status_backend),
rest_path("users/<int:user_id>/status", POST=update_user_status_admin, GET=get_status_backend),
# user_groups -> zerver.views.user_groups
rest_path("user_groups", GET=get_user_groups),
rest_path("user_groups/create", POST=add_user_group),