user_settings: Migrate to typed_endpoint.

Migrate `user_settings.py` to `typed_endpoint`.
Fix the error messages for the tests.
Migrate required validators.
This commit is contained in:
Kenneth Rodrigues 2024-07-30 13:47:33 +05:30 committed by Tim Abbott
parent 61ba381c30
commit 0f692436ca
4 changed files with 201 additions and 163 deletions

View File

@ -69,6 +69,18 @@ def timezone_or_empty_validator() -> AfterValidator:
return AfterValidator(lambda s: to_timezone_or_empty(s))
def check_timezone(s: str) -> str:
try:
zoneinfo.ZoneInfo(canonicalize_timezone(s))
except (ValueError, zoneinfo.ZoneInfoNotFoundError):
raise ValueError(_("Not a recognized time zone"))
return s
def timezone_validator() -> AfterValidator:
return AfterValidator(lambda s: check_timezone(s))
def to_non_negative_int_or_none(s: str) -> NonNegativeInt | None:
try:
i = int(s)

View File

@ -29,7 +29,6 @@ for any particular type of object.
"""
import re
import zoneinfo
from collections.abc import Collection, Container, Iterator
from dataclasses import dataclass
from datetime import datetime, timezone
@ -44,7 +43,6 @@ from pydantic.functional_validators import ModelWrapValidatorHandler
from typing_extensions import override
from zerver.lib.exceptions import InvalidJSONError, JsonableError
from zerver.lib.timezone import canonicalize_timezone
from zerver.lib.types import ProfileFieldData, Validator
ResultT = TypeVar("ResultT")
@ -116,17 +114,6 @@ def check_long_string(var_name: str, val: object) -> str:
return check_capped_string(500)(var_name, val)
def check_timezone(var_name: str, val: object) -> str:
s = check_string(var_name, val)
try:
zoneinfo.ZoneInfo(canonicalize_timezone(s))
except (ValueError, zoneinfo.ZoneInfoNotFoundError):
raise ValidationError(
_("{var_name} is not a recognized time zone").format(var_name=var_name)
)
return s
def check_date(var_name: str, val: object) -> str:
if not isinstance(val, str):
raise ValidationError(_("{var_name} is not a string").format(var_name=var_name))

View File

@ -407,39 +407,86 @@ class ChangeSettingsTest(ZulipTestCase):
self.do_test_change_user_setting("timezone")
def test_invalid_setting_value(self) -> None:
invalid_values_dict = dict(
default_language="invalid_de",
web_home_view="invalid_view",
emojiset="apple",
timezone="invalid_US/Mountain",
demote_inactive_streams=10,
web_mark_read_on_scroll_policy=10,
web_channel_default_view=10,
user_list_style=10,
web_animate_image_previews="invalid_value",
web_stream_unreads_count_display_policy=10,
color_scheme=10,
notification_sound="invalid_sound",
desktop_icon_count_display=10,
)
invalid_values: list[dict[str, Any]] = [
{
"setting_name": "default_language",
"value": "invalid_de",
"error_msg": "Invalid default_language",
},
{
"setting_name": "web_home_view",
"value": "invalid_view",
"error_msg": "Invalid web_home_view: Value error, Not in the list of possible values",
},
{
"setting_name": "emojiset",
"value": "apple",
"error_msg": "Invalid emojiset: Value error, Not in the list of possible values",
},
{
"setting_name": "timezone",
"value": "invalid_US/Mountain",
"error_msg": "Invalid timezone: Value error, Not a recognized time zone",
},
{
"setting_name": "demote_inactive_streams",
"value": 10,
"error_msg": "Invalid demote_inactive_streams: Value error, Not in the list of possible values",
},
{
"setting_name": "web_mark_read_on_scroll_policy",
"value": 10,
"error_msg": "Invalid web_mark_read_on_scroll_policy: Value error, Not in the list of possible values",
},
{
"setting_name": "web_channel_default_view",
"value": 10,
"error_msg": "Invalid web_channel_default_view: Value error, Not in the list of possible values",
},
{
"setting_name": "user_list_style",
"value": 10,
"error_msg": "Invalid user_list_style: Value error, Not in the list of possible values",
},
{
"setting_name": "web_animate_image_previews",
"value": "invalid_value",
"error_msg": "Invalid web_animate_image_previews: Value error, Not in the list of possible values",
},
{
"setting_name": "web_stream_unreads_count_display_policy",
"value": 10,
"error_msg": "Invalid web_stream_unreads_count_display_policy: Value error, Not in the list of possible values",
},
{
"setting_name": "color_scheme",
"value": 10,
"error_msg": "Invalid color_scheme: Value error, Not in the list of possible values",
},
{
"setting_name": "notification_sound",
"value": "invalid_sound",
"error_msg": "Invalid notification sound '\"invalid_sound\"'",
},
{
"setting_name": "desktop_icon_count_display",
"value": 10,
"error_msg": "Invalid desktop_icon_count_display: Value error, Not in the list of possible values",
},
]
self.login("hamlet")
for setting_name in invalid_values_dict:
invalid_value = invalid_values_dict.get(setting_name)
if isinstance(invalid_value, str):
invalid_value = orjson.dumps(invalid_value).decode()
for invalid_value in invalid_values:
if isinstance(invalid_value["value"], str):
invalid_value["value"] = orjson.dumps(invalid_value["value"]).decode()
req = {setting_name: invalid_value}
req = {invalid_value["setting_name"]: invalid_value["value"]}
result = self.client_patch("/json/settings", req)
expected_error_msg = f"Invalid {setting_name}"
if setting_name == "notification_sound":
expected_error_msg = f"Invalid notification sound '{invalid_value}'"
elif setting_name == "timezone":
expected_error_msg = "timezone is not a recognized time zone"
self.assert_json_error(result, expected_error_msg)
self.assert_json_error(result, invalid_value["error_msg"])
hamlet = self.example_user("hamlet")
self.assertNotEqual(getattr(hamlet, setting_name), invalid_value)
self.assertNotEqual(
getattr(hamlet, invalid_value["setting_name"]), invalid_value["value"]
)
def do_change_emojiset(self, emojiset: str) -> "TestHttpResponse":
self.login("hamlet")
@ -454,7 +501,9 @@ class ChangeSettingsTest(ZulipTestCase):
for emojiset in banned_emojisets:
result = self.do_change_emojiset(emojiset)
self.assert_json_error(result, "Invalid emojiset")
self.assert_json_error(
result, "Invalid emojiset: Value error, Not in the list of possible values"
)
for emojiset in valid_emojisets:
result = self.do_change_emojiset(emojiset)

View File

@ -1,5 +1,5 @@
from email.headerregistry import Address
from typing import Any
from typing import Annotated, Any
from django.conf import settings
from django.contrib.auth import authenticate, update_session_auth_hash
@ -13,6 +13,7 @@ from django.utils.html import escape
from django.utils.safestring import SafeString
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy
from pydantic import Json
from confirmation.models import (
Confirmation,
@ -45,18 +46,16 @@ from zerver.lib.exceptions import (
)
from zerver.lib.i18n import get_available_language_codes
from zerver.lib.rate_limiter import RateLimitedUser
from zerver.lib.request import REQ, has_request_variables
from zerver.lib.response import json_success
from zerver.lib.send_email import FromAddress, send_email
from zerver.lib.sounds import get_available_notification_sounds
from zerver.lib.upload import upload_avatar_image
from zerver.lib.validator import (
check_bool,
check_int,
check_int_in,
check_string_in,
check_timezone,
from zerver.lib.typed_endpoint import typed_endpoint, typed_endpoint_without_parameters
from zerver.lib.typed_endpoint_validators import (
check_int_in_validator,
check_string_in_validator,
timezone_validator,
)
from zerver.lib.upload import upload_avatar_image
from zerver.models import EmailChangeStatus, RealmUserDefault, UserBaseSettings, UserProfile
from zerver.models.realms import avatar_changes_disabled, name_changes_disabled
from zerver.views.auth import redirect_to_deactivation_notice
@ -223,120 +222,111 @@ def check_information_density_setting_values(
@human_users_only
@has_request_variables
@typed_endpoint
def json_change_settings(
request: HttpRequest,
user_profile: UserProfile,
full_name: str | None = REQ(default=None),
email: str | None = REQ(default=None),
old_password: str | None = REQ(default=None),
new_password: str | None = REQ(default=None),
twenty_four_hour_time: bool | None = REQ(json_validator=check_bool, default=None),
dense_mode: bool | None = REQ(json_validator=check_bool, default=None),
web_mark_read_on_scroll_policy: int | None = REQ(
json_validator=check_int_in(UserProfile.WEB_MARK_READ_ON_SCROLL_POLICY_CHOICES),
default=None,
),
web_channel_default_view: int | None = REQ(
json_validator=check_int_in(UserProfile.WEB_CHANNEL_DEFAULT_VIEW_CHOICES),
default=None,
),
starred_message_counts: bool | None = REQ(json_validator=check_bool, default=None),
receives_typing_notifications: bool | None = REQ(json_validator=check_bool, default=None),
fluid_layout_width: bool | None = REQ(json_validator=check_bool, default=None),
high_contrast_mode: bool | None = REQ(json_validator=check_bool, default=None),
color_scheme: int | None = REQ(
json_validator=check_int_in(UserProfile.COLOR_SCHEME_CHOICES), default=None
),
web_font_size_px: int | None = REQ(json_validator=check_int, default=None),
web_line_height_percent: int | None = REQ(json_validator=check_int, default=None),
translate_emoticons: bool | None = REQ(json_validator=check_bool, default=None),
display_emoji_reaction_users: bool | None = REQ(json_validator=check_bool, default=None),
default_language: str | None = REQ(default=None),
web_home_view: str | None = REQ(
str_validator=check_string_in(web_home_view_options), default=None
),
web_escape_navigates_to_home_view: bool | None = REQ(json_validator=check_bool, default=None),
left_side_userlist: bool | None = REQ(json_validator=check_bool, default=None),
emojiset: str | None = REQ(str_validator=check_string_in(emojiset_choices), default=None),
demote_inactive_streams: int | None = REQ(
json_validator=check_int_in(UserProfile.DEMOTE_STREAMS_CHOICES), default=None
),
web_stream_unreads_count_display_policy: int | None = REQ(
json_validator=check_int_in(UserProfile.WEB_STREAM_UNREADS_COUNT_DISPLAY_POLICY_CHOICES),
default=None,
),
timezone: str | None = REQ(str_validator=check_timezone, default=None),
email_notifications_batching_period_seconds: int | None = REQ(
json_validator=check_int, default=None
),
enable_drafts_synchronization: bool | None = REQ(json_validator=check_bool, default=None),
enable_stream_desktop_notifications: bool | None = REQ(json_validator=check_bool, default=None),
enable_stream_email_notifications: bool | None = REQ(json_validator=check_bool, default=None),
enable_stream_push_notifications: bool | None = REQ(json_validator=check_bool, default=None),
enable_stream_audible_notifications: bool | None = REQ(json_validator=check_bool, default=None),
wildcard_mentions_notify: bool | None = REQ(json_validator=check_bool, default=None),
enable_followed_topic_desktop_notifications: bool | None = REQ(
json_validator=check_bool, default=None
),
enable_followed_topic_email_notifications: bool | None = REQ(
json_validator=check_bool, default=None
),
enable_followed_topic_push_notifications: bool | None = REQ(
json_validator=check_bool, default=None
),
enable_followed_topic_audible_notifications: bool | None = REQ(
json_validator=check_bool, default=None
),
enable_followed_topic_wildcard_mentions_notify: bool | None = REQ(
json_validator=check_bool, default=None
),
notification_sound: str | None = REQ(default=None),
enable_desktop_notifications: bool | None = REQ(json_validator=check_bool, default=None),
enable_sounds: bool | None = REQ(json_validator=check_bool, default=None),
enable_offline_email_notifications: bool | None = REQ(json_validator=check_bool, default=None),
enable_offline_push_notifications: bool | None = REQ(json_validator=check_bool, default=None),
enable_online_push_notifications: bool | None = REQ(json_validator=check_bool, default=None),
enable_digest_emails: bool | None = REQ(json_validator=check_bool, default=None),
enable_login_emails: bool | None = REQ(json_validator=check_bool, default=None),
enable_marketing_emails: bool | None = REQ(json_validator=check_bool, default=None),
message_content_in_email_notifications: bool | None = REQ(
json_validator=check_bool, default=None
),
pm_content_in_desktop_notifications: bool | None = REQ(json_validator=check_bool, default=None),
desktop_icon_count_display: int | None = REQ(
json_validator=check_int_in(UserProfile.DESKTOP_ICON_COUNT_DISPLAY_CHOICES), default=None
),
realm_name_in_email_notifications_policy: int | None = REQ(
json_validator=check_int_in(UserProfile.REALM_NAME_IN_EMAIL_NOTIFICATIONS_POLICY_CHOICES),
default=None,
),
automatically_follow_topics_policy: int | None = REQ(
json_validator=check_int_in(UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_CHOICES),
default=None,
),
automatically_unmute_topics_in_muted_streams_policy: int | None = REQ(
json_validator=check_int_in(UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_CHOICES),
default=None,
),
automatically_follow_topics_where_mentioned: bool | None = REQ(
json_validator=check_bool, default=None
),
presence_enabled: bool | None = REQ(json_validator=check_bool, default=None),
enter_sends: bool | None = REQ(json_validator=check_bool, default=None),
send_private_typing_notifications: bool | None = REQ(json_validator=check_bool, default=None),
send_stream_typing_notifications: bool | None = REQ(json_validator=check_bool, default=None),
send_read_receipts: bool | None = REQ(json_validator=check_bool, default=None),
user_list_style: int | None = REQ(
json_validator=check_int_in(UserProfile.USER_LIST_STYLE_CHOICES), default=None
),
web_animate_image_previews: str | None = REQ(
str_validator=check_string_in(web_animate_image_previews_options), default=None
),
email_address_visibility: int | None = REQ(
json_validator=check_int_in(UserProfile.EMAIL_ADDRESS_VISIBILITY_TYPES), default=None
),
web_navigate_to_sent_message: bool | None = REQ(json_validator=check_bool, default=None),
*,
full_name: str | None = None,
email: str | None = None,
old_password: str | None = None,
new_password: str | None = None,
twenty_four_hour_time: Json[bool] | None = None,
dense_mode: Json[bool] | None = None,
web_mark_read_on_scroll_policy: Annotated[
Json[int], check_int_in_validator(UserProfile.WEB_MARK_READ_ON_SCROLL_POLICY_CHOICES)
]
| None = None,
web_channel_default_view: Annotated[
Json[int], check_int_in_validator(UserProfile.WEB_CHANNEL_DEFAULT_VIEW_CHOICES)
]
| None = None,
starred_message_counts: Json[bool] | None = None,
receives_typing_notifications: Json[bool] | None = None,
fluid_layout_width: Json[bool] | None = None,
high_contrast_mode: Json[bool] | None = None,
color_scheme: Annotated[Json[int], check_int_in_validator(UserProfile.COLOR_SCHEME_CHOICES)]
| None = None,
web_font_size_px: Json[int] | None = None,
web_line_height_percent: Json[int] | None = None,
translate_emoticons: Json[bool] | None = None,
display_emoji_reaction_users: Json[bool] | None = None,
default_language: str | None = None,
web_home_view: Annotated[str, check_string_in_validator(web_home_view_options)] | None = None,
web_escape_navigates_to_home_view: Json[bool] | None = None,
left_side_userlist: Json[bool] | None = None,
emojiset: Annotated[str, check_string_in_validator(emojiset_choices)] | None = None,
demote_inactive_streams: Annotated[
Json[int], check_int_in_validator(UserProfile.DEMOTE_STREAMS_CHOICES)
]
| None = None,
web_stream_unreads_count_display_policy: Annotated[
Json[int],
check_int_in_validator(UserProfile.WEB_STREAM_UNREADS_COUNT_DISPLAY_POLICY_CHOICES),
]
| None = None,
timezone: Annotated[str, timezone_validator()] | None = None,
email_notifications_batching_period_seconds: Json[int] | None = None,
enable_drafts_synchronization: Json[bool] | None = None,
enable_stream_desktop_notifications: Json[bool] | None = None,
enable_stream_email_notifications: Json[bool] | None = None,
enable_stream_push_notifications: Json[bool] | None = None,
enable_stream_audible_notifications: Json[bool] | None = None,
wildcard_mentions_notify: Json[bool] | None = None,
enable_followed_topic_desktop_notifications: Json[bool] | None = None,
enable_followed_topic_email_notifications: Json[bool] | None = None,
enable_followed_topic_push_notifications: Json[bool] | None = None,
enable_followed_topic_audible_notifications: Json[bool] | None = None,
enable_followed_topic_wildcard_mentions_notify: Json[bool] | None = None,
notification_sound: str | None = None,
enable_desktop_notifications: Json[bool] | None = None,
enable_sounds: Json[bool] | None = None,
enable_offline_email_notifications: Json[bool] | None = None,
enable_offline_push_notifications: Json[bool] | None = None,
enable_online_push_notifications: Json[bool] | None = None,
enable_digest_emails: Json[bool] | None = None,
enable_login_emails: Json[bool] | None = None,
enable_marketing_emails: Json[bool] | None = None,
message_content_in_email_notifications: Json[bool] | None = None,
pm_content_in_desktop_notifications: Json[bool] | None = None,
desktop_icon_count_display: Annotated[
Json[int], check_int_in_validator(UserProfile.DESKTOP_ICON_COUNT_DISPLAY_CHOICES)
]
| None = None,
realm_name_in_email_notifications_policy: Annotated[
Json[int],
check_int_in_validator(UserProfile.REALM_NAME_IN_EMAIL_NOTIFICATIONS_POLICY_CHOICES),
]
| None = None,
automatically_follow_topics_policy: Annotated[
Json[int],
check_int_in_validator(UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_CHOICES),
]
| None = None,
automatically_unmute_topics_in_muted_streams_policy: Annotated[
Json[int],
check_int_in_validator(UserProfile.AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_CHOICES),
]
| None = None,
automatically_follow_topics_where_mentioned: Json[bool] | None = None,
presence_enabled: Json[bool] | None = None,
enter_sends: Json[bool] | None = None,
send_private_typing_notifications: Json[bool] | None = None,
send_stream_typing_notifications: Json[bool] | None = None,
send_read_receipts: Json[bool] | None = None,
user_list_style: Annotated[
Json[int], check_int_in_validator(UserProfile.USER_LIST_STYLE_CHOICES)
]
| None = None,
web_animate_image_previews: Annotated[
str, check_string_in_validator(web_animate_image_previews_options)
]
| None = None,
email_address_visibility: Annotated[
Json[int], check_int_in_validator(UserProfile.EMAIL_ADDRESS_VISIBILITY_TYPES)
]
| None = None,
web_navigate_to_sent_message: Json[bool] | None = None,
) -> HttpResponse:
# UserProfile object is being refetched here to make sure that we
# do not use stale object from cache which can happen when a
@ -485,7 +475,7 @@ def delete_avatar_backend(request: HttpRequest, user_profile: UserProfile) -> Ht
# We don't use @human_users_only here, because there are use cases for
# a bot regenerating its own API key.
@has_request_variables
@typed_endpoint_without_parameters
def regenerate_api_key(request: HttpRequest, user_profile: UserProfile) -> HttpResponse:
new_api_key = do_regenerate_api_key(user_profile, user_profile)
json_result = dict(