models: Add push_notifications_enabled & corresponding end_timestamp.

Add two fields to Realm model:
*push_notifications_enabled
*push_notifications_enabled_end_timestamp

Co-authored-by: Prakhar Pratyush <prakhar@zulip.com>
This commit is contained in:
Tim Abbott 2023-11-23 13:07:41 -08:00
parent 6aa911a9b2
commit f6c7eaf1e5
16 changed files with 351 additions and 12 deletions

View File

@ -20,6 +20,19 @@ format used by the Zulip server that they are interacting with.
## Changes in Zulip 8.0
**Feature level 231**
* [`POST /register`](/api/register-queue):
`realm_push_notifications_enabled` now represents more accurately
whether push notifications are actually enabled via the mobile push
notifications service. Added
`realm_push_notifications_enabled_end_timestamp` field to realm
data.
* [`GET /events`](/api/get-events): A `realm` update event is now sent
whenever `push_notifications_enabled` or
`push_notifications_enabled_end_timestamp` changes.
**Feature level 230**
* [`GET /events`](/api/get-events): Added `has_trigger` field in

View File

@ -33,7 +33,7 @@ DESKTOP_WARNING_VERSION = "5.9.3"
# Changes should be accompanied by documentation explaining what the
# new level means in api_docs/changelog.md, as well as "**Changes**"
# entries in the endpoint's documentation in `zulip.yaml`.
API_FEATURE_LEVEL = 230
API_FEATURE_LEVEL = 231
# 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

View File

