mirror of https://github.com/zulip/zulip.git
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:
parent
5da629895f
commit
4cce94b667
|
@ -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),
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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)
|
||||||
|
|
|
@ -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: |
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
Loading…
Reference in New Issue