diff --git a/api_docs/changelog.md b/api_docs/changelog.md index 85a540f86a..0955ccafe7 100644 --- a/api_docs/changelog.md +++ b/api_docs/changelog.md @@ -20,6 +20,12 @@ format used by the Zulip server that they are interacting with. ## Changes in Zulip 9.0 +**Feature level 262**: + +* [`GET /users/{user_id}/status`](/api/get-user-status): Added a new + endpoint to fetch an individual user's currently set + [status](/help/status-and-availability). + **Feature level 261** * [`POST /invites`](/api/send-invites), diff --git a/api_docs/include/rest-endpoints.md b/api_docs/include/rest-endpoints.md index 24de217204..4cd2447c3f 100644 --- a/api_docs/include/rest-endpoints.md +++ b/api_docs/include/rest-endpoints.md @@ -69,6 +69,7 @@ * [Deactivate own user](/api/deactivate-own-user) * [Set "typing" status](/api/set-typing-status) * [Get user presence](/api/get-user-presence) +* [Get a user's status](/api/get-user-status) * [Get presence of all users](/api/get-presence) * [Get attachments](/api/get-attachments) * [Delete an attachment](/api/remove-attachment) diff --git a/version.py b/version.py index ad60c80db7..59ba6694f6 100644 --- a/version.py +++ b/version.py @@ -33,7 +33,7 @@ DESKTOP_WARNING_VERSION = "5.9.3" # Changes should be accompanied by documentation explaining what the # new level means in api_docs/changelog.md, as well as "**Changes**" # entries in the endpoint's documentation in `zulip.yaml`. -API_FEATURE_LEVEL = 261 +API_FEATURE_LEVEL = 262 # 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 diff --git a/zerver/lib/user_status.py b/zerver/lib/user_status.py index 81f1fb645c..854d613eab 100644 --- a/zerver/lib/user_status.py +++ b/zerver/lib/user_status.py @@ -113,3 +113,22 @@ def update_user_status( user_profile_id=user_profile_id, defaults=defaults, ) + + +def get_user_status(user_profile: UserProfile) -> UserInfoDict: + status_set_by_user = ( + UserStatus.objects.filter(user_profile=user_profile) + .values( + "user_profile_id", + "user_profile__presence_enabled", + "status_text", + "emoji_name", + "emoji_code", + "reaction_type", + ) + .first() + ) + + if not status_set_by_user: + return {} + return format_user_status(status_set_by_user) diff --git a/zerver/openapi/python_examples.py b/zerver/openapi/python_examples.py index 60552c5788..86a5c7ef7e 100644 --- a/zerver/openapi/python_examples.py +++ b/zerver/openapi/python_examples.py @@ -195,6 +195,20 @@ def get_user_presence(client: Client) -> None: validate_against_openapi_schema(result, "/users/{user_id_or_email}/presence", "get", "200") +@openapi_test_function("/users/{user_id}/status:get") +def get_user_status(client: Client) -> None: + # {code_example|start} + # Get the status currently set by a user. + user_id = 11 + result = client.call_endpoint( + url=f"/users/{user_id}/status", + method="GET", + ) + # {code_example|end} + + validate_against_openapi_schema(result, "/users/{user_id}/status", "get", "200") + + @openapi_test_function("/users/me/presence:post") def update_presence(client: Client) -> None: request = { @@ -1701,6 +1715,7 @@ def test_users(client: Client, owner_client: Client) -> None: reactivate_user(client) update_user(client) update_status(client) + get_user_status(client) get_user_by_email(client) get_subscription_status(client) get_profile(client) diff --git a/zerver/openapi/zulip.yaml b/zerver/openapi/zulip.yaml index 09617a6c87..619848b04b 100644 --- a/zerver/openapi/zulip.yaml +++ b/zerver/openapi/zulip.yaml @@ -8416,6 +8416,68 @@ paths: "200": $ref: "#/components/responses/SimpleSuccess" + /users/{user_id}/status: + get: + operationId: get-user-status + summary: Get a user's status + tags: ["users"] + description: | + Get the [status](/help/status-and-availability) currently set by a + user in the organization. + + **Changes**: New in Zulip 9.0 (feature level 262). Previously, + user statuses could only be fetched via the [`POST + /register`](/api/register-queue) endpoint. + parameters: + - $ref: "#/components/parameters/UserId" + responses: + "200": + description: Success. + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/JsonSuccessBase" + - additionalProperties: false + properties: + result: {} + msg: {} + ignored_parameters_unsupported: {} + status: + allOf: + - description: | + The status set by the user. Note that, if the user doesn't have a status + currently set, then the returned dictionary will be empty as none of the + keys listed below will be present. + - $ref: "#/components/schemas/UserStatus" + + example: + { + "result": "success", + "msg": "", + "status": + { + "status_text": "on vacation", + "emoji_name": "car", + "emoji_code": "1f697", + "reaction_type": "unicode_emoji", + }, + } + "400": + description: Success. + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CodedError" + - example: + { + "result": "error", + "msg": "No such user", + "code": "BAD_REQUEST", + } + description: | + An example JSON error response when the user does not exist: /users/{user_id_or_email}/presence: get: operationId: get-user-presence @@ -13652,63 +13714,11 @@ paths: **Changes**: The emoji parameters are new in Zulip 5.0 (feature level 86). Previously, Zulip did not support emoji associated with statuses. additionalProperties: - description: | - `{user_id}`: Object containing the status details of a user - with the key of the object being the ID of the user. - type: object - additionalProperties: false - properties: - away: - type: boolean - deprecated: true - description: | - If present, the user has marked themself "away". - - **Changes**: Deprecated in Zulip 6.0 (feature level 148); - starting with that feature level, `away` is a legacy way to - access the user's `presence_enabled` setting, with - `away = !presence_enabled`. To be removed in a future release. - status_text: - type: string - description: | - If present, the text content of the user's status message. - emoji_name: - type: string - description: | - If present, the name for the emoji to associate with the user's status. - - **Changes**: New in Zulip 5.0 (feature level 86). - emoji_code: - type: string - description: | - If present, 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). - reaction_type: - type: string - enum: - - unicode_emoji - - realm_emoji - - zulip_extra_emoji - description: | - If present, 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). + allOf: + - description: | + `{user_id}`: Object containing the status details of a user + with the key of the object being the ID of the user. + - $ref: "#/components/schemas/UserStatus" user_settings: type: object description: | @@ -21100,6 +21110,61 @@ components: **Changes**: Starting with Zulip 7.0 (feature level 178), always `false` when present as the server no longer stores which client submitted presence data. + UserStatus: + type: object + additionalProperties: false + properties: + away: + type: boolean + deprecated: true + description: | + If present, the user has marked themself "away". + + **Changes**: Deprecated in Zulip 6.0 (feature level 148); + starting with that feature level, `away` is a legacy way to + access the user's `presence_enabled` setting, with + `away = !presence_enabled`. To be removed in a future release. + status_text: + type: string + description: | + If present, the text content of the user's status message. + emoji_name: + type: string + description: | + If present, the name for the emoji to associate with the user's status. + + **Changes**: New in Zulip 5.0 (feature level 86). + emoji_code: + type: string + description: | + If present, 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). + reaction_type: + type: string + enum: + - unicode_emoji + - realm_emoji + - zulip_extra_emoji + description: | + If present, 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). Draft: type: object description: | diff --git a/zerver/tests/test_user_status.py b/zerver/tests/test_user_status.py index 9298c9dcd9..72a611fcc7 100644 --- a/zerver/tests/test_user_status.py +++ b/zerver/tests/test_user_status.py @@ -3,7 +3,12 @@ from typing import Any, Dict, Optional import orjson from zerver.lib.test_classes import ZulipTestCase -from zerver.lib.user_status import UserInfoDict, get_all_users_status_dict, update_user_status +from zerver.lib.user_status import ( + UserInfoDict, + get_all_users_status_dict, + get_user_status, + update_user_status, +) from zerver.models import UserProfile, UserStatus from zerver.models.clients import get_client @@ -65,6 +70,17 @@ class UserStatusTest(ZulipTestCase): ), ) + fetched_status = get_user_status(hamlet) + self.assertEqual( + fetched_status, + dict( + status_text="out to lunch", + emoji_name="car", + emoji_code="1f697", + reaction_type=UserStatus.UNICODE_EMOJI, + ), + ) + rec_count = UserStatus.objects.filter(user_profile_id=hamlet.id).count() self.assertEqual(rec_count, 1) @@ -88,6 +104,17 @@ class UserStatusTest(ZulipTestCase): ), ) + fetched_status = get_user_status(hamlet) + self.assertEqual( + fetched_status, + dict( + status_text="out to lunch", + emoji_name="car", + emoji_code="1f697", + reaction_type=UserStatus.UNICODE_EMOJI, + ), + ) + # Clear the status_text and emoji_info now. update_user_status( user_profile_id=hamlet.id, @@ -103,6 +130,12 @@ class UserStatusTest(ZulipTestCase): {}, ) + fetched_status = get_user_status(hamlet) + self.assertEqual( + fetched_status, + {}, + ) + # Set Hamlet to in a meeting. update_user_status( user_profile_id=hamlet.id, @@ -118,6 +151,12 @@ class UserStatusTest(ZulipTestCase): dict(status_text="in a meeting"), ) + fetched_status = get_user_status(hamlet) + self.assertEqual( + fetched_status, + dict(status_text="in a meeting"), + ) + # Test user status for inaccessible users. self.set_up_db_for_testing_user_access() cordelia = self.example_user("cordelia") @@ -204,6 +243,13 @@ class UserStatusTest(ZulipTestCase): 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. @@ -234,6 +280,19 @@ class UserStatusTest(ZulipTestCase): ), ) + 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", + emoji_name="car", + emoji_code="1f697", + reaction_type=UserStatus.UNICODE_EMOJI, + ), + ) + # Server should remove emoji_code and reaction_type if emoji_name is empty. self.update_status_and_assert_event( payload=dict( @@ -252,6 +311,13 @@ class UserStatusTest(ZulipTestCase): 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"), + ) + # Now revoke "away" status. self.update_status_and_assert_event( payload=dict(away=orjson.dumps(False).decode()), @@ -280,6 +346,13 @@ class UserStatusTest(ZulipTestCase): dict(status_text="in office"), ) + result = self.client_get(f"/json/users/{hamlet.id}/status") + result_dict = self.assert_json_success(result) + self.assertEqual( + result_dict["status"], + dict(status_text="in office"), + ) + # And finally clear your info. self.update_status_and_assert_event( payload=dict(status_text=""), @@ -290,6 +363,13 @@ class UserStatusTest(ZulipTestCase): {}, ) + result = self.client_get(f"/json/users/{hamlet.id}/status") + result_dict = self.assert_json_success(result) + self.assertEqual( + result_dict["status"], + {}, + ) + # Turn on "away" status again. self.update_status_and_assert_event( payload=dict(away=orjson.dumps(True).decode()), @@ -312,3 +392,23 @@ class UserStatusTest(ZulipTestCase): user_status_info(hamlet), dict(status_text="at the beach", away=True), ) + + result = self.client_get(f"/json/users/{hamlet.id}/status") + result_dict = self.assert_json_success(result) + self.assertEqual( + result_dict["status"], + dict(status_text="at the beach", away=True), + ) + + # Invalid user ID should fail + result = self.client_get("/json/users/12345/status") + self.assert_json_error(result, "No such user") + + # Test status if the status has not been set + iago = self.example_user("iago") + result = self.client_get(f"/json/users/{iago.id}/status") + result_dict = self.assert_json_success(result) + self.assertEqual( + result_dict["status"], + {}, + ) diff --git a/zerver/views/presence.py b/zerver/views/presence.py index 56d5ab5035..67a5f330a3 100644 --- a/zerver/views/presence.py +++ b/zerver/views/presence.py @@ -18,7 +18,8 @@ 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.users import check_can_access_user +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 from zerver.models.users import get_active_user, get_active_user_profile_by_id_in_realm @@ -66,6 +67,13 @@ def get_presence_backend( return json_success(request, data=result) +def get_status_backend( + request: HttpRequest, user_profile: UserProfile, user_id: int +) -> HttpResponse: + target_user = access_user_by_id(user_profile, user_id, for_admin=False) + return json_success(request, data={"status": get_user_status(target_user)}) + + @human_users_only @typed_endpoint def update_user_status_backend( diff --git a/zproject/urls.py b/zproject/urls.py index d49fc506ab..e5e4f66af2 100644 --- a/zproject/urls.py +++ b/zproject/urls.py @@ -89,6 +89,7 @@ from zerver.views.muted_users import mute_user, unmute_user from zerver.views.onboarding_steps import mark_onboarding_step_as_read from zerver.views.presence import ( get_presence_backend, + get_status_backend, get_statuses_for_realm, update_active_status_backend, update_user_status_backend, @@ -396,6 +397,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), # user_groups -> zerver.views.user_groups rest_path("user_groups", GET=get_user_group), rest_path("user_groups/create", POST=add_user_group),