invites: Add option to receive notification on accepted invitations.

Previously, when a referrer's invitation to Zulip was accepted,
they got a notification from notification-bot indicating
their invitation has been accepted.

This commit adds an option for referrer to decide
whether he wants to receive the direct notification
from the notification-bot.

Fixes: #20398
This commit is contained in:
Shashank Singh 2024-04-18 17:38:47 +00:00 committed by Tim Abbott
parent 5da629895f
commit 4cce94b667
12 changed files with 119 additions and 2 deletions

View File

@ -20,6 +20,13 @@ format used by the Zulip server that they are interacting with.
## Changes in Zulip 9.0 ## Changes in Zulip 9.0
**Feature level 267**
* [`GET /invites`](/api/get-invites),[`POST /invites`](/api/send-invites): Added
`notify_referrer_on_join` parameter, indicating whether the referrer has opted
to receive a direct message from the notification bot whenever a user joins
via this invitation.
**Feature level 266** **Feature level 266**
* `PATCH /realm`, [`POST /register`](/api/register-queue), * `PATCH /realm`, [`POST /register`](/api/register-queue),

View File

@ -33,7 +33,7 @@ DESKTOP_WARNING_VERSION = "5.9.3"
# Changes should be accompanied by documentation explaining what the # Changes should be accompanied by documentation explaining what the
# new level means in api_docs/changelog.md, as well as "**Changes**" # new level means in api_docs/changelog.md, as well as "**Changes**"
# entries in the endpoint's documentation in `zulip.yaml`. # entries in the endpoint's documentation in `zulip.yaml`.
API_FEATURE_LEVEL = 266 API_FEATURE_LEVEL = 267
# Bump the minor PROVISION_VERSION to indicate that folks should provision # 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 # only when going from an old version of the code to a newer version. Bump

View File

