users: Allow spectators to access /users API endpoint.

We need this to support faster initial loading time for spectators.
This commit is contained in:
Aman Agrawal
2025-05-15 18:50:49 +05:30
committed by Tim Abbott
parent 078a27def2
commit 1dc845f07b
7 changed files with 74 additions and 9 deletions

View File

@@ -20,6 +20,12 @@ format used by the Zulip server that they are interacting with.
## Changes in Zulip 11.0 ## Changes in Zulip 11.0
**Feature level 387**
* [`GET /users`](/api/get-users): This endpoint no longer requires
authentication for organizations using the [public access
option](/help/public-access-option).
**Feature level 386** **Feature level 386**
* [`PATCH /user_groups/{user_group_id}`](/api/update-user-group): * [`PATCH /user_groups/{user_group_id}`](/api/update-user-group):

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 = 386 API_FEATURE_LEVEL = 387
# 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

@@ -9526,6 +9526,11 @@ paths:
Optionally includes values of [custom profile fields](/help/custom-profile-fields). Optionally includes values of [custom profile fields](/help/custom-profile-fields).
You can also [fetch details on a single user](/api/get-user). You can also [fetch details on a single user](/api/get-user).
**Changes**: This endpoint did not support unauthenticated
access in organizations using the [public access
option](/help/public-access-option) prior to Zulip 11.0
(feature level 387).
x-curl-examples-parameters: x-curl-examples-parameters:
oneOf: oneOf:
- type: include - type: include

View File

@@ -1440,9 +1440,9 @@ class RestAPITest(ZulipTestCase):
self.assertEqual(str(result["Allow"]), "DELETE, GET, HEAD, PATCH") self.assertEqual(str(result["Allow"]), "DELETE, GET, HEAD, PATCH")
def test_http_accept_redirect(self) -> None: def test_http_accept_redirect(self) -> None:
result = self.client_get("/json/users", HTTP_ACCEPT="text/html") result = self.client_get("/json/attachments", HTTP_ACCEPT="text/html")
self.assertEqual(result.status_code, 302) self.assertEqual(result.status_code, 302)
self.assertTrue(result["Location"].endswith("/login/?next=%2Fjson%2Fusers")) self.assertTrue(result["Location"].endswith("/login/?next=%2Fjson%2Fattachments"))
class TestUserAgentParsing(ZulipTestCase): class TestUserAgentParsing(ZulipTestCase):

View File

@@ -2164,7 +2164,7 @@ class ActivateTest(ZulipTestCase):
session_key = self.client.session.session_key session_key = self.client.session.session_key
self.assertTrue(session_key) self.assertTrue(session_key)
result = self.client_get("/json/users") result = self.client_get("/json/attachments")
self.assert_json_success(result) self.assert_json_success(result)
self.assertEqual(Session.objects.filter(pk=session_key).count(), 1) self.assertEqual(Session.objects.filter(pk=session_key).count(), 1)
@@ -2172,7 +2172,7 @@ class ActivateTest(ZulipTestCase):
do_deactivate_user(user, acting_user=None) do_deactivate_user(user, acting_user=None)
self.assertEqual(Session.objects.filter(pk=session_key).count(), 0) self.assertEqual(Session.objects.filter(pk=session_key).count(), 0)
result = self.client_get("/json/users") result = self.client_get("/json/attachments")
self.assert_json_error( self.assert_json_error(
result, "Not logged in: API authentication or user session required", 401 result, "Not logged in: API authentication or user session required", 401
) )
@@ -3195,6 +3195,43 @@ class GetProfileTest(ZulipTestCase):
) )
self.assertEqual(inaccessible_user_ids, {othello.id}) self.assertEqual(inaccessible_user_ids, {othello.id})
def test_get_users_for_spectators(self) -> None:
# Checks that spectators can fetch users data.
hamlet = self.example_user("hamlet")
othello = self.example_user("othello")
# Try with a realm with no web-public channels.
with self.assert_database_query_count(2):
result = self.client_get("/json/users", subdomain="lear")
self.assert_json_error(
result,
"Not logged in: API authentication or user session required",
status_code=401,
)
with self.assert_database_query_count(4):
result = self.client_get("/json/users")
self.assert_json_success(result)
result_dict = orjson.loads(result.content)
all_fetched_users = result_dict["members"]
self.assertEqual(
len(all_fetched_users), UserProfile.objects.filter(realm=hamlet.realm).count()
)
user_ids_to_fetch = [hamlet.id, othello.id]
with self.assert_database_query_count(4):
result_dict = orjson.loads(
self.client_get(
"/json/users", {"user_ids": orjson.dumps(user_ids_to_fetch).decode()}
).content
)
all_fetched_users = result_dict["members"]
self.assertCountEqual(
[user["user_id"] for user in all_fetched_users],
user_ids_to_fetch,
)
class DeleteUserTest(ZulipTestCase): class DeleteUserTest(ZulipTestCase):
def test_do_delete_user(self) -> None: def test_do_delete_user(self) -> None:

