diff --git a/templates/zerver/api/changelog.md b/templates/zerver/api/changelog.md index ac7b5651f2..d4a4b64452 100644 --- a/templates/zerver/api/changelog.md +++ b/templates/zerver/api/changelog.md @@ -11,6 +11,11 @@ below features are supported. ## Changes in Zulip 5.0 +**Feature level 96** + +* [`PATCH /realm/user_settings_defaults`](/api/update-realm-user-settings-defaults): + Added new endpoint to update default values of user settings in a realm. + **Feature level 95** * [`POST /register`](/api/register-queue): Added diff --git a/templates/zerver/help/include/rest-endpoints.md b/templates/zerver/help/include/rest-endpoints.md index 06a728fe4b..2e51415949 100644 --- a/templates/zerver/help/include/rest-endpoints.md +++ b/templates/zerver/help/include/rest-endpoints.md @@ -77,6 +77,7 @@ * [Get all custom profile fields](/api/get-custom-profile-fields) * [Reorder custom profile fields](/api/reorder-custom-profile-fields) * [Create a custom profile field](/api/create-custom-profile-field) +* [Change default values of user preferences](/api/update-realm-user-settings-defaults) #### Real-time events diff --git a/tools/linter_lib/custom_check.py b/tools/linter_lib/custom_check.py index 08d80894ca..a7b7ed7b2a 100644 --- a/tools/linter_lib/custom_check.py +++ b/tools/linter_lib/custom_check.py @@ -896,6 +896,7 @@ help_markdown_rules = RuleList( "good_lines": ["Organization", "deactivate_realm", "realm_filter"], "bad_lines": ["Users are in a realm", "Realm is the best model"], "description": "Realms are referred to as Organizations in user-facing docs.", + "exclude_pattern": "-realm-", }, ], length_exclude=markdown_docs_length_exclude, diff --git a/version.py b/version.py index e324042002..8b27eac55f 100644 --- a/version.py +++ b/version.py @@ -33,7 +33,7 @@ DESKTOP_WARNING_VERSION = "5.4.3" # Changes should be accompanied by documentation explaining what the # new level means in templates/zerver/api/changelog.md, as well as # "**Changes**" entries in the endpoint's documentation in `zulip.yaml`. -API_FEATURE_LEVEL = 95 +API_FEATURE_LEVEL = 96 # 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/validator.py b/zerver/lib/validator.py index c2fdd8d893..af55602e63 100644 --- a/zerver/lib/validator.py +++ b/zerver/lib/validator.py @@ -587,7 +587,7 @@ def check_string_or_int(var_name: str, val: object) -> Union[str, int]: def check_settings_values( notification_sound: Optional[str], email_notifications_batching_period_seconds: Optional[int], - default_language: Optional[str], + default_language: Optional[str] = None, ) -> None: from zerver.lib.actions import get_available_notification_sounds from zerver.lib.i18n import get_available_language_codes diff --git a/zerver/openapi/zulip.yaml b/zerver/openapi/zulip.yaml index f69421df47..dba863a45a 100644 --- a/zerver/openapi/zulip.yaml +++ b/zerver/openapi/zulip.yaml @@ -7499,6 +7499,349 @@ paths: description: | The ID for the custom profile field. example: {"result": "success", "msg": "", "id": 9} + /realm/user_settings_defaults: + patch: + operationId: update-realm-user-settings-defaults + summary: Update realm-level defaults of user settings. + tags: ["server_and_organizations"] + x-requires-administrator: true + description: | + Change the the default/initial values of + [personal preference settings](/api/update-settings) for new users + created in the organization. + + `PATCH {{ api_url }}/v1/realm/user_settings_defaults` + + This feature can be invaluable for customizing Zulip's default + settings for notifications or UI to be appropriate for how the + organization is using Zulip. (Note that this only supports + personal preference settings, like when to send push + notifications or what emoji set to use, not profile or + identity settings that naturally should be different for each user). + + Note that this endpoint cannot, at present, be used to modify + settings for existing users in any way. + + **Changes**: New in Zulip 5.0 (feature level 96). + x-curl-examples-parameters: + oneOf: + - type: include + parameters: + enum: + - left_side_userlist + - emojiset + parameters: + - name: dense_mode + in: query + description: | + This setting has no effect at present. It is reserved for use in controlling + the default font size in Zulip. + schema: + type: boolean + example: true + - name: starred_message_counts + in: query + description: | + Whether clients should display the [number of starred + messages](/help/star-a-message#display-the-number-of-starred-messages). + schema: + type: boolean + example: true + - name: fluid_layout_width + in: query + description: | + Whether to use the [maximum available screen width](/help/enable-full-width-display) + for the web app's center panel (message feed, recent topics) on wide screens. + schema: + type: boolean + example: true + - name: high_contrast_mode + in: query + description: | + This setting is reserved for use to control variations in Zulip's design + to help visually impaired users. + schema: + type: boolean + example: true + - name: color_scheme + in: query + description: | + Controls which [color theme](/help/night-mode) to use. + + * 1 - Automatic + * 2 - Night mode + * 3 - Day mode + + Automatic detection is implementing using the standard `prefers-color-scheme` + media query. + content: + application/json: + schema: + type: integer + enum: + - 1 + - 2 + - 3 + example: 1 + - name: enable_drafts_synchronization + in: query + description: | + A boolean parameter to control whether synchronizing drafts is enabled for + the user. When synchronization is disabled, all drafts stored in the server + will be automatically deleted from the server. + + This does not do anything (like sending events) to delete local copies of + drafts stored in clients. + schema: + type: boolean + example: true + - name: translate_emoticons + in: query + description: | + Whether to [translate emoticons to emoji](/help/enable-emoticon-translations) + in messages the user sends. + schema: + type: boolean + example: true + - name: default_view + in: query + description: | + The [default view](/help/change-default-view) used when opening a new + Zulip web app window or hitting the `Esc` keyboard shortcut repeatedly. + + * "recent_topics" - Recent topics view + * "all_messages" - All messages view + schema: + type: string + example: all_messages + - name: left_side_userlist + in: query + description: | + Whether the users list on left sidebar in narrow windows. + + This feature is not heavily used and is likely to be reworked. + schema: + type: boolean + example: true + - name: emojiset + in: query + description: | + The user's configured [emoji set](/help/emoji-and-emoticons#use-emoticons), + used to display emoji to the user everything they appear in the UI. + + * "google" - Google modern + * "google-blob" - Google classic + * "twitter" - Twitter + * "text" - Plain text + schema: + type: string + example: "google" + - name: demote_inactive_streams + in: query + description: | + Whether to [demote inactive streams](/help/manage-inactive-streams) in the left sidebar. + + * 1 - Automatic + * 2 - Always + * 3 - Never + content: + application/json: + schema: + type: integer + enum: + - 1 + - 2 + - 3 + example: 1 + - name: enable_stream_desktop_notifications + in: query + description: | + Enable visual desktop notifications for stream messages. + schema: + type: boolean + example: true + - name: enable_stream_email_notifications + in: query + description: | + Enable email notifications for stream messages. + schema: + type: boolean + example: true + - name: enable_stream_push_notifications + in: query + description: | + Enable mobile notifications for stream messages. + schema: + type: boolean + example: true + - name: enable_stream_audible_notifications + in: query + description: | + Enable audible desktop notifications for stream messages. + schema: + type: boolean + example: true + - name: notification_sound + in: query + description: | + Notification sound name. + schema: + type: string + example: ding + - name: enable_desktop_notifications + in: query + description: | + Enable visual desktop notifications for private messages and @-mentions. + schema: + type: boolean + example: true + - name: enable_sounds + in: query + description: | + Enable audible desktop notifications for private messages and + @-mentions. + schema: + type: boolean + example: true + - name: email_notifications_batching_period_seconds + in: query + description: | + The duration (in seconds) for which the server should wait to batch + email notifications before sending them. + schema: + type: integer + example: 120 + - name: enable_offline_email_notifications + in: query + description: | + Enable email notifications for private messages and @-mentions received + when the user is offline. + schema: + type: boolean + example: true + - name: enable_offline_push_notifications + in: query + description: | + Enable mobile notification for private messages and @-mentions received + when the user is offline. + schema: + type: boolean + example: true + - name: enable_online_push_notifications + in: query + description: | + Enable mobile notification for private messages and @-mentions received + when the user is online. + schema: + type: boolean + example: true + - name: enable_digest_emails + in: query + description: | + Enable digest emails when the user is away. + schema: + type: boolean + example: true + - name: enable_login_emails + in: query + description: | + Enable email notifications for new logins to account. + schema: + type: boolean + example: true + - name: message_content_in_email_notifications + in: query + description: | + Include the message's content in email notifications for new messages. + schema: + type: boolean + example: true + - name: pm_content_in_desktop_notifications + in: query + description: | + Include content of private messages in desktop notifications. + schema: + type: boolean + example: true + - name: wildcard_mentions_notify + in: query + description: | + Whether wildcard mentions (E.g. @**all**) should send notifications + like a personal mention. + schema: + type: boolean + example: true + - name: desktop_icon_count_display + in: query + description: | + Unread count summary (appears in desktop sidebar and browser tab) + + * 1 - All unreads + * 2 - Private messages and mentions + * 3 - None + content: + application/json: + schema: + type: integer + enum: + - 1 + - 2 + - 3 + example: 1 + - name: realm_name_in_notifications + in: query + description: | + Include organization name in subject of message notification emails. + schema: + type: boolean + example: true + - name: presence_enabled + in: query + description: | + Display the presence status to other users when online. + schema: + type: boolean + example: true + - name: enter_sends + in: query + description: | + Whether pressing Enter in the compose box sends a message + (or saves a message edit). + schema: + type: boolean + example: true + responses: + "200": + description: Success + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/JsonSuccessBase" + - $ref: "#/components/schemas/SuccessDescription" + - additionalProperties: false + properties: + result: {} + msg: {} + ignored_parameters_unsupported: + type: array + items: + type: string + description: | + This field lists any parameters sent in the request that are not + supported by the endpoint. While this can be expected, e.g. when sending + both current and legacy names for a parameter to a Zulip server of + unknown version, this often indicates a bug in the client + implementation or an attempt to configure a new feature, while + connected to an older Zulip server that does not support the feature. + example: + { + "ignored_parameters_unsupported": + ["desktop_notifications", "demote_streams"], + "msg": "", + "result": "success", + } + /users/me/subscriptions/properties: post: operationId: update-subscription-settings diff --git a/zerver/tests/test_realm.py b/zerver/tests/test_realm.py index e9cda57185..53aeba68a4 100644 --- a/zerver/tests/test_realm.py +++ b/zerver/tests/test_realm.py @@ -18,6 +18,7 @@ from zerver.lib.actions import ( do_scrub_realm, do_send_realm_reactivation_email, do_set_realm_property, + do_set_realm_user_default_setting, ) from zerver.lib.realm_description import get_realm_rendered_description, get_realm_text_description from zerver.lib.send_email import send_future_email @@ -29,6 +30,7 @@ from zerver.models import ( Message, Realm, RealmAuditLog, + RealmUserDefault, ScheduledEmail, Stream, UserMessage, @@ -818,6 +820,97 @@ class RealmAPITest(ZulipTestCase): with self.subTest(property=prop): self.do_test_realm_update_api(prop) + def update_with_realm_default_api(self, name: str, val: Any) -> None: + if not isinstance(val, str): + val = orjson.dumps(val).decode() + result = self.client_patch("/json/realm/user_settings_defaults", {name: val}) + self.assert_json_success(result) + + def do_test_realm_default_setting_update_api(self, name: str) -> None: + bool_tests: List[bool] = [False, True] + test_values: Dict[str, Any] = dict( + color_scheme=UserProfile.COLOR_SCHEME_CHOICES, + default_view=["recent_topics", "all_messages"], + emojiset=[emojiset["key"] for emojiset in RealmUserDefault.emojiset_choices()], + demote_inactive_streams=UserProfile.DEMOTE_STREAMS_CHOICES, + desktop_icon_count_display=[1, 2, 3], + notification_sound=["zulip", "ding"], + email_notifications_batching_period_seconds=[120, 300], + ) + + vals = test_values.get(name) + property_type = RealmUserDefault.property_types[name] + + if property_type is bool: + vals = bool_tests + + if vals is None: + raise AssertionError(f"No test created for {name}") + + realm = get_realm("zulip") + realm_user_default = RealmUserDefault.objects.get(realm=realm) + do_set_realm_user_default_setting(realm_user_default, name, vals[0], acting_user=None) + + for val in vals[1:]: + self.update_with_realm_default_api(name, val) + realm_user_default = RealmUserDefault.objects.get(realm=realm) + self.assertEqual(getattr(realm_user_default, name), val) + + self.update_with_realm_default_api(name, vals[0]) + realm_user_default = RealmUserDefault.objects.get(realm=realm) + self.assertEqual(getattr(realm_user_default, name), vals[0]) + + def test_update_default_realm_settings(self) -> None: + for prop in RealmUserDefault.property_types: + # enable_marketing_emails setting is not actually used and thus cannot be updated + # using this endpoint. It is included in notification_setting_types only for avoiding + # duplicate code. default_language and twenty_four_hour_time are currently present + # in Realm table also and thus are updated using '/realm' endpoint, but those + # will be removed in future and the settings in RealmUserDefault table will be used. + if prop in ["default_language", "twenty_four_hour_time", "enable_marketing_emails"]: + continue + self.do_test_realm_default_setting_update_api(prop) + + def test_invalid_default_notification_sound_value(self) -> None: + result = self.client_patch( + "/json/realm/user_settings_defaults", {"notification_sound": "invalid"} + ) + self.assert_json_error(result, "Invalid notification sound 'invalid'") + + result = self.client_patch( + "/json/realm/user_settings_defaults", {"notification_sound": "zulip"} + ) + self.assert_json_success(result) + realm = get_realm("zulip") + realm_user_default = RealmUserDefault.objects.get(realm=realm) + self.assertEqual(realm_user_default.notification_sound, "zulip") + + def test_invalid_email_notifications_batching_period_setting(self) -> None: + result = self.client_patch( + "/json/realm/user_settings_defaults", + {"email_notifications_batching_period_seconds": -1}, + ) + self.assert_json_error(result, "Invalid email batching period: -1 seconds") + + result = self.client_patch( + "/json/realm/user_settings_defaults", + {"email_notifications_batching_period_seconds": 7 * 24 * 60 * 60 + 10}, + ) + self.assert_json_error(result, "Invalid email batching period: 604810 seconds") + + def test_ignored_parameters_in_realm_default_endpoint(self) -> None: + params = {"starred_message_counts": orjson.dumps(False).decode(), "emoji_set": "twitter"} + json_result = self.client_patch("/json/realm/user_settings_defaults", params) + self.assert_json_success(json_result) + + realm = get_realm("zulip") + realm_user_default = RealmUserDefault.objects.get(realm=realm) + self.assertEqual(realm_user_default.starred_message_counts, False) + + result = orjson.loads(json_result.content) + self.assertIn("ignored_parameters_unsupported", result) + self.assertEqual(result["ignored_parameters_unsupported"], ["emoji_set"]) + def test_update_realm_allow_message_editing(self) -> None: """Tests updating the realm property 'allow_message_editing'.""" self.set_up_db("allow_message_editing", False) diff --git a/zerver/views/realm.py b/zerver/views/realm.py index 2849caa235..fdc66fc347 100644 --- a/zerver/views/realm.py +++ b/zerver/views/realm.py @@ -17,6 +17,7 @@ from zerver.lib.actions import ( do_set_realm_notifications_stream, do_set_realm_property, do_set_realm_signup_notifications_stream, + do_set_realm_user_default_setting, ) from zerver.lib.exceptions import JsonableError, OrganizationOwnerRequired from zerver.lib.i18n import get_available_language_codes @@ -30,10 +31,12 @@ from zerver.lib.validator import ( check_dict, check_int, check_int_in, + check_settings_values, + check_string_in, check_string_or_int, to_non_negative_int, ) -from zerver.models import Realm, UserProfile +from zerver.models import Realm, RealmUserDefault, UserProfile @require_realm_admin @@ -275,3 +278,100 @@ def realm_reactivation(request: HttpRequest, confirmation_key: str) -> HttpRespo do_reactivate_realm(realm) context = {"realm": realm} return render(request, "zerver/realm_reactivation.html", context) + + +emojiset_choices = {emojiset["key"] for emojiset in RealmUserDefault.emojiset_choices()} +default_view_options = ["recent_topics", "all_messages"] + + +@require_realm_admin +@has_request_variables +def update_realm_user_settings_defaults( + request: HttpRequest, + user_profile: UserProfile, + dense_mode: Optional[bool] = REQ(json_validator=check_bool, default=None), + starred_message_counts: Optional[bool] = REQ(json_validator=check_bool, default=None), + fluid_layout_width: Optional[bool] = REQ(json_validator=check_bool, default=None), + high_contrast_mode: Optional[bool] = REQ(json_validator=check_bool, default=None), + color_scheme: Optional[int] = REQ( + json_validator=check_int_in(UserProfile.COLOR_SCHEME_CHOICES), default=None + ), + translate_emoticons: Optional[bool] = REQ(json_validator=check_bool, default=None), + default_view: Optional[str] = REQ( + str_validator=check_string_in(default_view_options), default=None + ), + left_side_userlist: Optional[bool] = REQ(json_validator=check_bool, default=None), + emojiset: Optional[str] = REQ(str_validator=check_string_in(emojiset_choices), default=None), + demote_inactive_streams: Optional[int] = REQ( + json_validator=check_int_in(UserProfile.DEMOTE_STREAMS_CHOICES), default=None + ), + enable_stream_desktop_notifications: Optional[bool] = REQ( + json_validator=check_bool, default=None + ), + enable_stream_email_notifications: Optional[bool] = REQ( + json_validator=check_bool, default=None + ), + enable_stream_push_notifications: Optional[bool] = REQ(json_validator=check_bool, default=None), + enable_stream_audible_notifications: Optional[bool] = REQ( + json_validator=check_bool, default=None + ), + wildcard_mentions_notify: Optional[bool] = REQ(json_validator=check_bool, default=None), + notification_sound: Optional[str] = REQ(default=None), + enable_desktop_notifications: Optional[bool] = REQ(json_validator=check_bool, default=None), + enable_sounds: Optional[bool] = REQ(json_validator=check_bool, default=None), + enable_offline_email_notifications: Optional[bool] = REQ( + json_validator=check_bool, default=None + ), + enable_offline_push_notifications: Optional[bool] = REQ( + json_validator=check_bool, default=None + ), + enable_online_push_notifications: Optional[bool] = REQ(json_validator=check_bool, default=None), + enable_digest_emails: Optional[bool] = REQ(json_validator=check_bool, default=None), + enable_login_emails: Optional[bool] = REQ(json_validator=check_bool, default=None), + # enable_marketing_emails is not included here, since we don't at + # present allow organizations to customize this. (The user's selection + # in the signup form takes precedence over RealmUserDefault). + # + # We may want to change this model in the future, since some SSO signups + # do not offer an opportunity to prompt the user at all during signup. + message_content_in_email_notifications: Optional[bool] = REQ( + json_validator=check_bool, default=None + ), + pm_content_in_desktop_notifications: Optional[bool] = REQ( + json_validator=check_bool, default=None + ), + desktop_icon_count_display: Optional[int] = REQ( + json_validator=check_int_in(UserProfile.DESKTOP_ICON_COUNT_DISPLAY_CHOICES), default=None + ), + realm_name_in_notifications: Optional[bool] = REQ(json_validator=check_bool, default=None), + presence_enabled: Optional[bool] = REQ(json_validator=check_bool, default=None), + enter_sends: Optional[bool] = REQ(json_validator=check_bool, default=None), + enable_drafts_synchronization: Optional[bool] = REQ(json_validator=check_bool, default=None), + email_notifications_batching_period_seconds: Optional[int] = REQ( + json_validator=check_int, default=None + ), +) -> HttpResponse: + if notification_sound is not None or email_notifications_batching_period_seconds is not None: + check_settings_values(notification_sound, email_notifications_batching_period_seconds) + + realm_user_default = RealmUserDefault.objects.get(realm=user_profile.realm) + request_settings = { + k: v for k, v in list(locals().items()) if (k in RealmUserDefault.property_types) + } + for k, v in list(request_settings.items()): + if v is not None and getattr(realm_user_default, k) != v: + do_set_realm_user_default_setting(realm_user_default, k, v, acting_user=user_profile) + + # TODO: Extract `ignored_parameters_unsupported` to be a common feature of the REQ framework. + from zerver.lib.request import RequestNotes + + request_notes = RequestNotes.get_notes(request) + for req_var in request.POST: + if req_var not in request_notes.processed_parameters: + request_notes.ignored_parameters.add(req_var) + + result: Dict[str, Any] = {} + if len(request_notes.ignored_parameters) > 0: + result["ignored_parameters_unsupported"] = list(request_notes.ignored_parameters) + + return json_success(result) diff --git a/zproject/urls.py b/zproject/urls.py index 1bd59631cf..3988a05e7e 100644 --- a/zproject/urls.py +++ b/zproject/urls.py @@ -105,6 +105,7 @@ from zerver.views.realm import ( deactivate_realm, realm_reactivation, update_realm, + update_realm_user_settings_defaults, ) from zerver.views.realm_domains import ( create_realm_domain, @@ -245,6 +246,7 @@ if settings.TWO_FACTOR_AUTHENTICATION_ENABLED: v1_api_and_json_patterns = [ # realm-level calls rest_path("realm", PATCH=update_realm), + rest_path("realm/user_settings_defaults", PATCH=update_realm_user_settings_defaults), path("realm/subdomain/", check_subdomain_available), # realm/domains -> zerver.views.realm_domains rest_path("realm/domains", GET=list_realm_domains, POST=create_realm_domain),