@ -229,6 +229,7 @@ export function dispatch_normal_event(event) {
notifications_stream_id: stream_ui_updates.update_announce_stream_option,
org_type: noop,
private_message_policy: noop,
push_notifications_enabled: noop,
send_welcome_emails: noop,
message_content_allowed_in_email_notifications: noop,
enable_spectator_access: noop,

View File

@ -14,6 +14,7 @@ from zerver.actions.realm_settings import (
do_deactivate_realm,
)
from zerver.lib.bulk_create import create_users
from zerver.lib.push_notifications import sends_notifications_directly
from zerver.lib.remote_server import enqueue_register_realm_with_push_bouncer_if_needed
from zerver.lib.server_initialization import create_internal_realm, server_initialized
from zerver.lib.streams import ensure_stream, get_signups_stream
@ -215,6 +216,9 @@ def do_create_realm(
kwargs["enable_read_receipts"] = (
invite_required is None or invite_required is True or emails_restricted_to_domains
)
# Initialize this property correctly in the case that no network activity
# is required to do so correctly.
kwargs["push_notifications_enabled"] = sends_notifications_directly()
with transaction.atomic():
realm = Realm(string_id=string_id, name=name, **kwargs)

View File

@ -16,6 +16,7 @@ from zerver.lib.message import parse_message_time_limit_setting, update_first_vi
from zerver.lib.retention import move_messages_to_archive
from zerver.lib.send_email import FromAddress, send_email_to_admins
from zerver.lib.sessions import delete_user_sessions
from zerver.lib.timestamp import datetime_to_timestamp, timestamp_to_datetime
from zerver.lib.upload import delete_message_attachments
from zerver.lib.user_counts import realm_user_count_by_role
from zerver.models import (
@ -102,6 +103,50 @@ def do_set_realm_property(
update_users_in_full_members_system_group(realm, acting_user=acting_user)
def do_set_push_notifications_enabled_end_timestamp(
realm: Realm, value: Optional[int], *, acting_user: Optional[UserProfile]
) -> None:
# Variant of do_set_realm_property with a bit of extra complexity
# for the fact that we store a datetime object in the database but
# use an integer format timestamp in the API.
name = "push_notifications_enabled_end_timestamp"
old_timestamp = None
old_datetime = getattr(realm, name)
if old_datetime is not None:
old_timestamp = datetime_to_timestamp(old_datetime)
if old_timestamp == value:
return
with transaction.atomic():
new_datetime = None
if value is not None:
new_datetime = timestamp_to_datetime(value)
setattr(realm, name, new_datetime)
realm.save(update_fields=[name])
event_time = timezone_now()
RealmAuditLog.objects.create(
realm=realm,
event_type=RealmAuditLog.REALM_PROPERTY_CHANGED,
event_time=event_time,
acting_user=acting_user,
extra_data={
RealmAuditLog.OLD_VALUE: old_timestamp,
RealmAuditLog.NEW_VALUE: value,
"property": name,
},
)
event = dict(
type="realm",
op="update",
property=name,
value=value,
)
send_event(realm, event, active_user_ids(realm.id))
@transaction.atomic(durable=True)
def do_change_realm_permission_group_setting(
realm: Realm, setting_name: str, user_group: UserGroup, *, acting_user: Optional[UserProfile]

View File

@ -39,7 +39,6 @@ from zerver.lib.muted_users import get_user_mutes
from zerver.lib.narrow import check_narrow_for_events, read_stop_words
from zerver.lib.narrow_helpers import NarrowTerm
from zerver.lib.presence import get_presence_for_user, get_presences_for_realm
from zerver.lib.push_notifications import push_notifications_configured
from zerver.lib.realm_icon import realm_icon_url
from zerver.lib.realm_logo import get_realm_logo_source, get_realm_logo_url
from zerver.lib.scheduled_messages import get_undelivered_scheduled_messages
@ -329,6 +328,13 @@ def fetch_initial_state_data(
state["zulip_plan_is_not_limited"] = realm.plan_type != Realm.PLAN_TYPE_LIMITED
state["upgrade_text_for_wide_organization_logo"] = str(Realm.UPGRADE_TEXT_STANDARD)
if realm.push_notifications_enabled_end_timestamp is not None:
state["realm_push_notifications_enabled_end_timestamp"] = datetime_to_timestamp(
realm.push_notifications_enabled_end_timestamp
)
else:
state["realm_push_notifications_enabled_end_timestamp"] = None
state["password_min_length"] = settings.PASSWORD_MIN_LENGTH
state["password_min_guesses"] = settings.PASSWORD_MIN_GUESSES
state["server_inline_image_preview"] = settings.INLINE_IMAGE_PREVIEW
@ -345,8 +351,7 @@ def fetch_initial_state_data(
"event_queue_longpoll_timeout_seconds"
] = settings.EVENT_QUEUE_LONGPOLL_TIMEOUT_SECONDS
# TODO: Should these have the realm prefix replaced with server_?
state["realm_push_notifications_enabled"] = push_notifications_configured()
# TODO: This probably belongs on the server object.
state["realm_default_external_accounts"] = get_default_external_accounts()
server_default_jitsi_server_url = (

View File

@ -27,6 +27,7 @@ from zerver.lib.export import DATE_FIELDS, Field, Path, Record, TableData, Table
from zerver.lib.markdown import markdown_convert
from zerver.lib.markdown import version as markdown_version
from zerver.lib.message import get_last_message_id
from zerver.lib.push_notifications import sends_notifications_directly
from zerver.lib.remote_server import enqueue_register_realm_with_push_bouncer_if_needed
from zerver.lib.server_initialization import create_internal_realm, server_initialized
from zerver.lib.streams import render_stream_description
@ -974,6 +975,9 @@ def do_import_realm(import_dir: Path, subdomain: str, processes: int = 1) -> Rea
realm_properties = dict(**data["zerver_realm"][0])
realm_properties["deactivated"] = True
# Initialize whether we expect push notifications to work.
realm_properties["push_notifications_enabled"] = sends_notifications_directly()
with transaction.atomic(durable=True):
realm = Realm(**realm_properties)
if "zerver_usergroup" not in data:

View File

@ -33,12 +33,20 @@ from django.utils.translation import override as override_language
from typing_extensions import TypeAlias, override
from analytics.lib.counts import COUNT_STATS, do_increment_logging_stat
from zerver.actions.realm_settings import (
do_set_push_notifications_enabled_end_timestamp,
do_set_realm_property,
)
from zerver.lib.avatar import absolute_avatar_url
from zerver.lib.emoji_utils import hex_codepoint_to_emoji
from zerver.lib.exceptions import ErrorCode, JsonableError
from zerver.lib.message import access_message, huddle_users
from zerver.lib.outgoing_http import OutgoingSession
from zerver.lib.remote_server import send_json_to_push_bouncer, send_to_push_bouncer
from zerver.lib.remote_server import (
send_json_to_push_bouncer,
send_realms_only_to_push_bouncer,
send_to_push_bouncer,
)
from zerver.lib.soft_deactivation import soft_reactivate_if_personal_notification
from zerver.lib.tex import change_katex_to_raw_latex
from zerver.lib.timestamp import datetime_to_timestamp
@ -48,6 +56,7 @@ from zerver.models import (
Message,
NotificationTriggers,
PushDeviceToken,
Realm,
Recipient,
Stream,
UserGroup,
@ -565,6 +574,10 @@ def uses_notification_bouncer() -> bool:
return settings.PUSH_NOTIFICATION_BOUNCER_URL is not None
def sends_notifications_directly() -> bool:
return has_apns_credentials() and has_gcm_credentials() and not uses_notification_bouncer()
def send_notifications_to_bouncer(
user_profile: UserProfile,
apns_payload: Dict[str, Any],
@ -736,15 +749,70 @@ def push_notifications_configured() -> bool:
def initialize_push_notifications() -> None:
if not push_notifications_configured():
if settings.DEVELOPMENT and not settings.TEST_SUITE: # nocoverage
# Avoid unnecessary spam on development environment startup
"""Called during startup of the push notifications worker to check
whether we expect mobile push notifications to work on this server
and update state accordingly.
"""
if sends_notifications_directly():
# This server sends push notifications directly. Make sure we
# are set to report to clients that push notifications are
# enabled.
for realm in Realm.objects.filter(push_notifications_enabled=False):
do_set_realm_property(realm, "push_notifications_enabled", True, acting_user=None)
do_set_push_notifications_enabled_end_timestamp(realm, None, acting_user=None)
return
if not push_notifications_configured():
for realm in Realm.objects.filter(push_notifications_enabled=True):
do_set_realm_property(realm, "push_notifications_enabled", False, acting_user=None)
do_set_push_notifications_enabled_end_timestamp(realm, None, acting_user=None)
if settings.DEVELOPMENT and not settings.TEST_SUITE:
# Avoid unnecessary spam on development environment startup
return # nocoverage
logger.warning(
"Mobile push notifications are not configured.\n "
"See https://zulip.readthedocs.io/en/latest/"
"production/mobile-push-notifications.html"
)
return
if uses_notification_bouncer():
# If we're using the notification bouncer, check if we can
# actually send push notifications.
try:
realms = send_realms_only_to_push_bouncer()
except Exception:
# An exception was thrown trying to ask the bouncer service whether we can send
# push notifications or not. There may be certain transient failures that we could
# ignore here, but the default explanation is that there is something wrong either
# with our credentials being corrupted or our ability to reach the bouncer service
# over the network, so we immediately move to reporting push notifications as likely not working,
# as whatever failed here is likely to also fail when trying to send a push notification.
for realm in Realm.objects.filter(push_notifications_enabled=True):
do_set_realm_property(realm, "push_notifications_enabled", False, acting_user=None)
do_set_push_notifications_enabled_end_timestamp(realm, None, acting_user=None)
logger.exception("Exception while sending realms only data to push bouncer")
return
for realm_uuid, data in realms.items():
realm = Realm.objects.get(uuid=realm_uuid)
do_set_realm_property(
realm, "push_notifications_enabled", data["can_push"], acting_user=None
)
do_set_push_notifications_enabled_end_timestamp(
realm, data["expected_end_timestamp"], acting_user=None
)
return
logger.warning( # nocoverage
"Mobile push notifications are not fully configured.\n "
"See https://zulip.readthedocs.io/en/latest/production/mobile-push-notifications.html"
)
for realm in Realm.objects.filter(push_notifications_enabled=True): # nocoverage
do_set_realm_property(realm, "push_notifications_enabled", False, acting_user=None)
do_set_push_notifications_enabled_end_timestamp(realm, None, acting_user=None)
def get_mobile_push_content(rendered_content: str) -> str:

View File

@ -0,0 +1,22 @@
# Generated by Django 4.2.7 on 2023-11-28 14:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("zerver", "0491_alter_realmuserdefault_web_home_view_and_more"),
]
operations = [
migrations.AddField(
model_name="realm",
name="push_notifications_enabled",
field=models.BooleanField(db_index=True, default=False),
),
migrations.AddField(
model_name="realm",
name="push_notifications_enabled_end_timestamp",
field=models.DateTimeField(default=None, null=True),
),
]

View File

@ -344,6 +344,11 @@ class Realm(models.Model): # type: ignore[django-manager-missing] # django-stub
# bouncer.
uuid = models.UUIDField(default=uuid4, unique=True)
uuid_owner_secret = models.TextField(default=generate_realm_uuid_owner_secret)
# Whether push notifications are working for this realm, and
# whether there is a specific date at which we expect that to
# cease to be the case.
push_notifications_enabled = models.BooleanField(default=False, db_index=True)
push_notifications_enabled_end_timestamp = models.DateTimeField(default=None, null=True)
date_created = models.DateTimeField(default=timezone_now)
demo_organization_scheduled_deletion_date = models.DateTimeField(default=None, null=True)
@ -829,6 +834,7 @@ class Realm(models.Model): # type: ignore[django-manager-missing] # django-stub
name=str,
name_changes_disabled=bool,
private_message_policy=int,
push_notifications_enabled=bool,
send_welcome_emails=bool,
user_group_edit_policy=int,
video_chat_provider=int,

View File

@ -4581,6 +4581,27 @@ paths:
system group.
**Changes**: New in Zulip 8.0 (feature level 225).
push_notifications_enabled:
type: boolean
description: |
Whether push notifications are enabled for this organization. Typically
`true` for Zulip Cloud and self-hosted realms that have a valid
registration for the [Mobile push notifications
service](https://zulip.readthedocs.io/en/latest/production/mobile-push-notifications.html),
and `false` for self-hosted servers that do not.
**Changes**: New in Zulip 8.0 (feature level 231).
Previously, this value was never updated via events.
push_notifications_enabled_end_timestamp:
type: integer
nullable: true
description: |
If the server expects the realm's push notifications access to end at a
definite time in the future, the time at which this is expected to happen.
Mobile clients should use this field to display warnings to users when the
indicated timestamp is near.
**Changes**: New in Zulip 8.0 (feature level 231).
additionalProperties: false
example:
{
@ -14425,8 +14446,26 @@ paths:
Present if `realm` is present in `fetch_event_types`.
Whether push notifications are enabled for this organization. Typically
`false` for self-hosted servers that have not configured the
[Mobile push notifications service](https://zulip.readthedocs.io/en/latest/production/mobile-push-notifications.html).
`true` for Zulip Cloud and self-hosted realms that have a valid
registration for the [Mobile push notifications
service](https://zulip.readthedocs.io/en/latest/production/mobile-push-notifications.html),
and `false` for self-hosted servers that do not.
**Changes**: Before Zulip 8.0 (feature level 231), this incorrectly was
`true` for servers that were partly configured to use the Mobile Push
Notifications Service but not properly registered.
realm_push_notifications_enabled_end_timestamp:
type: integer
nullable: true
description: |
Present if `realm` is present in `fetch_event_types`.
If the server expects the realm's push notifications access to end at a
definite time in the future, the time at which this is expected to happen.
Mobile clients should use this field to display warnings to users when the
indicated timestamp is near.
**Changes**: New in Zulip 8.0 (feature level 231).
realm_upload_quota_mib:
type: integer
nullable: true

View File

@ -76,6 +76,7 @@ from zerver.actions.realm_settings import (
do_change_realm_permission_group_setting,
do_change_realm_plan_type,
do_deactivate_realm,
do_set_push_notifications_enabled_end_timestamp,
do_set_realm_authentication_methods,
do_set_realm_notifications_stream,
do_set_realm_property,
@ -216,7 +217,7 @@ from zerver.lib.test_helpers import (
reset_email_visibility_to_everyone_in_zulip_realm,
stdout_suppressed,
)
from zerver.lib.timestamp import convert_to_UTC
from zerver.lib.timestamp import convert_to_UTC, datetime_to_timestamp
from zerver.lib.topic import TOPIC_NAME
from zerver.lib.types import ProfileDataElementUpdateDict
from zerver.models import (
@ -3660,6 +3661,58 @@ class RealmPropertyActionTest(BaseAction):
continue
self.do_set_realm_user_default_setting_test(prop)
def test_do_set_push_notifications_enabled_end_timestamp(self) -> None:
realm = self.user_profile.realm
# Default value of 'push_notifications_enabled_end_timestamp' is None.
# Verify that no event is sent when the new value is the same as existing value.
new_timestamp = None
self.verify_action(
lambda: do_set_push_notifications_enabled_end_timestamp(
realm=realm,
value=new_timestamp,
acting_user=None,
),
state_change_expected=False,
num_events=0,
)
old_datetime = timezone_now() - datetime.timedelta(days=3)
old_timestamp = datetime_to_timestamp(old_datetime)
now = timezone_now()
timestamp_now = datetime_to_timestamp(now)
realm.push_notifications_enabled_end_timestamp = old_datetime
realm.save(update_fields=["push_notifications_enabled_end_timestamp"])
event = self.verify_action(
lambda: do_set_push_notifications_enabled_end_timestamp(
realm=realm,
value=timestamp_now,
acting_user=None,
),
state_change_expected=True,
num_events=1,
)[0]
self.assertEqual(event["type"], "realm")
self.assertEqual(event["op"], "update")
self.assertEqual(event["property"], "push_notifications_enabled_end_timestamp")
self.assertEqual(event["value"], timestamp_now)
self.assertEqual(
RealmAuditLog.objects.filter(
realm=realm,
event_type=RealmAuditLog.REALM_PROPERTY_CHANGED,
acting_user=None,
extra_data={
RealmAuditLog.OLD_VALUE: old_timestamp,
RealmAuditLog.NEW_VALUE: timestamp_now,
"property": "push_notifications_enabled_end_timestamp",
},
).count(),
1,
)
class UserDisplayActionTest(BaseAction):
def do_change_user_settings_test(self, setting_name: str) -> None:

View File

@ -24,6 +24,7 @@ from zerver.lib.home import (
from zerver.lib.soft_deactivation import do_soft_deactivate_users
from zerver.lib.test_classes import ZulipTestCase
from zerver.lib.test_helpers import get_user_messages, queries_captured
from zerver.lib.timestamp import datetime_to_timestamp
from zerver.models import (
DefaultStream,
Draft,
@ -178,6 +179,7 @@ class HomeTest(ZulipTestCase):
"realm_presence_disabled",
"realm_private_message_policy",
"realm_push_notifications_enabled",
"realm_push_notifications_enabled_end_timestamp",
"realm_send_welcome_emails",
"realm_signup_notifications_stream_id",
"realm_upload_quota_mib",
@ -1278,3 +1280,17 @@ class HomeTest(ZulipTestCase):
# +2 for what's already in the test DB.
for draft in page_params["drafts"]:
self.assertNotEqual(draft["timestamp"], base_time)
def test_realm_push_notifications_enabled_end_timestamp(self) -> None:
self.login("hamlet")
realm = get_realm("zulip")
end_timestamp = timezone_now() + datetime.timedelta(days=1)
realm.push_notifications_enabled_end_timestamp = end_timestamp
realm.save()
result = self._get_home_page(stream="Denmark")
page_params = self._get_page_params(result)
self.assertEqual(
page_params["realm_push_notifications_enabled_end_timestamp"],
datetime_to_timestamp(end_timestamp),
)

View File

@ -737,6 +737,65 @@ class PushBouncerNotificationTest(BouncerTestCase):
result = self.uuid_post(self.server_uuid, endpoint, payload)
self.assert_json_error(result, "Invalid APNS token")
def test_initialize_push_notifications(self) -> None:
realm = get_realm("zulip")
realm.push_notifications_enabled = False
realm.save()
from zerver.lib.push_notifications import initialize_push_notifications
with mock.patch(
"zerver.lib.push_notifications.sends_notifications_directly", return_value=True
):
initialize_push_notifications()
realm = get_realm("zulip")
self.assertTrue(realm.push_notifications_enabled)
with mock.patch(
"zerver.lib.push_notifications.push_notifications_configured", return_value=False
), self.assertLogs("zerver.lib.push_notifications", level="WARNING") as warn_log:
initialize_push_notifications()
not_configured_warn_log = (
"WARNING:zerver.lib.push_notifications:"
"Mobile push notifications are not configured.\n "
"See https://zulip.readthedocs.io/en/latest/production/mobile-push-notifications.html"
)
realm = get_realm("zulip")
self.assertFalse(realm.push_notifications_enabled)
self.assertEqual(
warn_log.output[0],
not_configured_warn_log,
)
with mock.patch(
"zerver.lib.push_notifications.uses_notification_bouncer", return_value=True
):
realms_response = {realm.uuid: {"can_push": True, "expected_end_timestamp": None}}
with mock.patch(
"zerver.lib.push_notifications.send_realms_only_to_push_bouncer",
return_value=realms_response,
):
initialize_push_notifications()
realm = get_realm("zulip")
self.assertTrue(realm.push_notifications_enabled)
self.assertEqual(realm.push_notifications_enabled_end_timestamp, None)
with mock.patch(
"zerver.lib.push_notifications.send_realms_only_to_push_bouncer",
side_effect=Exception,
), self.assertLogs("zerver.lib.push_notifications", level="ERROR") as exception_log:
initialize_push_notifications()
realm = get_realm("zulip")
self.assertFalse(realm.push_notifications_enabled)
self.assertIn(
"ERROR:zerver.lib.push_notifications:Exception while sending realms only data to push bouncer",
exception_log.output[0],
)
@override_settings(PUSH_NOTIFICATION_BOUNCER_URL="https://push.zulip.org.example.com")
@responses.activate
def test_register_token_realm_uuid_belongs_to_different_server(self) -> None:

View File

@ -1360,6 +1360,8 @@ class RealmAPITest(ZulipTestCase):
def test_update_realm_properties(self) -> None:
for prop in Realm.property_types:
# push_notifications_enabled is maintained by the server, not via the API.
if prop != "push_notifications_enabled":
with self.subTest(property=prop):
self.do_test_realm_update_api(prop)

View File

@ -105,6 +105,8 @@ def update_realm(
authentication_methods: Optional[Dict[str, Any]] = REQ(
json_validator=check_dict([]), default=None
),
# Note: push_notifications_enabled and push_notifications_enabled_end_timestamp
# are not offered here as it is maintained by the server, not via the API.
notifications_stream_id: Optional[int] = REQ(json_validator=check_int, default=None),
signup_notifications_stream_id: Optional[int] = REQ(json_validator=check_int, default=None),
message_retention_days_raw: Optional[Union[int, str]] = REQ(