diff --git a/templates/zerver/api/changelog.md b/templates/zerver/api/changelog.md index ff60245602..ac7b5651f2 100644 --- a/templates/zerver/api/changelog.md +++ b/templates/zerver/api/changelog.md @@ -11,6 +11,16 @@ below features are supported. ## Changes in Zulip 5.0 +**Feature level 95** + +* [`POST /register`](/api/register-queue): Added + `realm_user_settings_defaults` object, containing default values of + personal user settings for new users in the realm. + +* [`GET /events`](/api/get-events): Added + `realm_user_settings_defaults` event type, which is sent when the + organization's configured default settings for new users change. + **Feature level 94** * [`POST /register`](/api/register-queue): Added `demo_organization_scheduled_deletion_date` field to realm data. diff --git a/version.py b/version.py index 367948a584..e324042002 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 = 94 +API_FEATURE_LEVEL = 95 # 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/actions.py b/zerver/lib/actions.py index eff526069d..cc8b19d189 100644 --- a/zerver/lib/actions.py +++ b/zerver/lib/actions.py @@ -1009,6 +1009,44 @@ def do_set_realm_signup_notifications_stream( send_event(realm, event, active_user_ids(realm.id)) +def do_set_realm_user_default_setting( + realm_user_default: RealmUserDefault, + name: str, + value: Any, + *, + acting_user: Optional[UserProfile], +) -> None: + old_value = getattr(realm_user_default, name) + realm = realm_user_default.realm + event_time = timezone_now() + + with transaction.atomic(savepoint=False): + setattr(realm_user_default, name, value) + realm_user_default.save(update_fields=[name]) + + RealmAuditLog.objects.create( + realm=realm, + event_type=RealmAuditLog.REALM_DEFAULT_USER_SETTINGS_CHANGED, + event_time=event_time, + acting_user=acting_user, + extra_data=orjson.dumps( + { + RealmAuditLog.OLD_VALUE: old_value, + RealmAuditLog.NEW_VALUE: value, + "property": name, + } + ).decode(), + ) + + event = dict( + type="realm_user_settings_defaults", + op="update", + property=name, + value=value, + ) + send_event(realm, event, active_user_ids(realm.id)) + + def do_deactivate_realm(realm: Realm, *, acting_user: Optional[UserProfile]) -> None: """ Deactivate this realm. Do NOT deactivate the users -- we need to be able to diff --git a/zerver/lib/event_schema.py b/zerver/lib/event_schema.py index fc14a5a516..53b680afe7 100644 --- a/zerver/lib/event_schema.py +++ b/zerver/lib/event_schema.py @@ -45,7 +45,7 @@ from zerver.lib.data_types import ( make_checker, ) from zerver.lib.topic import ORIG_TOPIC, TOPIC_LINKS, TOPIC_NAME -from zerver.models import Realm, Stream, Subscription, UserProfile +from zerver.models import Realm, RealmUserDefault, Stream, Subscription, UserProfile # These fields are used for "stream" events, and are included in the # larger "subscription" events that also contain personal settings. @@ -898,6 +898,32 @@ def check_realm_update( raise AssertionError(f"Unexpected property type {property_type}") +realm_user_settings_defaults_update_event = event_dict_type( + required_keys=[ + ("type", Equals("realm_user_settings_defaults")), + ("op", Equals("update")), + ("property", str), + ("value", value_type), + ], +) +_check_realm_default_update = make_checker(realm_user_settings_defaults_update_event) + + +def check_realm_default_update( + var_name: str, + event: Dict[str, object], + prop: str, +) -> None: + _check_realm_default_update(var_name, event) + + assert prop == event["property"] + assert prop not in ["default_language", "twenty_four_hour_time"] + assert prop in RealmUserDefault.property_types + + prop_type = RealmUserDefault.property_types[prop] + assert isinstance(event["value"], prop_type) + + authentication_dict = DictType( required_keys=[ ("Google", bool), diff --git a/zerver/lib/events.py b/zerver/lib/events.py index 6ad2ae691a..07fddcfa8b 100644 --- a/zerver/lib/events.py +++ b/zerver/lib/events.py @@ -58,6 +58,7 @@ from zerver.models import ( Draft, Message, Realm, + RealmUserDefault, Stream, UserMessage, UserProfile, @@ -327,6 +328,21 @@ def fetch_initial_state_data( realm.demo_organization_scheduled_deletion_date ) + if want("realm_user_settings_defaults"): + realm_user_default = RealmUserDefault.objects.get(realm=realm) + state["realm_user_settings_defaults"] = {} + for property_name in RealmUserDefault.property_types: + state["realm_user_settings_defaults"][property_name] = getattr( + realm_user_default, property_name + ) + + state["realm_user_settings_defaults"][ + "emojiset_choices" + ] = RealmUserDefault.emojiset_choices() + state["realm_user_settings_defaults"][ + "available_notification_sounds" + ] = get_available_notification_sounds() + if want("realm_domains"): state["realm_domains"] = get_realm_domains(realm) @@ -928,6 +944,11 @@ def apply_event( pass else: raise AssertionError("Unexpected event type {type}/{op}".format(**event)) + elif event["type"] == "realm_user_settings_defaults": + if event["op"] == "update": + state["realm_user_settings_defaults"][event["property"]] = event["value"] + else: + raise AssertionError("Unexpected event type {type}/{op}".format(**event)) elif event["type"] == "subscription": if event["op"] == "add": added_stream_ids = {sub["stream_id"] for sub in event["subscriptions"]} diff --git a/zerver/models.py b/zerver/models.py index 8e866052e1..523ad8f989 100644 --- a/zerver/models.py +++ b/zerver/models.py @@ -3639,6 +3639,7 @@ class AbstractRealmAuditLog(models.Model): REALM_SPONSORSHIP_PENDING_STATUS_CHANGED = 213 REALM_SUBDOMAIN_CHANGED = 214 REALM_CREATED = 215 + REALM_DEFAULT_USER_SETTINGS_CHANGED = 216 SUBSCRIPTION_CREATED = 301 SUBSCRIPTION_ACTIVATED = 302 diff --git a/zerver/openapi/zulip.yaml b/zerver/openapi/zulip.yaml index d342542b34..f69421df47 100644 --- a/zerver/openapi/zulip.yaml +++ b/zerver/openapi/zulip.yaml @@ -3884,6 +3884,45 @@ paths: }, "id": 0, } + - type: object + additionalProperties: false + description: | + Event sent to all users in a Zulip organization when the + default settings for new users of the organization + (realm) have changed. + + **Changes**: New in Zulip 5.0 (feature level 95). + properties: + id: + $ref: "#/components/schemas/EventIdSchema" + type: + allOf: + - $ref: "#/components/schemas/EventTypeSchema" + - enum: + - realm_user_settings_defaults + op: + type: string + enum: + - update + property: + type: string + description: | + The name of the property that was changed. + value: + description: | + The new value of the property. + oneOf: + - type: boolean + - type: integer + - type: string + example: + { + "type": "realm_user_settings_defaults", + "op": "update", + "property": "left_side_userlist", + "value": false, + "id": 0, + } - type: object additionalProperties: false description: | @@ -10652,6 +10691,235 @@ paths: Since these notifications are sent by the server, this field is primarily relevant to clients containing UI for changing it. + realm_user_settings_defaults: + type: object + additionalProperties: false + description: | + Present if `realm_user_settings_defaults` is present in `fetch_event_types`. + + A dictionary containing the default values of settings for new users. + + **Changes**: New in Zulip 5.0 (feature level 95). + properties: + twenty_four_hour_time: + type: boolean + description: | + Whether time should be [displayed in 24-hour notation](/help/change-the-time-format). + dense_mode: + type: boolean + description: | + This setting has no effect at present. It is reserved for use in + controlling the default font size in Zulip. + starred_message_counts: + type: boolean + description: | + Whether clients should display the [number of starred + messages](/help/star-a-message#display-the-number-of-starred-messages). + fluid_layout_width: + type: boolean + 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. + high_contrast_mode: + type: boolean + description: | + This setting is reserved for use to control variations in Zulip's design + to help visually impaired users. + color_scheme: + type: integer + 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. + translate_emoticons: + type: boolean + description: | + Whether to [translate emoticons to emoji](/help/enable-emoticon-translations) + in messages the user sends. + default_language: + type: string + description: | + What [default language](/help/change-your-language) to use for the account. + + This controls both the Zulip UI as well as email notifications sent to the user. + + The value needs to be a standard language code that the Zulip server has + translation data for; for example, `"en"` for English or `"de"` for German. + default_view: + type: string + 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 + left_side_userlist: + type: boolean + description: | + Whether the users list on left sidebar in narrow windows. + + This feature is not heavily used and is likely to be reworked. + emojiset: + type: string + 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 + demote_inactive_streams: + type: integer + description: | + Whether to [demote inactive streams](/help/manage-inactive-streams) in the left sidebar. + + * 1 - Automatic + * 2 - Always + * 3 - Never + enable_stream_desktop_notifications: + type: boolean + description: | + Enable visual desktop notifications for stream messages. + enable_stream_email_notifications: + type: boolean + description: | + Enable email notifications for stream messages. + enable_stream_push_notifications: + type: boolean + description: | + Enable mobile notifications for stream messages. + enable_stream_audible_notifications: + type: boolean + description: | + Enable audible desktop notifications for stream messages. + notification_sound: + type: string + description: | + Notification sound name. + enable_desktop_notifications: + type: boolean + description: | + Enable visual desktop notifications for private messages and @-mentions. + enable_sounds: + type: boolean + description: | + Enable audible desktop notifications for private messages and + @-mentions. + enable_offline_email_notifications: + type: boolean + description: | + Enable email notifications for private messages and @-mentions received + when the user is offline. + enable_offline_push_notifications: + type: boolean + description: | + Enable mobile notification for private messages and @-mentions received + when the user is offline. + enable_online_push_notifications: + type: boolean + description: | + Enable mobile notification for private messages and @-mentions received + when the user is online. + enable_digest_emails: + type: boolean + description: | + Enable digest emails when the user is away. + enable_marketing_emails: + type: boolean + description: | + Enable marketing emails. Has no function outside Zulip Cloud. + enable_login_emails: + type: boolean + description: | + Enable email notifications for new logins to account. + message_content_in_email_notifications: + type: boolean + description: | + Include the message's content in email notifications for new messages. + pm_content_in_desktop_notifications: + type: boolean + description: | + Include content of private messages in desktop notifications. + wildcard_mentions_notify: + type: boolean + description: | + Whether wildcard mentions (E.g. @**all**) should send notifications + like a personal mention. + desktop_icon_count_display: + type: integer + description: | + Unread count summary (appears in desktop sidebar and browser tab) + + * 1 - All unreads + * 2 - Private messages and mentions + * 3 - None + realm_name_in_notifications: + type: boolean + description: | + Include organization name in subject of message notification emails. + presence_enabled: + type: boolean + description: | + Display the presence status to other users when online. + enter_sends: + type: boolean + description: | + Whether the user setting for [sending on pressing Enter](/help/enable-enter-to-send) + in the compose box is enabled. + enable_drafts_synchronization: + type: boolean + 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. + email_notifications_batching_period_seconds: + type: integer + description: | + The duration (in seconds) for which the server should wait to batch + email notifications before sending them. + available_notification_sounds: + type: array + items: + type: string + description: | + Array containing the names of the notification sound options + supported by this Zulip server. Only relevant to support UI + for configuring notification sounds. + emojiset_choices: + description: | + Array of dictionaries where each dictionary describes an emoji set + supported by this version of the Zulip server. + + Only relevant to clients with configuration UI for choosing an emoji set; + the currently selected emoji set is available in the `emojiset` key. + + See [PATCH /settings](/api/update-settings) for details on + the meaning of this setting. + type: array + items: + type: object + description: | + Object describing a emoji set. + additionalProperties: false + properties: + key: + type: string + description: | + The key or the name of the emoji set which will be the value + of `emojiset` if this emoji set is chosen. + text: + type: string + description: | + The text describing the emoji set. realm_users: type: array description: | diff --git a/zerver/tests/test_event_system.py b/zerver/tests/test_event_system.py index b5770dd3fb..a8ac735e45 100644 --- a/zerver/tests/test_event_system.py +++ b/zerver/tests/test_event_system.py @@ -1001,7 +1001,7 @@ class FetchQueriesTest(ZulipTestCase): with mock.patch("zerver.lib.events.always_want") as want_mock: fetch_initial_state_data(user) - self.assert_length(queries, 34) + self.assert_length(queries, 35) expected_counts = dict( alert_words=1, @@ -1025,6 +1025,7 @@ class FetchQueriesTest(ZulipTestCase): realm_playgrounds=1, realm_user=3, realm_user_groups=2, + realm_user_settings_defaults=1, recent_private_conversations=1, starred_messages=1, stream=2, diff --git a/zerver/tests/test_events.py b/zerver/tests/test_events.py index a3177f82b3..72e39cb544 100644 --- a/zerver/tests/test_events.py +++ b/zerver/tests/test_events.py @@ -82,6 +82,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, do_set_zoom_token, do_unmute_topic, do_unmute_user, @@ -125,6 +126,7 @@ from zerver.lib.event_schema import ( check_realm_bot_remove, check_realm_bot_update, check_realm_deactivated, + check_realm_default_update, check_realm_domains_add, check_realm_domains_change, check_realm_domains_remove, @@ -191,6 +193,7 @@ from zerver.models import ( RealmAuditLog, RealmDomain, RealmPlayground, + RealmUserDefault, Service, Stream, UserGroup, @@ -2167,6 +2170,76 @@ class RealmPropertyActionTest(BaseAction): with self.settings(SEND_DIGEST_EMAILS=True): self.do_set_realm_property_test(prop) + def do_set_realm_user_default_setting_test(self, name: str) -> None: + bool_tests: List[bool] = [True, 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_user_default = RealmUserDefault.objects.get(realm=self.user_profile.realm) + now = timezone_now() + do_set_realm_user_default_setting( + realm_user_default, name, vals[0], acting_user=self.user_profile + ) + self.assertEqual( + RealmAuditLog.objects.filter( + realm=self.user_profile.realm, + event_type=RealmAuditLog.REALM_DEFAULT_USER_SETTINGS_CHANGED, + event_time__gte=now, + acting_user=self.user_profile, + ).count(), + 1, + ) + for count, val in enumerate(vals[1:]): + now = timezone_now() + state_change_expected = True + events = self.verify_action( + lambda: do_set_realm_user_default_setting( + realm_user_default, name, val, acting_user=self.user_profile + ), + state_change_expected=state_change_expected, + ) + + old_value = vals[count] + self.assertEqual( + RealmAuditLog.objects.filter( + realm=self.user_profile.realm, + event_type=RealmAuditLog.REALM_DEFAULT_USER_SETTINGS_CHANGED, + event_time__gte=now, + acting_user=self.user_profile, + extra_data=orjson.dumps( + { + RealmAuditLog.OLD_VALUE: old_value, + RealmAuditLog.NEW_VALUE: val, + "property": name, + } + ).decode(), + ).count(), + 1, + ) + check_realm_default_update("events[0]", events[0], name) + + def test_change_realm_user_default_setting(self) -> None: + for prop in RealmUserDefault.property_types: + if prop in ["default_language", "twenty_four_hour_time"]: + continue + self.do_set_realm_user_default_setting_test(prop) + class UserDisplayActionTest(BaseAction): def do_change_user_settings_test(self, setting_name: str) -> None: diff --git a/zerver/tests/test_home.py b/zerver/tests/test_home.py index 6018bb0f49..f78b944bbe 100644 --- a/zerver/tests/test_home.py +++ b/zerver/tests/test_home.py @@ -170,6 +170,7 @@ class HomeTest(ZulipTestCase): "realm_uri", "realm_user_group_edit_policy", "realm_user_groups", + "realm_user_settings_defaults", "realm_users", "realm_video_chat_provider", "realm_waiting_period_threshold", @@ -238,7 +239,7 @@ class HomeTest(ZulipTestCase): set(result["Cache-Control"].split(", ")), {"must-revalidate", "no-store", "no-cache"} ) - self.assert_length(queries, 43) + self.assert_length(queries, 44) self.assert_length(cache_mock.call_args_list, 5) html = result.content.decode() @@ -345,7 +346,7 @@ class HomeTest(ZulipTestCase): result = self._get_home_page() self.check_rendered_logged_in_app(result) self.assert_length(cache_mock.call_args_list, 6) - self.assert_length(queries, 40) + self.assert_length(queries, 41) def test_num_queries_with_streams(self) -> None: main_user = self.example_user("hamlet") @@ -376,7 +377,7 @@ class HomeTest(ZulipTestCase): with queries_captured() as queries2: result = self._get_home_page() - self.assert_length(queries2, 38) + self.assert_length(queries2, 39) # Do a sanity check that our new streams were in the payload. html = result.content.decode()