From 715d07c2315093c3e4d90cdd48b7d915e7ad2d47 Mon Sep 17 00:00:00 2001 From: ducnb Date: Thu, 30 Jan 2025 16:52:58 -0500 Subject: [PATCH] api: Add administrator endpoint for updating user status. Fixes #33139. --- api_docs/include/rest-endpoints.md | 1 + zerver/openapi/zulip.yaml | 146 +++++++++++++++++++++++++++ zerver/tests/test_user_status.py | 152 ++++++++++++++++++++++++++++- zerver/views/presence.py | 27 ++++- zproject/urls.py | 3 +- 5 files changed, 324 insertions(+), 5 deletions(-) diff --git a/api_docs/include/rest-endpoints.md b/api_docs/include/rest-endpoints.md index 014b446102..c696498c04 100644 --- a/api_docs/include/rest-endpoints.md +++ b/api_docs/include/rest-endpoints.md @@ -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) diff --git a/zerver/openapi/zulip.yaml b/zerver/openapi/zulip.yaml index ca9d46a4cf..e7337061bb 100644 --- a/zerver/openapi/zulip.yaml +++ b/zerver/openapi/zulip.yaml @@ -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 diff --git a/zerver/tests/test_user_status.py b/zerver/tests/test_user_status.py index 768e73eaa4..08944b054b 100644 --- a/zerver/tests/test_user_status.py +++ b/zerver/tests/test_user_status.py @@ -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, + ), + ) diff --git a/zerver/views/presence.py b/zerver/views/presence.py index 1db352d37a..968434f795 100644 --- a/zerver/views/presence.py +++ b/zerver/views/presence.py @@ -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( diff --git a/zproject/urls.py b/zproject/urls.py index 0cfa2e169c..08cc2c9535 100644 --- a/zproject/urls.py +++ b/zproject/urls.py @@ -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//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//status", GET=get_status_backend), + rest_path("users//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),