View File

@@ -91,6 +91,7 @@ from zerver.models.realms import (
DomainNotAllowedForRealmError, DomainNotAllowedForRealmError,
EmailContainsPlusError, EmailContainsPlusError,
InvalidFakeEmailDomainError, InvalidFakeEmailDomainError,
Realm,
) )
from zerver.models.users import ( from zerver.models.users import (
get_user_by_delivery_email, get_user_by_delivery_email,
@@ -722,12 +723,13 @@ def get_bots_backend(request: HttpRequest, user_profile: UserProfile) -> HttpRes
def get_user_data( def get_user_data(
user_profile: UserProfile, user_profile: UserProfile | None,
include_custom_profile_fields: bool, include_custom_profile_fields: bool,
client_gravatar: bool, client_gravatar: bool,
*, *,
target_user: UserProfile | None = None, target_user: UserProfile | None = None,
user_ids: list[int] | None = None, user_ids: list[int] | None = None,
realm: Realm | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """
The client_gravatar field here is set to True by default assuming that clients The client_gravatar field here is set to True by default assuming that clients
@@ -735,6 +737,8 @@ def get_user_data(
an optimization than it might seem because gravatar URLs contain MD5 hashes that an optimization than it might seem because gravatar URLs contain MD5 hashes that
compress very poorly compared to other data. compress very poorly compared to other data.
""" """
if realm is None:
assert user_profile is not None
realm = user_profile.realm realm = user_profile.realm
members = get_users_for_api( members = get_users_for_api(
@@ -780,17 +784,28 @@ def get_member_backend(
@typed_endpoint @typed_endpoint
def get_members_backend( def get_members_backend(
request: HttpRequest, request: HttpRequest,
user_profile: UserProfile, maybe_user_profile: UserProfile | AnonymousUser,
*, *,
user_ids: Json[list[int]] | None = None, user_ids: Json[list[int]] | None = None,
include_custom_profile_fields: Json[bool] = False, include_custom_profile_fields: Json[bool] = False,
client_gravatar: Json[bool] = True, client_gravatar: Json[bool] = True,
) -> HttpResponse: ) -> HttpResponse:
if isinstance(maybe_user_profile, UserProfile):
user_profile = maybe_user_profile
realm = user_profile.realm
else:
realm = get_valid_realm_from_request(request)
if not realm.allow_web_public_streams_access():
raise MissingAuthenticationError
user_profile = None
data = get_user_data( data = get_user_data(
user_profile, user_profile,
include_custom_profile_fields, include_custom_profile_fields,
client_gravatar, client_gravatar,
user_ids=user_ids, user_ids=user_ids,
realm=realm,
) )
return json_success(request, data) return json_success(request, data)

View File

@@ -322,7 +322,9 @@ v1_api_and_json_patterns = [
# realm/deactivate -> zerver.views.deactivate_realm # realm/deactivate -> zerver.views.deactivate_realm
rest_path("realm/deactivate", POST=deactivate_realm), rest_path("realm/deactivate", POST=deactivate_realm),
# users -> zerver.views.users # users -> zerver.views.users
rest_path("users", GET=get_members_backend, POST=create_user_backend), rest_path(
"users", GET=(get_members_backend, {"allow_anonymous_user_web"}), POST=create_user_backend
),
rest_path("users/me", GET=get_profile_backend, DELETE=deactivate_user_own_backend), rest_path("users/me", GET=get_profile_backend, DELETE=deactivate_user_own_backend),
rest_path("users/<int:user_id>/reactivate", POST=reactivate_user_backend), rest_path("users/<int:user_id>/reactivate", POST=reactivate_user_backend),
rest_path( rest_path(