streams: Convert to typed_enpoint.

This commit is contained in:
Kenneth Rodrigues 2024-07-20 14:40:02 +05:30 committed by Tim Abbott
parent 025fba0a11
commit 8b489f4b96
4 changed files with 211 additions and 183 deletions

View File

@ -376,7 +376,12 @@ def parse_value_for_parameter(parameter: FuncParam[T], value: object) -> T:
elif error["type"] == "value_error": elif error["type"] == "value_error":
context["msg"] = error["msg"] context["msg"] = error["msg"]
error_template = _("Invalid {var_name}: {msg}") error_template = _("Invalid {var_name}: {msg}")
elif error["type"] == "model_type":
context["msg"] = error["msg"]
error_template = _("Invalid {var_name}: {msg}")
elif error["type"] == "missing":
context["msg"] = error["msg"]
error_template = _("{var_name} field is missing: {msg}")
assert error_template is not None, MISSING_ERROR_TEMPLATE.format( assert error_template is not None, MISSING_ERROR_TEMPLATE.format(
error_type=error["type"], error_type=error["type"],
url=error.get("url", "(documentation unavailable)"), url=error.get("url", "(documentation unavailable)"),

View File

@ -1,3 +1,4 @@
import re
import zoneinfo import zoneinfo
from collections.abc import Collection from collections.abc import Collection
@ -83,3 +84,12 @@ def to_non_negative_int_or_none(s: str) -> NonNegativeInt | None:
# integer, and we want to return None in that case. # integer, and we want to return None in that case.
def non_negative_int_or_none_validator() -> BeforeValidator: def non_negative_int_or_none_validator() -> BeforeValidator:
return BeforeValidator(lambda s: to_non_negative_int_or_none(s)) return BeforeValidator(lambda s: to_non_negative_int_or_none(s))
def check_color(var_name: str, val: object) -> str:
s = str(val)
valid_color_pattern = re.compile(r"^#([a-fA-F0-9]{3,6})$")
matched_results = valid_color_pattern.match(s)
if not matched_results:
raise ValueError(_("{var_name} is not a valid hex color code").format(var_name=var_name))
return s

View File

@ -3403,7 +3403,9 @@ class SubscriptionPropertiesTest(ZulipTestCase):
).decode() ).decode()
}, },
) )
self.assert_json_error(result, "color is not a valid hex color code") self.assert_json_error(
result, "Invalid subscription_data[0]: Value error, color is not a valid hex color code"
)
def test_set_color_missing_stream_id(self) -> None: def test_set_color_missing_stream_id(self) -> None:
""" """
@ -3420,7 +3422,9 @@ class SubscriptionPropertiesTest(ZulipTestCase):
).decode() ).decode()
}, },
) )
self.assert_json_error(result, "stream_id key is missing from subscription_data[0]") self.assert_json_error(
result, 'subscription_data[0]["stream_id"] field is missing: Field required'
)
def test_set_color_unsubscribed_stream_id(self) -> None: def test_set_color_unsubscribed_stream_id(self) -> None:
""" """
@ -3468,7 +3472,9 @@ class SubscriptionPropertiesTest(ZulipTestCase):
).decode() ).decode()
}, },
) )
self.assert_json_error(result, "value key is missing from subscription_data[0]") self.assert_json_error(
result, 'subscription_data[0]["value"] field is missing: Field required'
)
def test_set_stream_wildcard_mentions_notify(self) -> None: def test_set_stream_wildcard_mentions_notify(self) -> None:
""" """
@ -3733,7 +3739,9 @@ class SubscriptionPropertiesTest(ZulipTestCase):
).decode() ).decode()
}, },
) )
self.assert_json_error(result, f"{property_name} is not a string") self.assert_json_error(
result, "Invalid subscription_data[0]: Value error, color is not a valid hex color code"
)
def test_json_subscription_property_invalid_stream(self) -> None: def test_json_subscription_property_invalid_stream(self) -> None:
test_user = self.example_user("hamlet") test_user = self.example_user("hamlet")
@ -3838,7 +3846,9 @@ class SubscriptionRestApiTest(ZulipTestCase):
# incorrect color format # incorrect color format
subscriptions = [{"name": "my_test_stream_3", "color": "#0g0g0g"}] subscriptions = [{"name": "my_test_stream_3", "color": "#0g0g0g"}]
result = self.common_subscribe_to_streams(user, subscriptions, allow_fail=True) result = self.common_subscribe_to_streams(user, subscriptions, allow_fail=True)
self.assert_json_error(result, 'subscriptions[0]["color"] is not a valid hex color code') self.assert_json_error(
result, "Invalid subscriptions[0]: Value error, add.color is not a valid hex color code"
)
def test_api_valid_property(self) -> None: def test_api_valid_property(self) -> None:
""" """
@ -3881,7 +3891,7 @@ class SubscriptionRestApiTest(ZulipTestCase):
result = self.api_patch( result = self.api_patch(
user, user,
"/api/v1/users/me/subscriptions/121", "/api/v1/users/me/subscriptions/121",
{"property": "is_muted", "value": "somevalue"}, {"property": "is_muted", "value": orjson.dumps(True).decode()},
) )
self.assert_json_error(result, "Invalid channel ID") self.assert_json_error(result, "Invalid channel ID")
@ -3896,8 +3906,11 @@ class SubscriptionRestApiTest(ZulipTestCase):
result = self.api_patch(user, "/api/v1/users/me/subscriptions", request) result = self.api_patch(user, "/api/v1/users/me/subscriptions", request)
self.assert_json_error(result, expected_message) self.assert_json_error(result, expected_message)
check_for_error(["foo"], "add[0] is not a dict") check_for_error(
check_for_error([{"bogus": "foo"}], "name key is missing from add[0]") ["foo"],
"Invalid add[0]: Input should be a valid dictionary or instance of AddSubscriptionData",
)
check_for_error([{"bogus": "foo"}], 'add[0]["name"] field is missing: Field required')
check_for_error([{"name": {}}], 'add[0]["name"] is not a string') check_for_error([{"name": {}}], 'add[0]["name"] is not a string')
def test_bad_principals(self) -> None: def test_bad_principals(self) -> None:
@ -3909,7 +3922,7 @@ class SubscriptionRestApiTest(ZulipTestCase):
"principals": orjson.dumps([{}]).decode(), "principals": orjson.dumps([{}]).decode(),
} }
result = self.api_patch(user, "/api/v1/users/me/subscriptions", request) result = self.api_patch(user, "/api/v1/users/me/subscriptions", request)
self.assert_json_error(result, "principals is not an allowed_type") self.assert_json_error(result, 'principals["list[str]"][0] is not a string')
def test_bad_delete_parameters(self) -> None: def test_bad_delete_parameters(self) -> None:
user = self.example_user("hamlet") user = self.example_user("hamlet")