@ -47,6 +47,7 @@ function reset_error_messages(): void {
function get_common_invitation_data(): { function get_common_invitation_data(): {
csrfmiddlewaretoken: string; csrfmiddlewaretoken: string;
invite_as: number; invite_as: number;
notify_referrer_on_join: boolean;
stream_ids: string; stream_ids: string;
invite_expires_in_minutes: string; invite_expires_in_minutes: string;
invitee_emails: string; invitee_emails: string;
@ -56,6 +57,7 @@ function get_common_invitation_data(): {
$<HTMLSelectOneElement>("select:not([multiple])#invite_as").val()!, $<HTMLSelectOneElement>("select:not([multiple])#invite_as").val()!,
10, 10,
); );
const notify_referrer_on_join = $("#receive-invite-acceptance-notification").is(":checked");
const raw_expires_in = $<HTMLSelectOneElement>("select:not([multiple])#expires_in").val()!; const raw_expires_in = $<HTMLSelectOneElement>("select:not([multiple])#expires_in").val()!;
// See settings_config.expires_in_values for why we do this conversion. // See settings_config.expires_in_values for why we do this conversion.
let expires_in: number | null; let expires_in: number | null;
@ -82,6 +84,7 @@ function get_common_invitation_data(): {
const data = { const data = {
csrfmiddlewaretoken: csrf_token, csrfmiddlewaretoken: csrf_token,
invite_as, invite_as,
notify_referrer_on_join,
stream_ids: JSON.stringify(stream_ids), stream_ids: JSON.stringify(stream_ids),
invite_expires_in_minutes: JSON.stringify(expires_in), invite_expires_in_minutes: JSON.stringify(expires_in),
invitee_emails: pills invitee_emails: pills
@ -459,9 +462,11 @@ function open_invite_user_modal(e: JQuery.ClickEvent<Document, undefined>): void
switch (key) { switch (key) {
case "invite-email-tab": case "invite-email-tab":
$("#invitee_emails_container").show(); $("#invitee_emails_container").show();
$("#receive-invite-acceptance-notification-container").show();
break; break;
case "invite-link-tab": case "invite-link-tab":
$("#invitee_emails_container").hide(); $("#invitee_emails_container").hide();
$("#receive-invite-acceptance-notification-container").hide();
break; break;
} }
toggle_invite_submit_button(key); toggle_invite_submit_button(key);

View File

@ -26,6 +26,7 @@ export const invite_schema = z.intersection(
expiry_date: z.number().nullable(), expiry_date: z.number().nullable(),
id: z.number(), id: z.number(),
invited_as: z.number(), invited_as: z.number(),
notify_referrer_on_join: z.boolean(),
}), }),
z.discriminatedUnion("is_multiuse", [ z.discriminatedUnion("is_multiuse", [
z.object({ z.object({
@ -46,6 +47,7 @@ type Invite = z.output<typeof invite_schema> & {
disable_buttons?: boolean; disable_buttons?: boolean;
referrer_name?: string; referrer_name?: string;
img_src?: string; img_src?: string;
notify_referrer_on_join?: boolean;
}; };
const meta = { const meta = {

View File

@ -26,6 +26,13 @@
</div> </div>
</div> </div>
</div> </div>
<div class="input-group new-style" id="receive-invite-acceptance-notification-container">
<label class="checkbox display-block">
<input type="checkbox" id="receive-invite-acceptance-notification" checked/>
<span></span>
{{t "Send me a direct message when my invitation is accepted" }}
</label>
</div>
<div class="input-group"> <div class="input-group">
<label for="expires_in" class="modal-field-label">{{t "Invitation expires after" }}</label> <label for="expires_in" class="modal-field-label">{{t "Invitation expires after" }}</label>
<select id="expires_in" class="invite-user-select modal_select bootstrap-focus-style"> <select id="expires_in" class="invite-user-select modal_select bootstrap-focus-style">

View File

@ -291,6 +291,7 @@ def process_new_human_user(
and prereg_user is not None and prereg_user is not None
and prereg_user.referred_by is not None and prereg_user.referred_by is not None
and prereg_user.referred_by.is_active and prereg_user.referred_by.is_active
and prereg_user.notify_referrer_on_join
): ):
# This is a cross-realm direct message. # This is a cross-realm direct message.
with override_language(prereg_user.referred_by.default_language): with override_language(prereg_user.referred_by.default_language):

View File

@ -174,6 +174,7 @@ def do_invite_users(
user_profile: UserProfile, user_profile: UserProfile,
invitee_emails: Collection[str], invitee_emails: Collection[str],
streams: Collection[Stream], streams: Collection[Stream],
notify_referrer_on_join: bool = True,
*, *,
invite_expires_in_minutes: Optional[int], invite_expires_in_minutes: Optional[int],
include_realm_default_subscriptions: bool, include_realm_default_subscriptions: bool,
@ -264,6 +265,7 @@ def do_invite_users(
invited_as=invite_as, invited_as=invite_as,
realm=realm, realm=realm,
include_realm_default_subscriptions=include_realm_default_subscriptions, include_realm_default_subscriptions=include_realm_default_subscriptions,
notify_referrer_on_join=notify_referrer_on_join,
) )
prereg_user.save() prereg_user.save()
stream_ids = [stream.id for stream in streams] stream_ids = [stream.id for stream in streams]
@ -318,6 +320,7 @@ def do_get_invites_controlled_by_user(user_profile: UserProfile) -> List[Dict[st
id=invitee.id, id=invitee.id,
invited_as=invitee.invited_as, invited_as=invitee.invited_as,
is_multiuse=False, is_multiuse=False,
notify_referrer_on_join=invitee.notify_referrer_on_join,
) )
) )

View File

@ -0,0 +1,17 @@
# Generated by Django 5.0.6 on 2024-06-28 17:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("zerver", "0541_alter_realmauditlog_options"),
]
operations = [
migrations.AddField(
model_name="preregistrationuser",
name="notify_referrer_on_join",
field=models.BooleanField(default=True),
),
]

View File

@ -69,6 +69,7 @@ class PreregistrationUser(models.Model):
full_name = models.CharField(max_length=UserProfile.MAX_NAME_LENGTH, null=True) full_name = models.CharField(max_length=UserProfile.MAX_NAME_LENGTH, null=True)
full_name_validated = models.BooleanField(default=False) full_name_validated = models.BooleanField(default=False)
referred_by = models.ForeignKey(UserProfile, null=True, on_delete=CASCADE) referred_by = models.ForeignKey(UserProfile, null=True, on_delete=CASCADE)
notify_referrer_on_join = models.BooleanField(default=True)
streams = models.ManyToManyField("zerver.Stream") streams = models.ManyToManyField("zerver.Stream")
invited_at = models.DateTimeField(auto_now=True) invited_at = models.DateTimeField(auto_now=True)
realm_creation = models.BooleanField(default=False) realm_creation = models.BooleanField(default=False)

View File

@ -12425,6 +12425,7 @@ paths:
invited: 1710606654, invited: 1710606654,
invited_as: 200, invited_as: 200,
invited_by_user_id: 9, invited_by_user_id: 9,
notify_referrer_on_join: true,
is_multiuse: false, is_multiuse: false,
}, },
{ {
@ -12434,6 +12435,7 @@ paths:
invited_as: 400, invited_as: 400,
invited_by_user_id: 9, invited_by_user_id: 9,
is_multiuse: true, is_multiuse: true,
notify_referrer_on_join: true,
link_url: "https://example.zulipchat.com/join/yddhtzk4jgl7rsmazc5fyyyy/", link_url: "https://example.zulipchat.com/join/yddhtzk4jgl7rsmazc5fyyyy/",
}, },
], ],
@ -12511,6 +12513,18 @@ paths:
type: boolean type: boolean
default: false default: false
example: false example: false
notify_referrer_on_join:
description: |
A boolean indicating whether the referrer would like to receive a
direct message from [notification
bot](/help/configure-automated-notices) when a user account is created
using this invitation.
**Changes**: New in Zulip 9.0 (feature level 267). Previously,
referrers always received such direct messages.
type: boolean
example: false
default: true
required: required:
- invitee_emails - invitee_emails
- stream_ids - stream_ids
@ -20688,6 +20702,15 @@ components:
description: | description: |
The email address the invitation was sent to. This will not be present when The email address the invitation was sent to. This will not be present when
`is_multiuse` is `true` (i.e. the invitation is a reusable invitation link). `is_multiuse` is `true` (i.e. the invitation is a reusable invitation link).
notify_referrer_on_join:
type: boolean
description: |
A boolean indicating whether the referrer has opted to receive a direct
message from [notification bot](/help/configure-automated-notices) when a user
account is created using this invitation.
**Changes**: New in Zulip 9.0 (feature level 267). Previously,
referrers always received such direct messages.
link_url: link_url:
type: string type: string
description: | description: |

View File

@ -175,6 +175,7 @@ class InviteUserBase(ZulipTestCase):
self, self,
invitee_emails: str, invitee_emails: str,
stream_names: Sequence[str], stream_names: Sequence[str],
notify_referrer_on_join: bool = True,
invite_expires_in_minutes: Optional[int] = INVITATION_LINK_VALIDITY_MINUTES, invite_expires_in_minutes: Optional[int] = INVITATION_LINK_VALIDITY_MINUTES,
body: str = "", body: str = "",
invite_as: int = PreregistrationUser.INVITE_AS["MEMBER"], invite_as: int = PreregistrationUser.INVITE_AS["MEMBER"],
@ -206,6 +207,7 @@ class InviteUserBase(ZulipTestCase):
"include_realm_default_subscriptions": orjson.dumps( "include_realm_default_subscriptions": orjson.dumps(
include_realm_default_subscriptions include_realm_default_subscriptions
).decode(), ).decode(),
"notify_referrer_on_join": orjson.dumps(notify_referrer_on_join).decode(),
}, },
subdomain=realm.string_id if realm else "zulip", subdomain=realm.string_id if realm else "zulip",
) )
@ -1046,6 +1048,51 @@ earl-test@zulip.com""",
] ]
) )
def test_direct_notification_for_accepted_invitation(self) -> None:
"""
New test to check if notification-bot message is sent to the referrer
when notify_referrer_on_join is false.
"""
self.login("hamlet")
user_profile = self.example_user("hamlet")
invitee = self.nonreg_email("alice")
realm = get_realm("zulip")
self.assert_json_success(self.invite(invitee, ["Denmark"], False))
self.assertTrue(find_key_by_email(invitee))
self.submit_reg_form_for_user(invitee, "password")
assert user_profile.recipient_id is not None
invite_acceptance_notification_message = Message.objects.filter(
recipient_id=user_profile.recipient_id, realm=realm
).last()
self.assertIsNone(
invite_acceptance_notification_message,
"Unexpected message found from notification-bot for accepted invitations.",
)
self.login("hamlet")
new_invitee = self.nonreg_email("bob")
self.assert_json_success(self.invite(new_invitee, ["Denmark"], True))
self.assertTrue(find_key_by_email(new_invitee))
self.submit_reg_form_for_user(new_invitee, "new_password")
new_invitee_profile = self.nonreg_user("bob")
new_invite_acceptance_notification_message = Message.objects.filter(
recipient_id=user_profile.recipient_id, realm=realm
).last()
assert new_invite_acceptance_notification_message is not None
self.assertEqual(
new_invite_acceptance_notification_message.sender.email, "notification-bot@zulip.com"
)
self.assertTrue(
new_invite_acceptance_notification_message.content.startswith(
f"@_**{new_invitee_profile.full_name}|{new_invitee_profile.id}** accepted your",
)
)
def test_max_invites_model(self) -> None: def test_max_invites_model(self) -> None:
realm = get_realm("zulip") realm = get_realm("zulip")
self.assertEqual(realm.max_invites, settings.INVITES_DEFAULT_REALM_DAILY_MAX) self.assertEqual(realm.max_invites, settings.INVITES_DEFAULT_REALM_DAILY_MAX)
@ -2302,7 +2349,7 @@ class InvitationsTestCase(InviteUserBase):
self.login("iago") self.login("iago")
invitee = "resend@zulip.com" invitee = "resend@zulip.com"
self.assert_json_success(self.invite(invitee, ["Denmark"], None)) self.assert_json_success(self.invite(invitee, ["Denmark"], True, None))
prereg_user = PreregistrationUser.objects.get(email=invitee) prereg_user = PreregistrationUser.objects.get(email=invitee)
# Verify and then clear from the outbox the original invite email # Verify and then clear from the outbox the original invite email

View File

@ -58,6 +58,9 @@ def invite_users_backend(
), ),
default=PreregistrationUser.INVITE_AS["MEMBER"], default=PreregistrationUser.INVITE_AS["MEMBER"],
), ),
notify_referrer_on_join: bool = REQ(
"notify_referrer_on_join", json_validator=check_bool, default=True
),
stream_ids: List[int] = REQ(json_validator=check_list(check_int)), stream_ids: List[int] = REQ(json_validator=check_list(check_int)),
include_realm_default_subscriptions: bool = REQ(json_validator=check_bool, default=False), include_realm_default_subscriptions: bool = REQ(json_validator=check_bool, default=False),
) -> HttpResponse: ) -> HttpResponse:
@ -99,6 +102,7 @@ def invite_users_backend(
user_profile, user_profile,
invitee_emails, invitee_emails,
streams, streams,
notify_referrer_on_join,
invite_expires_in_minutes=invite_expires_in_minutes, invite_expires_in_minutes=invite_expires_in_minutes,
include_realm_default_subscriptions=include_realm_default_subscriptions, include_realm_default_subscriptions=include_realm_default_subscriptions,
invite_as=invite_as, invite_as=invite_as,