View File

@ -1,16 +1,16 @@
import time import time
from collections import defaultdict from collections import defaultdict
from collections.abc import Callable, Mapping, Sequence from collections.abc import Callable
from typing import Any from typing import Annotated, Any
import orjson import orjson
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
from django.db import transaction from django.db import transaction
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.utils.translation import override as override_language from django.utils.translation import override as override_language
from pydantic import BaseModel, Field, Json, NonNegativeInt, StringConstraints, model_validator
from zerver.actions.default_streams import ( from zerver.actions.default_streams import (
do_add_default_stream, do_add_default_stream,
@ -48,7 +48,6 @@ from zerver.lib.email_mirror_helpers import encode_email_address
from zerver.lib.exceptions import JsonableError, OrganizationOwnerRequiredError from zerver.lib.exceptions import JsonableError, OrganizationOwnerRequiredError
from zerver.lib.mention import MentionBackend, silent_mention_syntax_for_user from zerver.lib.mention import MentionBackend, silent_mention_syntax_for_user
from zerver.lib.message import bulk_access_stream_messages_query from zerver.lib.message import bulk_access_stream_messages_query
from zerver.lib.request import REQ, has_request_variables
from zerver.lib.response import json_success from zerver.lib.response import json_success
from zerver.lib.retention import STREAM_MESSAGE_BATCH_SIZE as RETENTION_STREAM_MESSAGE_BATCH_SIZE from zerver.lib.retention import STREAM_MESSAGE_BATCH_SIZE as RETENTION_STREAM_MESSAGE_BATCH_SIZE
from zerver.lib.retention import parse_message_retention_days from zerver.lib.retention import parse_message_retention_days
@ -73,24 +72,11 @@ from zerver.lib.topic import (
get_topic_history_for_stream, get_topic_history_for_stream,
messages_for_topic, messages_for_topic,
) )
from zerver.lib.types import Validator from zerver.lib.typed_endpoint import ApiParamConfig, PathOnly, typed_endpoint
from zerver.lib.typed_endpoint_validators import check_color, check_int_in_validator
from zerver.lib.user_groups import access_user_group_for_setting from zerver.lib.user_groups import access_user_group_for_setting
from zerver.lib.users import access_user_by_email, access_user_by_id from zerver.lib.users import access_user_by_email, access_user_by_id
from zerver.lib.utils import assert_is_not_none from zerver.lib.utils import assert_is_not_none
from zerver.lib.validator import (
check_bool,
check_capped_string,
check_color,
check_dict,
check_dict_only,
check_int,
check_int_in,
check_list,
check_string,
check_string_or_int,
check_union,
to_non_negative_int,
)
from zerver.models import NamedUserGroup, Realm, Stream, UserProfile from zerver.models import NamedUserGroup, Realm, Stream, UserProfile
from zerver.models.users import get_system_bot from zerver.models.users import get_system_bot
@ -125,9 +111,9 @@ def deactivate_stream_backend(
@require_realm_admin @require_realm_admin
@has_request_variables @typed_endpoint
def add_default_stream( def add_default_stream(
request: HttpRequest, user_profile: UserProfile, stream_id: int = REQ(json_validator=check_int) request: HttpRequest, user_profile: UserProfile, *, stream_id: Json[int]
) -> HttpResponse: ) -> HttpResponse:
(stream, sub) = access_stream_by_id(user_profile, stream_id) (stream, sub) = access_stream_by_id(user_profile, stream_id)
if stream.invite_only: if stream.invite_only:
@ -137,13 +123,14 @@ def add_default_stream(
@require_realm_admin @require_realm_admin
@has_request_variables @typed_endpoint
def create_default_stream_group( def create_default_stream_group(
request: HttpRequest, request: HttpRequest,
user_profile: UserProfile, user_profile: UserProfile,
group_name: str = REQ(), *,
description: str = REQ(), group_name: str,
stream_names: list[str] = REQ(json_validator=check_list(check_string)), description: str,
stream_names: Json[list[str]],
) -> HttpResponse: ) -> HttpResponse:
streams = [] streams = []
for stream_name in stream_names: for stream_name in stream_names:
@ -154,13 +141,14 @@ def create_default_stream_group(
@require_realm_admin @require_realm_admin
@has_request_variables @typed_endpoint
def update_default_stream_group_info( def update_default_stream_group_info(
request: HttpRequest, request: HttpRequest,
user_profile: UserProfile, user_profile: UserProfile,
group_id: int, *,
new_group_name: str | None = REQ(default=None), group_id: PathOnly[int],
new_description: str | None = REQ(default=None), new_group_name: str | None = None,
new_description: str | None = None,
) -> HttpResponse: ) -> HttpResponse:
if not new_group_name and not new_description: if not new_group_name and not new_description:
raise JsonableError(_('You must pass "new_description" or "new_group_name".')) raise JsonableError(_('You must pass "new_description" or "new_group_name".'))
@ -174,13 +162,14 @@ def update_default_stream_group_info(
@require_realm_admin @require_realm_admin
@has_request_variables @typed_endpoint
def update_default_stream_group_streams( def update_default_stream_group_streams(
request: HttpRequest, request: HttpRequest,
user_profile: UserProfile, user_profile: UserProfile,
group_id: int, *,
op: str = REQ(), group_id: PathOnly[int],
stream_names: list[str] = REQ(json_validator=check_list(check_string)), op: str,
stream_names: Json[list[str]],
) -> HttpResponse: ) -> HttpResponse:
group = access_default_stream_group_by_id(user_profile.realm, group_id) group = access_default_stream_group_by_id(user_profile.realm, group_id)
streams = [] streams = []
@ -198,9 +187,9 @@ def update_default_stream_group_streams(
@require_realm_admin @require_realm_admin
@has_request_variables @typed_endpoint
def remove_default_stream_group( def remove_default_stream_group(
request: HttpRequest, user_profile: UserProfile, group_id: int request: HttpRequest, user_profile: UserProfile, *, group_id: PathOnly[int]
) -> HttpResponse: ) -> HttpResponse:
group = access_default_stream_group_by_id(user_profile.realm, group_id) group = access_default_stream_group_by_id(user_profile.realm, group_id)
do_remove_default_stream_group(user_profile.realm, group) do_remove_default_stream_group(user_profile.realm, group)
@ -208,9 +197,9 @@ def remove_default_stream_group(
@require_realm_admin @require_realm_admin
@has_request_variables @typed_endpoint
def remove_default_stream( def remove_default_stream(
request: HttpRequest, user_profile: UserProfile, stream_id: int = REQ(json_validator=check_int) request: HttpRequest, user_profile: UserProfile, *, stream_id: Json[int]
) -> HttpResponse: ) -> HttpResponse:
(stream, sub) = access_stream_by_id( (stream, sub) = access_stream_by_id(
user_profile, user_profile,
@ -221,29 +210,31 @@ def remove_default_stream(
return json_success(request) return json_success(request)
@has_request_variables @typed_endpoint
def update_stream_backend( def update_stream_backend(
request: HttpRequest, request: HttpRequest,
user_profile: UserProfile, user_profile: UserProfile,
stream_id: int, *,
description: str | None = REQ( stream_id: PathOnly[int],
str_validator=check_capped_string(Stream.MAX_DESCRIPTION_LENGTH), default=None description: Annotated[str, StringConstraints(max_length=Stream.MAX_DESCRIPTION_LENGTH)]
), | None = None,
is_private: bool | None = REQ(json_validator=check_bool, default=None), is_private: Json[bool] | None = None,
is_announcement_only: bool | None = REQ(json_validator=check_bool, default=None), is_announcement_only: Json[bool] | None = None,
is_default_stream: bool | None = REQ(json_validator=check_bool, default=None), is_default_stream: Json[bool] | None = None,
stream_post_policy: int | None = REQ( stream_post_policy: Json[
json_validator=check_int_in(Stream.STREAM_POST_POLICY_TYPES), default=None Annotated[
), int,
history_public_to_subscribers: bool | None = REQ(json_validator=check_bool, default=None), check_int_in_validator(Stream.STREAM_POST_POLICY_TYPES),
is_web_public: bool | None = REQ(json_validator=check_bool, default=None), ]
new_name: str | None = REQ(default=None), ]
message_retention_days: int | str | None = REQ( | None = None,
json_validator=check_string_or_int, default=None history_public_to_subscribers: Json[bool] | None = None,
), is_web_public: Json[bool] | None = None,
can_remove_subscribers_group_id: int | None = REQ( new_name: str | None = None,
"can_remove_subscribers_group", json_validator=check_int, default=None message_retention_days: Json[str] | Json[int] | None = None,
), can_remove_subscribers_group_id: Annotated[
Json[int | None], ApiParamConfig("can_remove_subscribers_group")
] = None,
) -> HttpResponse: ) -> HttpResponse:
# We allow realm administrators to to update the stream name and # We allow realm administrators to to update the stream name and
# description even for private streams. # description even for private streams.
@ -397,11 +388,12 @@ def update_stream_backend(
return json_success(request) return json_success(request)
@has_request_variables @typed_endpoint
def list_subscriptions_backend( def list_subscriptions_backend(
request: HttpRequest, request: HttpRequest,
user_profile: UserProfile, user_profile: UserProfile,
include_subscribers: bool = REQ(json_validator=check_bool, default=False), *,
include_subscribers: Json[bool] = False,
) -> HttpResponse: ) -> HttpResponse:
subscribed, _ = gather_subscriptions( subscribed, _ = gather_subscriptions(
user_profile, user_profile,
@ -410,26 +402,32 @@ def list_subscriptions_backend(
return json_success(request, data={"subscriptions": subscribed}) return json_success(request, data={"subscriptions": subscribed})
add_subscriptions_schema = check_list( class AddSubscriptionData(BaseModel):
check_dict_only( name: str
required_keys=[("name", check_string)], color: str | None = None
optional_keys=[ description: (
("color", check_color), Annotated[str, StringConstraints(max_length=Stream.MAX_DESCRIPTION_LENGTH)] | None
("description", check_capped_string(Stream.MAX_DESCRIPTION_LENGTH)), ) = None
],
),
)
remove_subscriptions_schema = check_list(check_string) @model_validator(mode="after")
def validate_terms(self) -> "AddSubscriptionData":
if self.color is not None:
self.color = check_color("add.color", self.color)
return self
@has_request_variables @typed_endpoint
def update_subscriptions_backend( def update_subscriptions_backend(
request: HttpRequest, request: HttpRequest,
user_profile: UserProfile, user_profile: UserProfile,
delete: Sequence[str] = REQ(json_validator=remove_subscriptions_schema, default=[]), *,
add: Sequence[Mapping[str, str]] = REQ(json_validator=add_subscriptions_schema, default=[]), delete: Json[list[str]] | None = None,
add: Json[list[AddSubscriptionData]] | None = None,
) -> HttpResponse: ) -> HttpResponse:
if delete is None:
delete = []
if add is None:
add = []
if not add and not delete: if not add and not delete:
raise JsonableError(_('Nothing to do. Specify at least one of "add" or "delete".')) raise JsonableError(_('Nothing to do. Specify at least one of "add" or "delete".'))
@ -459,17 +457,13 @@ def compose_views(thunks: list[Callable[[], HttpResponse]]) -> dict[str, Any]:
return json_dict return json_dict
check_principals: Validator[list[str] | list[int]] = check_union( @typed_endpoint
[check_list(check_string), check_list(check_int)],
)
@has_request_variables
def remove_subscriptions_backend( def remove_subscriptions_backend(
request: HttpRequest, request: HttpRequest,
user_profile: UserProfile, user_profile: UserProfile,
streams_raw: Sequence[str] = REQ("subscriptions", json_validator=remove_subscriptions_schema), *,
principals: list[str] | list[int] | None = REQ(json_validator=check_principals, default=None), streams_raw: Annotated[Json[list[str]], ApiParamConfig("subscriptions")],
principals: Json[list[str] | list[int]] | None = None,
) -> HttpResponse: ) -> HttpResponse:
realm = user_profile.realm realm = user_profile.realm
@ -529,42 +523,36 @@ def you_were_just_subscribed_message(
RETENTION_DEFAULT: str | int = "realm_default" RETENTION_DEFAULT: str | int = "realm_default"
EMPTY_PRINCIPALS: Sequence[str] | Sequence[int] = []
@transaction.atomic(savepoint=False) @transaction.atomic(savepoint=False)
@require_non_guest_user @require_non_guest_user
@has_request_variables @typed_endpoint
def add_subscriptions_backend( def add_subscriptions_backend(
request: HttpRequest, request: HttpRequest,
user_profile: UserProfile, user_profile: UserProfile,
streams_raw: Sequence[Mapping[str, str]] = REQ( *,
"subscriptions", json_validator=add_subscriptions_schema streams_raw: Annotated[Json[list[AddSubscriptionData]], ApiParamConfig("subscriptions")],
), invite_only: Json[bool] = False,
invite_only: bool = REQ(json_validator=check_bool, default=False), is_web_public: Json[bool] = False,
is_web_public: bool = REQ(json_validator=check_bool, default=False), is_default_stream: Json[bool] = False,
is_default_stream: bool = REQ(json_validator=check_bool, default=False), stream_post_policy: Json[
stream_post_policy: int = REQ( Annotated[int, check_int_in_validator(Stream.STREAM_POST_POLICY_TYPES)]
json_validator=check_int_in(Stream.STREAM_POST_POLICY_TYPES), ] = Stream.STREAM_POST_POLICY_EVERYONE,
default=Stream.STREAM_POST_POLICY_EVERYONE, history_public_to_subscribers: Json[bool] | None = None,
), message_retention_days: Json[str] | Json[int] = RETENTION_DEFAULT,
history_public_to_subscribers: bool | None = REQ(json_validator=check_bool, default=None), can_remove_subscribers_group_id: Annotated[
message_retention_days: str | int = REQ( Json[int | None], ApiParamConfig("can_remove_subscribers_group")
json_validator=check_string_or_int, default=RETENTION_DEFAULT ] = None,
), announce: Json[bool] = False,
can_remove_subscribers_group_id: int | None = REQ( principals: Json[list[str] | list[int]] | None = None,
"can_remove_subscribers_group", json_validator=check_int, default=None authorization_errors_fatal: Json[bool] = True,
),
announce: bool = REQ(json_validator=check_bool, default=False),
principals: Sequence[str] | Sequence[int] = REQ(
json_validator=check_principals,
default=EMPTY_PRINCIPALS,
),
authorization_errors_fatal: bool = REQ(json_validator=check_bool, default=True),
) -> HttpResponse: ) -> HttpResponse:
realm = user_profile.realm realm = user_profile.realm
stream_dicts = [] stream_dicts = []
color_map = {} color_map = {}
if principals is None:
principals = []
if can_remove_subscribers_group_id is not None: if can_remove_subscribers_group_id is not None:
permission_configuration = Stream.stream_permission_group_settings[ permission_configuration = Stream.stream_permission_group_settings[
@ -586,18 +574,18 @@ def add_subscriptions_backend(
is_system_group=True, is_system_group=True,
) )
for stream_dict in streams_raw: for stream_obj in streams_raw:
# 'color' field is optional # 'color' field is optional
# check for its presence in the streams_raw first # check for its presence in the streams_raw first
if "color" in stream_dict: if stream_obj.color is not None:
color_map[stream_dict["name"]] = stream_dict["color"] color_map[stream_obj.name] = stream_obj.color
stream_dict_copy: StreamDict = {} stream_dict_copy: StreamDict = {}
stream_dict_copy["name"] = stream_dict["name"].strip() stream_dict_copy["name"] = stream_obj.name.strip()
# We don't allow newline characters in stream descriptions. # We don't allow newline characters in stream descriptions.
if "description" in stream_dict: if stream_obj.description is not None:
stream_dict_copy["description"] = stream_dict["description"].replace("\n", " ") stream_dict_copy["description"] = stream_obj.description.replace("\n", " ")
stream_dict_copy["invite_only"] = invite_only stream_dict_copy["invite_only"] = invite_only
stream_dict_copy["is_web_public"] = is_web_public stream_dict_copy["is_web_public"] = is_web_public
@ -815,11 +803,12 @@ def send_messages_for_new_subscribers(
do_send_messages(notifications, mark_as_read=[user_profile.id]) do_send_messages(notifications, mark_as_read=[user_profile.id])
@has_request_variables @typed_endpoint
def get_subscribers_backend( def get_subscribers_backend(
request: HttpRequest, request: HttpRequest,
user_profile: UserProfile, user_profile: UserProfile,
stream_id: int = REQ("stream", converter=to_non_negative_int, path_only=True), *,
stream_id: Annotated[NonNegativeInt, ApiParamConfig("stream", path_only=True)],
) -> HttpResponse: ) -> HttpResponse:
(stream, sub) = access_stream_by_id( (stream, sub) = access_stream_by_id(
user_profile, user_profile,
@ -833,16 +822,17 @@ def get_subscribers_backend(
# By default, lists all streams that the user has access to -- # By default, lists all streams that the user has access to --
# i.e. public streams plus invite-only streams that the user is on # i.e. public streams plus invite-only streams that the user is on
@has_request_variables @typed_endpoint
def get_streams_backend( def get_streams_backend(
request: HttpRequest, request: HttpRequest,
user_profile: UserProfile, user_profile: UserProfile,
include_public: bool = REQ(json_validator=check_bool, default=True), *,
include_web_public: bool = REQ(json_validator=check_bool, default=False), include_public: Json[bool] = True,
include_subscribed: bool = REQ(json_validator=check_bool, default=True), include_web_public: Json[bool] = False,
include_all_active: bool = REQ(json_validator=check_bool, default=False), include_subscribed: Json[bool] = True,
include_default: bool = REQ(json_validator=check_bool, default=False), include_all_active: Json[bool] = False,
include_owner_subscribed: bool = REQ(json_validator=check_bool, default=False), include_default: Json[bool] = False,
include_owner_subscribed: Json[bool] = False,
) -> HttpResponse: ) -> HttpResponse:
streams = do_get_streams( streams = do_get_streams(
user_profile, user_profile,
@ -856,11 +846,12 @@ def get_streams_backend(
return json_success(request, data={"streams": streams}) return json_success(request, data={"streams": streams})
@has_request_variables @typed_endpoint
def get_stream_backend( def get_stream_backend(
request: HttpRequest, request: HttpRequest,
user_profile: UserProfile, user_profile: UserProfile,
stream_id: int, *,
stream_id: PathOnly[int],
) -> HttpResponse: ) -> HttpResponse:
(stream, sub) = access_stream_by_id(user_profile, stream_id, allow_realm_admin=True) (stream, sub) = access_stream_by_id(user_profile, stream_id, allow_realm_admin=True)
@ -868,11 +859,12 @@ def get_stream_backend(
return json_success(request, data={"stream": stream_to_dict(stream, recent_traffic)}) return json_success(request, data={"stream": stream_to_dict(stream, recent_traffic)})
@has_request_variables @typed_endpoint
def get_topics_backend( def get_topics_backend(
request: HttpRequest, request: HttpRequest,
maybe_user_profile: UserProfile | AnonymousUser, maybe_user_profile: UserProfile | AnonymousUser,
stream_id: int = REQ(converter=to_non_negative_int, path_only=True), *,
stream_id: PathOnly[NonNegativeInt],
) -> HttpResponse: ) -> HttpResponse:
if not maybe_user_profile.is_authenticated: if not maybe_user_profile.is_authenticated:
is_web_public_query = True is_web_public_query = True
@ -906,12 +898,13 @@ def get_topics_backend(
@require_realm_admin @require_realm_admin
@has_request_variables @typed_endpoint
def delete_in_topic( def delete_in_topic(
request: HttpRequest, request: HttpRequest,
user_profile: UserProfile, user_profile: UserProfile,
stream_id: int = REQ(converter=to_non_negative_int, path_only=True), *,
topic_name: str = REQ("topic_name"), stream_id: PathOnly[NonNegativeInt],
topic_name: str,
) -> HttpResponse: ) -> HttpResponse:
stream, ignored_sub = access_stream_by_id(user_profile, stream_id) stream, ignored_sub = access_stream_by_id(user_profile, stream_id)
@ -943,43 +936,70 @@ def delete_in_topic(
return json_success(request, data={"complete": True}) return json_success(request, data={"complete": True})
@has_request_variables @typed_endpoint
def json_get_stream_id( def json_get_stream_id(
request: HttpRequest, user_profile: UserProfile, stream_name: str = REQ("stream") request: HttpRequest,
user_profile: UserProfile,
*,
stream_name: Annotated[str, ApiParamConfig("stream")],
) -> HttpResponse: ) -> HttpResponse:
(stream, sub) = access_stream_by_name(user_profile, stream_name) (stream, sub) = access_stream_by_name(user_profile, stream_name)
return json_success(request, data={"stream_id": stream.id}) return json_success(request, data={"stream_id": stream.id})
@has_request_variables class SubscriptionPropertyChangeRequest(BaseModel):
stream_id: int
property: str
value: bool | str
@model_validator(mode="after")
def validate_terms(self) -> "SubscriptionPropertyChangeRequest":
boolean_properties = {
"in_home_view",
"is_muted",
"desktop_notifications",
"audible_notifications",
"push_notifications",
"email_notifications",
"pin_to_top",
"wildcard_mentions_notify",
}
if self.property == "color":
self.value = check_color("color", self.value)
elif self.property in boolean_properties:
if not isinstance(self.value, bool):
raise JsonableError(_("{property} is not a boolean").format(property=self.property))
else:
raise JsonableError(
_("Unknown subscription property: {property}").format(property=self.property)
)
return self
@typed_endpoint
def update_subscriptions_property( def update_subscriptions_property(
request: HttpRequest, request: HttpRequest,
user_profile: UserProfile, user_profile: UserProfile,
stream_id: int = REQ(json_validator=check_int), *,
property: str = REQ(), stream_id: PathOnly[Json[int]],
value: str = REQ(), property: str,
value: Annotated[Json[bool] | str, Field(union_mode="left_to_right")],
) -> HttpResponse: ) -> HttpResponse:
subscription_data = [{"property": property, "stream_id": stream_id, "value": value}] change_request = SubscriptionPropertyChangeRequest(
stream_id=stream_id, property=property, value=value
)
return update_subscription_properties_backend( return update_subscription_properties_backend(
request, user_profile, subscription_data=subscription_data request, user_profile, subscription_data=[change_request]
) )
@has_request_variables @typed_endpoint
def update_subscription_properties_backend( def update_subscription_properties_backend(
request: HttpRequest, request: HttpRequest,
user_profile: UserProfile, user_profile: UserProfile,
subscription_data: list[dict[str, Any]] = REQ( *,
json_validator=check_list( subscription_data: Json[list[SubscriptionPropertyChangeRequest]],
check_dict(
[
("stream_id", check_int),
("property", check_string),
("value", check_union([check_string, check_bool])),
]
),
),
),
) -> HttpResponse: ) -> HttpResponse:
""" """
This is the entry point to changing subscription properties. This This is the entry point to changing subscription properties. This
@ -991,27 +1011,11 @@ def update_subscription_properties_backend(
[{"stream_id": "1", "property": "is_muted", "value": False}, [{"stream_id": "1", "property": "is_muted", "value": False},
{"stream_id": "1", "property": "color", "value": "#c2c2c2"}] {"stream_id": "1", "property": "color", "value": "#c2c2c2"}]
""" """
property_converters = {
"color": check_color,
"in_home_view": check_bool,
"is_muted": check_bool,
"desktop_notifications": check_bool,
"audible_notifications": check_bool,
"push_notifications": check_bool,
"email_notifications": check_bool,
"pin_to_top": check_bool,
"wildcard_mentions_notify": check_bool,
}
for change in subscription_data: for change in subscription_data:
stream_id = change["stream_id"] stream_id = change.stream_id
property = change["property"] property = change.property
value = change["value"] value = change.value
if property not in property_converters:
raise JsonableError(
_("Unknown subscription property: {property}").format(property=property)
)
(stream, sub) = access_stream_by_id(user_profile, stream_id) (stream, sub) = access_stream_by_id(user_profile, stream_id)
if sub is None: if sub is None:
@ -1019,11 +1023,6 @@ def update_subscription_properties_backend(
_("Not subscribed to channel ID {channel_id}").format(channel_id=stream_id) _("Not subscribed to channel ID {channel_id}").format(channel_id=stream_id)
) )
try:
value = property_converters[property](property, value)
except ValidationError as error:
raise JsonableError(error.message)
do_change_subscription_property( do_change_subscription_property(
user_profile, sub, stream, property, value, acting_user=user_profile user_profile, sub, stream, property, value, acting_user=user_profile
) )
@ -1031,11 +1030,12 @@ def update_subscription_properties_backend(
return json_success(request) return json_success(request)
@has_request_variables @typed_endpoint
def get_stream_email_address( def get_stream_email_address(
request: HttpRequest, request: HttpRequest,
user_profile: UserProfile, user_profile: UserProfile,
stream_id: int = REQ("stream", converter=to_non_negative_int, path_only=True), *,
stream_id: Annotated[NonNegativeInt, ApiParamConfig("stream", path_only=True)],
) -> HttpResponse: ) -> HttpResponse:
(stream, sub) = access_stream_by_id( (stream, sub) = access_stream_by_id(
user_profile, user_profile,