mirror of https://github.com/zulip/zulip.git
actions: Split out zerver.actions.invites.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
This commit is contained in:
parent
241463e215
commit
ca8d374e21
|
@ -32,17 +32,19 @@ from analytics.models import (
|
|||
UserCount,
|
||||
installation_epoch,
|
||||
)
|
||||
from zerver.actions.invites import (
|
||||
do_invite_users,
|
||||
do_resend_user_invite_email,
|
||||
do_revoke_user_invite,
|
||||
)
|
||||
from zerver.lib.actions import (
|
||||
do_activate_mirror_dummy_user,
|
||||
do_create_realm,
|
||||
do_create_user,
|
||||
do_deactivate_user,
|
||||
do_invite_users,
|
||||
do_mark_all_as_read,
|
||||
do_mark_stream_messages_as_read,
|
||||
do_reactivate_user,
|
||||
do_resend_user_invite_email,
|
||||
do_revoke_user_invite,
|
||||
do_update_message_flags,
|
||||
update_user_activity_interval,
|
||||
)
|
||||
|
|
|
@ -7,11 +7,8 @@ from django.utils.timezone import now as timezone_now
|
|||
|
||||
from corporate.lib.stripe import add_months, update_sponsorship_status
|
||||
from corporate.models import Customer, CustomerPlan, LicenseLedger, get_customer_by_realm
|
||||
from zerver.lib.actions import (
|
||||
do_create_multiuse_invite_link,
|
||||
do_send_realm_reactivation_email,
|
||||
do_set_realm_property,
|
||||
)
|
||||
from zerver.actions.invites import do_create_multiuse_invite_link
|
||||
from zerver.lib.actions import do_send_realm_reactivation_email, do_set_realm_property
|
||||
from zerver.lib.test_classes import ZulipTestCase
|
||||
from zerver.lib.test_helpers import reset_emails_in_zulip_realm
|
||||
from zerver.models import (
|
||||
|
|
|
@ -0,0 +1,419 @@
|
|||
import datetime
|
||||
from typing import Any, Collection, Dict, List, Optional, Sequence, Set, Tuple, Union
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Q, Sum
|
||||
from django.utils.timezone import now as timezone_now
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from analytics.lib.counts import COUNT_STATS, do_increment_logging_stat
|
||||
from analytics.models import RealmCount
|
||||
from confirmation.models import Confirmation, confirmation_url, create_confirmation_link
|
||||
from zerver.lib.email_validation import (
|
||||
get_existing_user_errors,
|
||||
get_realm_email_validator,
|
||||
validate_email_is_valid,
|
||||
)
|
||||
from zerver.lib.exceptions import InvitationError
|
||||
from zerver.lib.queue import queue_json_publish
|
||||
from zerver.lib.send_email import FromAddress, clear_scheduled_invitation_emails, send_email
|
||||
from zerver.lib.timestamp import datetime_to_timestamp
|
||||
from zerver.lib.types import UnspecifiedValue
|
||||
from zerver.models import (
|
||||
MultiuseInvite,
|
||||
PreregistrationUser,
|
||||
Realm,
|
||||
Stream,
|
||||
UserProfile,
|
||||
filter_to_valid_prereg_users,
|
||||
)
|
||||
from zerver.tornado.django_api import send_event
|
||||
|
||||
|
||||
def notify_invites_changed(realm: Realm) -> None:
|
||||
event = dict(type="invites_changed")
|
||||
admin_ids = [user.id for user in realm.get_admin_users_and_bots()]
|
||||
send_event(realm, event, admin_ids)
|
||||
|
||||
|
||||
def do_send_confirmation_email(
|
||||
invitee: PreregistrationUser,
|
||||
referrer: UserProfile,
|
||||
email_language: str,
|
||||
invite_expires_in_days: Union[Optional[int], UnspecifiedValue] = UnspecifiedValue(),
|
||||
) -> str:
|
||||
"""
|
||||
Send the confirmation/welcome e-mail to an invited user.
|
||||
"""
|
||||
activation_url = create_confirmation_link(
|
||||
invitee, Confirmation.INVITATION, validity_in_days=invite_expires_in_days
|
||||
)
|
||||
context = {
|
||||
"referrer_full_name": referrer.full_name,
|
||||
"referrer_email": referrer.delivery_email,
|
||||
"activate_url": activation_url,
|
||||
"referrer_realm_name": referrer.realm.name,
|
||||
}
|
||||
send_email(
|
||||
"zerver/emails/invitation",
|
||||
to_emails=[invitee.email],
|
||||
from_address=FromAddress.tokenized_no_reply_address(),
|
||||
language=email_language,
|
||||
context=context,
|
||||
realm=referrer.realm,
|
||||
)
|
||||
return activation_url
|
||||
|
||||
|
||||
def estimate_recent_invites(realms: Collection[Realm], *, days: int) -> int:
|
||||
"""An upper bound on the number of invites sent in the last `days` days"""
|
||||
recent_invites = RealmCount.objects.filter(
|
||||
realm__in=realms,
|
||||
property="invites_sent::day",
|
||||
end_time__gte=timezone_now() - datetime.timedelta(days=days),
|
||||
).aggregate(Sum("value"))["value__sum"]
|
||||
if recent_invites is None:
|
||||
return 0
|
||||
return recent_invites
|
||||
|
||||
|
||||
def check_invite_limit(realm: Realm, num_invitees: int) -> None:
|
||||
"""Discourage using invitation emails as a vector for carrying spam."""
|
||||
msg = _(
|
||||
"To protect users, Zulip limits the number of invitations you can send in one day. Because you have reached the limit, no invitations were sent."
|
||||
)
|
||||
if not settings.OPEN_REALM_CREATION:
|
||||
return
|
||||
|
||||
recent_invites = estimate_recent_invites([realm], days=1)
|
||||
if num_invitees + recent_invites > realm.max_invites:
|
||||
raise InvitationError(
|
||||
msg,
|
||||
[],
|
||||
sent_invitations=False,
|
||||
daily_limit_reached=True,
|
||||
)
|
||||
|
||||
default_max = settings.INVITES_DEFAULT_REALM_DAILY_MAX
|
||||
newrealm_age = datetime.timedelta(days=settings.INVITES_NEW_REALM_DAYS)
|
||||
if realm.date_created <= timezone_now() - newrealm_age:
|
||||
# If this isn't a "newly-created" realm, we're done. The
|
||||
# remaining code applies an aggregate limit across all
|
||||
# "new" realms, to address sudden bursts of spam realms.
|
||||
return
|
||||
|
||||
if realm.max_invites > default_max:
|
||||
# If a user is on a realm where we've bumped up
|
||||
# max_invites, then we exempt them from invite limits.
|
||||
return
|
||||
|
||||
new_realms = Realm.objects.filter(
|
||||
date_created__gte=timezone_now() - newrealm_age,
|
||||
_max_invites__lte=default_max,
|
||||
).all()
|
||||
|
||||
for days, count in settings.INVITES_NEW_REALM_LIMIT_DAYS:
|
||||
recent_invites = estimate_recent_invites(new_realms, days=days)
|
||||
if num_invitees + recent_invites > count:
|
||||
raise InvitationError(
|
||||
msg,
|
||||
[],
|
||||
sent_invitations=False,
|
||||
daily_limit_reached=True,
|
||||
)
|
||||
|
||||
|
||||
def do_invite_users(
|
||||
user_profile: UserProfile,
|
||||
invitee_emails: Collection[str],
|
||||
streams: Collection[Stream],
|
||||
*,
|
||||
invite_expires_in_days: Optional[int],
|
||||
invite_as: int = PreregistrationUser.INVITE_AS["MEMBER"],
|
||||
) -> None:
|
||||
num_invites = len(invitee_emails)
|
||||
|
||||
check_invite_limit(user_profile.realm, num_invites)
|
||||
if settings.BILLING_ENABLED:
|
||||
from corporate.lib.registration import check_spare_licenses_available_for_inviting_new_users
|
||||
|
||||
check_spare_licenses_available_for_inviting_new_users(user_profile.realm, num_invites)
|
||||
|
||||
realm = user_profile.realm
|
||||
if not realm.invite_required:
|
||||
# Inhibit joining an open realm to send spam invitations.
|
||||
min_age = datetime.timedelta(days=settings.INVITES_MIN_USER_AGE_DAYS)
|
||||
if user_profile.date_joined > timezone_now() - min_age and not user_profile.is_realm_admin:
|
||||
raise InvitationError(
|
||||
_(
|
||||
"Your account is too new to send invites for this organization. "
|
||||
"Ask an organization admin, or a more experienced user."
|
||||
),
|
||||
[],
|
||||
sent_invitations=False,
|
||||
)
|
||||
|
||||
good_emails: Set[str] = set()
|
||||
errors: List[Tuple[str, str, bool]] = []
|
||||
validate_email_allowed_in_realm = get_realm_email_validator(user_profile.realm)
|
||||
for email in invitee_emails:
|
||||
if email == "":
|
||||
continue
|
||||
email_error = validate_email_is_valid(
|
||||
email,
|
||||
validate_email_allowed_in_realm,
|
||||
)
|
||||
|
||||
if email_error:
|
||||
errors.append((email, email_error, False))
|
||||
else:
|
||||
good_emails.add(email)
|
||||
|
||||
"""
|
||||
good_emails are emails that look ok so far,
|
||||
but we still need to make sure they're not
|
||||
gonna conflict with existing users
|
||||
"""
|
||||
error_dict = get_existing_user_errors(user_profile.realm, good_emails)
|
||||
|
||||
skipped: List[Tuple[str, str, bool]] = []
|
||||
for email in error_dict:
|
||||
msg, deactivated = error_dict[email]
|
||||
skipped.append((email, msg, deactivated))
|
||||
good_emails.remove(email)
|
||||
|
||||
validated_emails = list(good_emails)
|
||||
|
||||
if errors:
|
||||
raise InvitationError(
|
||||
_("Some emails did not validate, so we didn't send any invitations."),
|
||||
errors + skipped,
|
||||
sent_invitations=False,
|
||||
)
|
||||
|
||||
if skipped and len(skipped) == len(invitee_emails):
|
||||
# All e-mails were skipped, so we didn't actually invite anyone.
|
||||
raise InvitationError(
|
||||
_("We weren't able to invite anyone."), skipped, sent_invitations=False
|
||||
)
|
||||
|
||||
# We do this here rather than in the invite queue processor since this
|
||||
# is used for rate limiting invitations, rather than keeping track of
|
||||
# when exactly invitations were sent
|
||||
do_increment_logging_stat(
|
||||
user_profile.realm,
|
||||
COUNT_STATS["invites_sent::day"],
|
||||
None,
|
||||
timezone_now(),
|
||||
increment=len(validated_emails),
|
||||
)
|
||||
|
||||
# Now that we are past all the possible errors, we actually create
|
||||
# the PreregistrationUser objects and trigger the email invitations.
|
||||
for email in validated_emails:
|
||||
# The logged in user is the referrer.
|
||||
prereg_user = PreregistrationUser(
|
||||
email=email, referred_by=user_profile, invited_as=invite_as, realm=user_profile.realm
|
||||
)
|
||||
prereg_user.save()
|
||||
stream_ids = [stream.id for stream in streams]
|
||||
prereg_user.streams.set(stream_ids)
|
||||
|
||||
event = {
|
||||
"prereg_id": prereg_user.id,
|
||||
"referrer_id": user_profile.id,
|
||||
"email_language": user_profile.realm.default_language,
|
||||
"invite_expires_in_days": invite_expires_in_days,
|
||||
}
|
||||
queue_json_publish("invites", event)
|
||||
|
||||
if skipped:
|
||||
raise InvitationError(
|
||||
_(
|
||||
"Some of those addresses are already using Zulip, "
|
||||
"so we didn't send them an invitation. We did send "
|
||||
"invitations to everyone else!"
|
||||
),
|
||||
skipped,
|
||||
sent_invitations=True,
|
||||
)
|
||||
notify_invites_changed(user_profile.realm)
|
||||
|
||||
|
||||
def get_invitation_expiry_date(confirmation_obj: Confirmation) -> Optional[int]:
|
||||
expiry_date = confirmation_obj.expiry_date
|
||||
if expiry_date is None:
|
||||
return expiry_date
|
||||
return datetime_to_timestamp(expiry_date)
|
||||
|
||||
|
||||
def do_get_invites_controlled_by_user(user_profile: UserProfile) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Returns a list of dicts representing invitations that can be controlled by user_profile.
|
||||
This isn't necessarily the same as all the invitations generated by the user, as administrators
|
||||
can control also invitations that they did not themselves create.
|
||||
"""
|
||||
if user_profile.is_realm_admin:
|
||||
prereg_users = filter_to_valid_prereg_users(
|
||||
PreregistrationUser.objects.filter(referred_by__realm=user_profile.realm)
|
||||
)
|
||||
else:
|
||||
prereg_users = filter_to_valid_prereg_users(
|
||||
PreregistrationUser.objects.filter(referred_by=user_profile)
|
||||
)
|
||||
|
||||
invites = []
|
||||
|
||||
for invitee in prereg_users:
|
||||
invites.append(
|
||||
dict(
|
||||
email=invitee.email,
|
||||
invited_by_user_id=invitee.referred_by.id,
|
||||
invited=datetime_to_timestamp(invitee.invited_at),
|
||||
expiry_date=get_invitation_expiry_date(invitee.confirmation.get()),
|
||||
id=invitee.id,
|
||||
invited_as=invitee.invited_as,
|
||||
is_multiuse=False,
|
||||
)
|
||||
)
|
||||
|
||||
if not user_profile.is_realm_admin:
|
||||
# We do not return multiuse invites to non-admin users.
|
||||
return invites
|
||||
|
||||
multiuse_confirmation_objs = Confirmation.objects.filter(
|
||||
realm=user_profile.realm, type=Confirmation.MULTIUSE_INVITE
|
||||
).filter(Q(expiry_date__gte=timezone_now()) | Q(expiry_date=None))
|
||||
for confirmation_obj in multiuse_confirmation_objs:
|
||||
invite = confirmation_obj.content_object
|
||||
assert invite is not None
|
||||
invites.append(
|
||||
dict(
|
||||
invited_by_user_id=invite.referred_by.id,
|
||||
invited=datetime_to_timestamp(confirmation_obj.date_sent),
|
||||
expiry_date=get_invitation_expiry_date(confirmation_obj),
|
||||
id=invite.id,
|
||||
link_url=confirmation_url(
|
||||
confirmation_obj.confirmation_key,
|
||||
user_profile.realm,
|
||||
Confirmation.MULTIUSE_INVITE,
|
||||
),
|
||||
invited_as=invite.invited_as,
|
||||
is_multiuse=True,
|
||||
)
|
||||
)
|
||||
return invites
|
||||
|
||||
|
||||
def get_valid_invite_confirmations_generated_by_user(
|
||||
user_profile: UserProfile,
|
||||
) -> List[Confirmation]:
|
||||
prereg_user_ids = filter_to_valid_prereg_users(
|
||||
PreregistrationUser.objects.filter(referred_by=user_profile)
|
||||
).values_list("id", flat=True)
|
||||
confirmations = list(
|
||||
Confirmation.objects.filter(type=Confirmation.INVITATION, object_id__in=prereg_user_ids)
|
||||
)
|
||||
|
||||
multiuse_invite_ids = MultiuseInvite.objects.filter(referred_by=user_profile).values_list(
|
||||
"id", flat=True
|
||||
)
|
||||
confirmations += list(
|
||||
Confirmation.objects.filter(
|
||||
type=Confirmation.MULTIUSE_INVITE,
|
||||
object_id__in=multiuse_invite_ids,
|
||||
).filter(Q(expiry_date__gte=timezone_now()) | Q(expiry_date=None))
|
||||
)
|
||||
|
||||
return confirmations
|
||||
|
||||
|
||||
def revoke_invites_generated_by_user(user_profile: UserProfile) -> None:
|
||||
confirmations_to_revoke = get_valid_invite_confirmations_generated_by_user(user_profile)
|
||||
now = timezone_now()
|
||||
for confirmation in confirmations_to_revoke:
|
||||
confirmation.expiry_date = now
|
||||
|
||||
Confirmation.objects.bulk_update(confirmations_to_revoke, ["expiry_date"])
|
||||
if len(confirmations_to_revoke):
|
||||
notify_invites_changed(realm=user_profile.realm)
|
||||
|
||||
|
||||
def do_create_multiuse_invite_link(
|
||||
referred_by: UserProfile,
|
||||
invited_as: int,
|
||||
invite_expires_in_days: Optional[int],
|
||||
streams: Sequence[Stream] = [],
|
||||
) -> str:
|
||||
realm = referred_by.realm
|
||||
invite = MultiuseInvite.objects.create(realm=realm, referred_by=referred_by)
|
||||
if streams:
|
||||
invite.streams.set(streams)
|
||||
invite.invited_as = invited_as
|
||||
invite.save()
|
||||
notify_invites_changed(referred_by.realm)
|
||||
return create_confirmation_link(
|
||||
invite, Confirmation.MULTIUSE_INVITE, validity_in_days=invite_expires_in_days
|
||||
)
|
||||
|
||||
|
||||
def do_revoke_user_invite(prereg_user: PreregistrationUser) -> None:
|
||||
email = prereg_user.email
|
||||
realm = prereg_user.realm
|
||||
assert realm is not None
|
||||
|
||||
# Delete both the confirmation objects and the prereg_user object.
|
||||
# TODO: Probably we actually want to set the confirmation objects
|
||||
# to a "revoked" status so that we can give the invited user a better
|
||||
# error message.
|
||||
content_type = ContentType.objects.get_for_model(PreregistrationUser)
|
||||
Confirmation.objects.filter(content_type=content_type, object_id=prereg_user.id).delete()
|
||||
prereg_user.delete()
|
||||
clear_scheduled_invitation_emails(email)
|
||||
notify_invites_changed(realm)
|
||||
|
||||
|
||||
def do_revoke_multi_use_invite(multiuse_invite: MultiuseInvite) -> None:
|
||||
realm = multiuse_invite.referred_by.realm
|
||||
|
||||
content_type = ContentType.objects.get_for_model(MultiuseInvite)
|
||||
Confirmation.objects.filter(content_type=content_type, object_id=multiuse_invite.id).delete()
|
||||
multiuse_invite.delete()
|
||||
notify_invites_changed(realm)
|
||||
|
||||
|
||||
def do_resend_user_invite_email(prereg_user: PreregistrationUser) -> int:
|
||||
# These are two structurally for the caller's code path.
|
||||
assert prereg_user.referred_by is not None
|
||||
assert prereg_user.realm is not None
|
||||
|
||||
check_invite_limit(prereg_user.referred_by.realm, 1)
|
||||
|
||||
prereg_user.invited_at = timezone_now()
|
||||
prereg_user.save()
|
||||
|
||||
expiry_date = prereg_user.confirmation.get().expiry_date
|
||||
if expiry_date is None:
|
||||
invite_expires_in_days = None
|
||||
else:
|
||||
# The resent invitation is reset to expire as long after the
|
||||
# reminder is sent as it lasted originally.
|
||||
invite_expires_in_days = (expiry_date - prereg_user.invited_at).days
|
||||
prereg_user.confirmation.clear()
|
||||
|
||||
do_increment_logging_stat(
|
||||
prereg_user.realm, COUNT_STATS["invites_sent::day"], None, prereg_user.invited_at
|
||||
)
|
||||
|
||||
clear_scheduled_invitation_emails(prereg_user.email)
|
||||
# We don't store the custom email body, so just set it to None
|
||||
event = {
|
||||
"prereg_id": prereg_user.id,
|
||||
"referrer_id": prereg_user.referred_by.id,
|
||||
"email_language": prereg_user.referred_by.realm.default_language,
|
||||
"invite_expires_in_days": invite_expires_in_days,
|
||||
}
|
||||
queue_json_publish("invites", event)
|
||||
|
||||
return datetime_to_timestamp(prereg_user.invited_at)
|
|
@ -26,10 +26,9 @@ from typing import (
|
|||
import django.db.utils
|
||||
import orjson
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import IntegrityError, connection, transaction
|
||||
from django.db.models import Exists, F, OuterRef, Q, Sum
|
||||
from django.db.models import Exists, F, OuterRef, Q
|
||||
from django.db.models.query import QuerySet
|
||||
from django.utils.html import escape
|
||||
from django.utils.timezone import now as timezone_now
|
||||
|
@ -40,19 +39,14 @@ from psycopg2.sql import SQL
|
|||
from typing_extensions import TypedDict
|
||||
|
||||
from analytics.lib.counts import COUNT_STATS, do_increment_logging_stat
|
||||
from analytics.models import RealmCount
|
||||
from confirmation import settings as confirmation_settings
|
||||
from confirmation.models import (
|
||||
Confirmation,
|
||||
confirmation_url,
|
||||
create_confirmation_link,
|
||||
generate_key,
|
||||
)
|
||||
from confirmation.models import Confirmation, create_confirmation_link, generate_key
|
||||
from zerver.actions.default_streams import (
|
||||
do_remove_default_stream,
|
||||
do_remove_streams_from_default_stream_group,
|
||||
get_default_streams_for_realm,
|
||||
)
|
||||
from zerver.actions.invites import notify_invites_changed, revoke_invites_generated_by_user
|
||||
from zerver.actions.user_groups import (
|
||||
do_send_user_group_members_update_event,
|
||||
update_users_in_full_members_system_group,
|
||||
|
@ -82,15 +76,9 @@ from zerver.lib.cache import (
|
|||
from zerver.lib.create_user import create_user, get_display_email_address
|
||||
from zerver.lib.email_mirror_helpers import encode_email_address
|
||||
from zerver.lib.email_notifications import enqueue_welcome_emails
|
||||
from zerver.lib.email_validation import (
|
||||
email_reserved_for_system_bots_error,
|
||||
get_existing_user_errors,
|
||||
get_realm_email_validator,
|
||||
validate_email_is_valid,
|
||||
)
|
||||
from zerver.lib.email_validation import email_reserved_for_system_bots_error
|
||||
from zerver.lib.emoji import check_emoji_request, emoji_name_to_emoji_code, get_emoji_file_name
|
||||
from zerver.lib.exceptions import (
|
||||
InvitationError,
|
||||
JsonableError,
|
||||
MarkdownRenderingException,
|
||||
StreamDoesNotExistError,
|
||||
|
@ -187,7 +175,6 @@ from zerver.lib.types import (
|
|||
RawSubscriptionDict,
|
||||
SubscriptionInfo,
|
||||
SubscriptionStreamDict,
|
||||
UnspecifiedValue,
|
||||
)
|
||||
from zerver.lib.upload import (
|
||||
claim_attachment,
|
||||
|
@ -231,7 +218,6 @@ from zerver.models import (
|
|||
Draft,
|
||||
EmailChangeStatus,
|
||||
Message,
|
||||
MultiuseInvite,
|
||||
MutedUser,
|
||||
PreregistrationUser,
|
||||
Reaction,
|
||||
|
@ -260,7 +246,6 @@ from zerver.models import (
|
|||
active_user_ids,
|
||||
bot_owner_user_ids,
|
||||
custom_profile_fields_for_realm,
|
||||
filter_to_valid_prereg_users,
|
||||
get_active_streams,
|
||||
get_bot_dicts_in_realm,
|
||||
get_bot_services,
|
||||
|
@ -372,12 +357,6 @@ def notify_new_user(user_profile: UserProfile) -> None:
|
|||
pass
|
||||
|
||||
|
||||
def notify_invites_changed(realm: Realm) -> None:
|
||||
event = dict(type="invites_changed")
|
||||
admin_ids = [user.id for user in realm.get_admin_users_and_bots()]
|
||||
send_event(realm, event, admin_ids)
|
||||
|
||||
|
||||
def add_new_user_history(user_profile: UserProfile, streams: Iterable[Stream]) -> None:
|
||||
"""Give you the last ONBOARDING_TOTAL_MESSAGES messages on your public
|
||||
streams, so you have something to look at in your home view once
|
||||
|
@ -7013,35 +6992,6 @@ def filter_presence_idle_user_ids(user_ids: Set[int]) -> List[int]:
|
|||
return sorted(idle_user_ids)
|
||||
|
||||
|
||||
def do_send_confirmation_email(
|
||||
invitee: PreregistrationUser,
|
||||
referrer: UserProfile,
|
||||
email_language: str,
|
||||
invite_expires_in_days: Union[Optional[int], UnspecifiedValue] = UnspecifiedValue(),
|
||||
) -> str:
|
||||
"""
|
||||
Send the confirmation/welcome e-mail to an invited user.
|
||||
"""
|
||||
activation_url = create_confirmation_link(
|
||||
invitee, Confirmation.INVITATION, validity_in_days=invite_expires_in_days
|
||||
)
|
||||
context = {
|
||||
"referrer_full_name": referrer.full_name,
|
||||
"referrer_email": referrer.delivery_email,
|
||||
"activate_url": activation_url,
|
||||
"referrer_realm_name": referrer.realm.name,
|
||||
}
|
||||
send_email(
|
||||
"zerver/emails/invitation",
|
||||
to_emails=[invitee.email],
|
||||
from_address=FromAddress.tokenized_no_reply_address(),
|
||||
language=email_language,
|
||||
context=context,
|
||||
realm=referrer.realm,
|
||||
)
|
||||
return activation_url
|
||||
|
||||
|
||||
def email_not_system_bot(email: str) -> None:
|
||||
if is_cross_realm_bot_email(email):
|
||||
msg = email_reserved_for_system_bots_error(email)
|
||||
|
@ -7053,359 +7003,6 @@ def email_not_system_bot(email: str) -> None:
|
|||
)
|
||||
|
||||
|
||||
def estimate_recent_invites(realms: Collection[Realm], *, days: int) -> int:
|
||||
"""An upper bound on the number of invites sent in the last `days` days"""
|
||||
recent_invites = RealmCount.objects.filter(
|
||||
realm__in=realms,
|
||||
property="invites_sent::day",
|
||||
end_time__gte=timezone_now() - datetime.timedelta(days=days),
|
||||
).aggregate(Sum("value"))["value__sum"]
|
||||
if recent_invites is None:
|
||||
return 0
|
||||
return recent_invites
|
||||
|
||||
|
||||
def check_invite_limit(realm: Realm, num_invitees: int) -> None:
|
||||
"""Discourage using invitation emails as a vector for carrying spam."""
|
||||
msg = _(
|
||||
"To protect users, Zulip limits the number of invitations you can send in one day. Because you have reached the limit, no invitations were sent."
|
||||
)
|
||||
if not settings.OPEN_REALM_CREATION:
|
||||
return
|
||||
|
||||
recent_invites = estimate_recent_invites([realm], days=1)
|
||||
if num_invitees + recent_invites > realm.max_invites:
|
||||
raise InvitationError(
|
||||
msg,
|
||||
[],
|
||||
sent_invitations=False,
|
||||
daily_limit_reached=True,
|
||||
)
|
||||
|
||||
default_max = settings.INVITES_DEFAULT_REALM_DAILY_MAX
|
||||
newrealm_age = datetime.timedelta(days=settings.INVITES_NEW_REALM_DAYS)
|
||||
if realm.date_created <= timezone_now() - newrealm_age:
|
||||
# If this isn't a "newly-created" realm, we're done. The
|
||||
# remaining code applies an aggregate limit across all
|
||||
# "new" realms, to address sudden bursts of spam realms.
|
||||
return
|
||||
|
||||
if realm.max_invites > default_max:
|
||||
# If a user is on a realm where we've bumped up
|
||||
# max_invites, then we exempt them from invite limits.
|
||||
return
|
||||
|
||||
new_realms = Realm.objects.filter(
|
||||
date_created__gte=timezone_now() - newrealm_age,
|
||||
_max_invites__lte=default_max,
|
||||
).all()
|
||||
|
||||
for days, count in settings.INVITES_NEW_REALM_LIMIT_DAYS:
|
||||
recent_invites = estimate_recent_invites(new_realms, days=days)
|
||||
if num_invitees + recent_invites > count:
|
||||
raise InvitationError(
|
||||
msg,
|
||||
[],
|
||||
sent_invitations=False,
|
||||
daily_limit_reached=True,
|
||||
)
|
||||
|
||||
|
||||
def do_invite_users(
|
||||
user_profile: UserProfile,
|
||||
invitee_emails: Collection[str],
|
||||
streams: Collection[Stream],
|
||||
*,
|
||||
invite_expires_in_days: Optional[int],
|
||||
invite_as: int = PreregistrationUser.INVITE_AS["MEMBER"],
|
||||
) -> None:
|
||||
num_invites = len(invitee_emails)
|
||||
|
||||
check_invite_limit(user_profile.realm, num_invites)
|
||||
if settings.BILLING_ENABLED:
|
||||
from corporate.lib.registration import check_spare_licenses_available_for_inviting_new_users
|
||||
|
||||
check_spare_licenses_available_for_inviting_new_users(user_profile.realm, num_invites)
|
||||
|
||||
realm = user_profile.realm
|
||||
if not realm.invite_required:
|
||||
# Inhibit joining an open realm to send spam invitations.
|
||||
min_age = datetime.timedelta(days=settings.INVITES_MIN_USER_AGE_DAYS)
|
||||
if user_profile.date_joined > timezone_now() - min_age and not user_profile.is_realm_admin:
|
||||
raise InvitationError(
|
||||
_(
|
||||
"Your account is too new to send invites for this organization. "
|
||||
"Ask an organization admin, or a more experienced user."
|
||||
),
|
||||
[],
|
||||
sent_invitations=False,
|
||||
)
|
||||
|
||||
good_emails: Set[str] = set()
|
||||
errors: List[Tuple[str, str, bool]] = []
|
||||
validate_email_allowed_in_realm = get_realm_email_validator(user_profile.realm)
|
||||
for email in invitee_emails:
|
||||
if email == "":
|
||||
continue
|
||||
email_error = validate_email_is_valid(
|
||||
email,
|
||||
validate_email_allowed_in_realm,
|
||||
)
|
||||
|
||||
if email_error:
|
||||
errors.append((email, email_error, False))
|
||||
else:
|
||||
good_emails.add(email)
|
||||
|
||||
"""
|
||||
good_emails are emails that look ok so far,
|
||||
but we still need to make sure they're not
|
||||
gonna conflict with existing users
|
||||
"""
|
||||
error_dict = get_existing_user_errors(user_profile.realm, good_emails)
|
||||
|
||||
skipped: List[Tuple[str, str, bool]] = []
|
||||
for email in error_dict:
|
||||
msg, deactivated = error_dict[email]
|
||||
skipped.append((email, msg, deactivated))
|
||||
good_emails.remove(email)
|
||||
|
||||
validated_emails = list(good_emails)
|
||||
|
||||
if errors:
|
||||
raise InvitationError(
|
||||
_("Some emails did not validate, so we didn't send any invitations."),
|
||||
errors + skipped,
|
||||
sent_invitations=False,
|
||||
)
|
||||
|
||||
if skipped and len(skipped) == len(invitee_emails):
|
||||
# All e-mails were skipped, so we didn't actually invite anyone.
|
||||
raise InvitationError(
|
||||
_("We weren't able to invite anyone."), skipped, sent_invitations=False
|
||||
)
|
||||
|
||||
# We do this here rather than in the invite queue processor since this
|
||||
# is used for rate limiting invitations, rather than keeping track of
|
||||
# when exactly invitations were sent
|
||||
do_increment_logging_stat(
|
||||
user_profile.realm,
|
||||
COUNT_STATS["invites_sent::day"],
|
||||
None,
|
||||
timezone_now(),
|
||||
increment=len(validated_emails),
|
||||
)
|
||||
|
||||
# Now that we are past all the possible errors, we actually create
|
||||
# the PreregistrationUser objects and trigger the email invitations.
|
||||
for email in validated_emails:
|
||||
# The logged in user is the referrer.
|
||||
prereg_user = PreregistrationUser(
|
||||
email=email, referred_by=user_profile, invited_as=invite_as, realm=user_profile.realm
|
||||
)
|
||||
prereg_user.save()
|
||||
stream_ids = [stream.id for stream in streams]
|
||||
prereg_user.streams.set(stream_ids)
|
||||
|
||||
event = {
|
||||
"prereg_id": prereg_user.id,
|
||||
"referrer_id": user_profile.id,
|
||||
"email_language": user_profile.realm.default_language,
|
||||
"invite_expires_in_days": invite_expires_in_days,
|
||||
}
|
||||
queue_json_publish("invites", event)
|
||||
|
||||
if skipped:
|
||||
raise InvitationError(
|
||||
_(
|
||||
"Some of those addresses are already using Zulip, "
|
||||
"so we didn't send them an invitation. We did send "
|
||||
"invitations to everyone else!"
|
||||
),
|
||||
skipped,
|
||||
sent_invitations=True,
|
||||
)
|
||||
notify_invites_changed(user_profile.realm)
|
||||
|
||||
|
||||
def get_invitation_expiry_date(confirmation_obj: Confirmation) -> Optional[int]:
|
||||
expiry_date = confirmation_obj.expiry_date
|
||||
if expiry_date is None:
|
||||
return expiry_date
|
||||
return datetime_to_timestamp(expiry_date)
|
||||
|
||||
|
||||
def do_get_invites_controlled_by_user(user_profile: UserProfile) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Returns a list of dicts representing invitations that can be controlled by user_profile.
|
||||
This isn't necessarily the same as all the invitations generated by the user, as administrators
|
||||
can control also invitations that they did not themselves create.
|
||||
"""
|
||||
if user_profile.is_realm_admin:
|
||||
prereg_users = filter_to_valid_prereg_users(
|
||||
PreregistrationUser.objects.filter(referred_by__realm=user_profile.realm)
|
||||
)
|
||||
else:
|
||||
prereg_users = filter_to_valid_prereg_users(
|
||||
PreregistrationUser.objects.filter(referred_by=user_profile)
|
||||
)
|
||||
|
||||
invites = []
|
||||
|
||||
for invitee in prereg_users:
|
||||
invites.append(
|
||||
dict(
|
||||
email=invitee.email,
|
||||
invited_by_user_id=invitee.referred_by.id,
|
||||
invited=datetime_to_timestamp(invitee.invited_at),
|
||||
expiry_date=get_invitation_expiry_date(invitee.confirmation.get()),
|
||||
id=invitee.id,
|
||||
invited_as=invitee.invited_as,
|
||||
is_multiuse=False,
|
||||
)
|
||||
)
|
||||
|
||||
if not user_profile.is_realm_admin:
|
||||
# We do not return multiuse invites to non-admin users.
|
||||
return invites
|
||||
|
||||
multiuse_confirmation_objs = Confirmation.objects.filter(
|
||||
realm=user_profile.realm, type=Confirmation.MULTIUSE_INVITE
|
||||
).filter(Q(expiry_date__gte=timezone_now()) | Q(expiry_date=None))
|
||||
for confirmation_obj in multiuse_confirmation_objs:
|
||||
invite = confirmation_obj.content_object
|
||||
assert invite is not None
|
||||
invites.append(
|
||||
dict(
|
||||
invited_by_user_id=invite.referred_by.id,
|
||||
invited=datetime_to_timestamp(confirmation_obj.date_sent),
|
||||
expiry_date=get_invitation_expiry_date(confirmation_obj),
|
||||
id=invite.id,
|
||||
link_url=confirmation_url(
|
||||
confirmation_obj.confirmation_key,
|
||||
user_profile.realm,
|
||||
Confirmation.MULTIUSE_INVITE,
|
||||
),
|
||||
invited_as=invite.invited_as,
|
||||
is_multiuse=True,
|
||||
)
|
||||
)
|
||||
return invites
|
||||
|
||||
|
||||
def get_valid_invite_confirmations_generated_by_user(
|
||||
user_profile: UserProfile,
|
||||
) -> List[Confirmation]:
|
||||
prereg_user_ids = filter_to_valid_prereg_users(
|
||||
PreregistrationUser.objects.filter(referred_by=user_profile)
|
||||
).values_list("id", flat=True)
|
||||
confirmations = list(
|
||||
Confirmation.objects.filter(type=Confirmation.INVITATION, object_id__in=prereg_user_ids)
|
||||
)
|
||||
|
||||
multiuse_invite_ids = MultiuseInvite.objects.filter(referred_by=user_profile).values_list(
|
||||
"id", flat=True
|
||||
)
|
||||
confirmations += list(
|
||||
Confirmation.objects.filter(
|
||||
type=Confirmation.MULTIUSE_INVITE,
|
||||
object_id__in=multiuse_invite_ids,
|
||||
).filter(Q(expiry_date__gte=timezone_now()) | Q(expiry_date=None))
|
||||
)
|
||||
|
||||
return confirmations
|
||||
|
||||
|
||||
def revoke_invites_generated_by_user(user_profile: UserProfile) -> None:
|
||||
confirmations_to_revoke = get_valid_invite_confirmations_generated_by_user(user_profile)
|
||||
now = timezone_now()
|
||||
for confirmation in confirmations_to_revoke:
|
||||
confirmation.expiry_date = now
|
||||
|
||||
Confirmation.objects.bulk_update(confirmations_to_revoke, ["expiry_date"])
|
||||
if len(confirmations_to_revoke):
|
||||
notify_invites_changed(realm=user_profile.realm)
|
||||
|
||||
|
||||
def do_create_multiuse_invite_link(
|
||||
referred_by: UserProfile,
|
||||
invited_as: int,
|
||||
invite_expires_in_days: Optional[int],
|
||||
streams: Sequence[Stream] = [],
|
||||
) -> str:
|
||||
realm = referred_by.realm
|
||||
invite = MultiuseInvite.objects.create(realm=realm, referred_by=referred_by)
|
||||
if streams:
|
||||
invite.streams.set(streams)
|
||||
invite.invited_as = invited_as
|
||||
invite.save()
|
||||
notify_invites_changed(referred_by.realm)
|
||||
return create_confirmation_link(
|
||||
invite, Confirmation.MULTIUSE_INVITE, validity_in_days=invite_expires_in_days
|
||||
)
|
||||
|
||||
|
||||
def do_revoke_user_invite(prereg_user: PreregistrationUser) -> None:
|
||||
email = prereg_user.email
|
||||
realm = prereg_user.realm
|
||||
assert realm is not None
|
||||
|
||||
# Delete both the confirmation objects and the prereg_user object.
|
||||
# TODO: Probably we actually want to set the confirmation objects
|
||||
# to a "revoked" status so that we can give the invited user a better
|
||||
# error message.
|
||||
content_type = ContentType.objects.get_for_model(PreregistrationUser)
|
||||
Confirmation.objects.filter(content_type=content_type, object_id=prereg_user.id).delete()
|
||||
prereg_user.delete()
|
||||
clear_scheduled_invitation_emails(email)
|
||||
notify_invites_changed(realm)
|
||||
|
||||
|
||||
def do_revoke_multi_use_invite(multiuse_invite: MultiuseInvite) -> None:
|
||||
realm = multiuse_invite.referred_by.realm
|
||||
|
||||
content_type = ContentType.objects.get_for_model(MultiuseInvite)
|
||||
Confirmation.objects.filter(content_type=content_type, object_id=multiuse_invite.id).delete()
|
||||
multiuse_invite.delete()
|
||||
notify_invites_changed(realm)
|
||||
|
||||
|
||||
def do_resend_user_invite_email(prereg_user: PreregistrationUser) -> int:
|
||||
# These are two structurally for the caller's code path.
|
||||
assert prereg_user.referred_by is not None
|
||||
assert prereg_user.realm is not None
|
||||
|
||||
check_invite_limit(prereg_user.referred_by.realm, 1)
|
||||
|
||||
prereg_user.invited_at = timezone_now()
|
||||
prereg_user.save()
|
||||
|
||||
expiry_date = prereg_user.confirmation.get().expiry_date
|
||||
if expiry_date is None:
|
||||
invite_expires_in_days = None
|
||||
else:
|
||||
# The resent invitation is reset to expire as long after the
|
||||
# reminder is sent as it lasted originally.
|
||||
invite_expires_in_days = (expiry_date - prereg_user.invited_at).days
|
||||
prereg_user.confirmation.clear()
|
||||
|
||||
do_increment_logging_stat(
|
||||
prereg_user.realm, COUNT_STATS["invites_sent::day"], None, prereg_user.invited_at
|
||||
)
|
||||
|
||||
clear_scheduled_invitation_emails(prereg_user.email)
|
||||
# We don't store the custom email body, so just set it to None
|
||||
event = {
|
||||
"prereg_id": prereg_user.id,
|
||||
"referrer_id": prereg_user.referred_by.id,
|
||||
"email_language": prereg_user.referred_by.realm.default_language,
|
||||
"invite_expires_in_days": invite_expires_in_days,
|
||||
}
|
||||
queue_json_publish("invites", event)
|
||||
|
||||
return datetime_to_timestamp(prereg_user.invited_at)
|
||||
|
||||
|
||||
def notify_realm_emoji(realm: Realm) -> None:
|
||||
event = dict(type="realm_emoji", op="update", realm_emoji=realm.get_emoji())
|
||||
send_event(realm, event, active_user_ids(realm.id))
|
||||
|
|
|
@ -40,6 +40,7 @@ from social_django.storage import BaseDjangoStorage
|
|||
from social_django.strategy import DjangoStrategy
|
||||
|
||||
from confirmation.models import Confirmation, create_confirmation_link
|
||||
from zerver.actions.invites import do_invite_users
|
||||
from zerver.lib.actions import (
|
||||
change_user_is_active,
|
||||
do_change_password,
|
||||
|
@ -47,7 +48,6 @@ from zerver.lib.actions import (
|
|||
do_create_user,
|
||||
do_deactivate_realm,
|
||||
do_deactivate_user,
|
||||
do_invite_users,
|
||||
do_reactivate_realm,
|
||||
do_reactivate_user,
|
||||
do_set_realm_property,
|
||||
|
|
|
@ -26,6 +26,12 @@ from zerver.actions.default_streams import (
|
|||
lookup_default_stream_groups,
|
||||
)
|
||||
from zerver.actions.hotspots import do_mark_hotspot_as_read
|
||||
from zerver.actions.invites import (
|
||||
do_create_multiuse_invite_link,
|
||||
do_invite_users,
|
||||
do_revoke_multi_use_invite,
|
||||
do_revoke_user_invite,
|
||||
)
|
||||
from zerver.actions.realm_linkifiers import (
|
||||
do_add_linkifier,
|
||||
do_remove_linkifier,
|
||||
|
@ -67,13 +73,11 @@ from zerver.lib.actions import (
|
|||
do_change_user_delivery_email,
|
||||
do_change_user_role,
|
||||
do_change_user_setting,
|
||||
do_create_multiuse_invite_link,
|
||||
do_create_user,
|
||||
do_deactivate_realm,
|
||||
do_deactivate_stream,
|
||||
do_deactivate_user,
|
||||
do_delete_messages,
|
||||
do_invite_users,
|
||||
do_make_user_billing_admin,
|
||||
do_mute_topic,
|
||||
do_mute_user,
|
||||
|
@ -84,8 +88,6 @@ from zerver.lib.actions import (
|
|||
do_remove_realm_domain,
|
||||
do_remove_realm_emoji,
|
||||
do_rename_stream,
|
||||
do_revoke_multi_use_invite,
|
||||
do_revoke_user_invite,
|
||||
do_set_realm_authentication_methods,
|
||||
do_set_realm_message_editing,
|
||||
do_set_realm_notifications_stream,
|
||||
|
|
|
@ -30,6 +30,11 @@ from zerver.actions.default_streams import (
|
|||
do_create_default_stream_group,
|
||||
get_default_streams_for_realm,
|
||||
)
|
||||
from zerver.actions.invites import (
|
||||
do_create_multiuse_invite_link,
|
||||
do_get_invites_controlled_by_user,
|
||||
do_invite_users,
|
||||
)
|
||||
from zerver.context_processors import common_context
|
||||
from zerver.decorator import do_two_factor_login
|
||||
from zerver.forms import HomepageForm, check_subdomain_available
|
||||
|
@ -39,13 +44,10 @@ from zerver.lib.actions import (
|
|||
do_change_full_name,
|
||||
do_change_realm_subdomain,
|
||||
do_change_user_role,
|
||||
do_create_multiuse_invite_link,
|
||||
do_create_realm,
|
||||
do_create_user,
|
||||
do_deactivate_realm,
|
||||
do_deactivate_user,
|
||||
do_get_invites_controlled_by_user,
|
||||
do_invite_users,
|
||||
do_set_realm_property,
|
||||
do_set_realm_user_default_setting,
|
||||
process_new_human_user,
|
||||
|
|
|
@ -12,16 +12,15 @@ from django.test import override_settings
|
|||
from django.utils.timezone import now as timezone_now
|
||||
|
||||
from confirmation.models import Confirmation
|
||||
from zerver.actions.invites import do_create_multiuse_invite_link, do_invite_users
|
||||
from zerver.lib.actions import (
|
||||
change_user_is_active,
|
||||
create_users,
|
||||
do_change_can_create_users,
|
||||
do_change_user_role,
|
||||
do_create_multiuse_invite_link,
|
||||
do_create_user,
|
||||
do_deactivate_user,
|
||||
do_delete_user,
|
||||
do_invite_users,
|
||||
do_mute_user,
|
||||
do_reactivate_user,
|
||||
do_set_realm_property,
|
||||
|
|
|
@ -5,8 +5,7 @@ from django.conf import settings
|
|||
from django.http import HttpRequest, HttpResponse
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from zerver.decorator import require_member_or_admin, require_realm_admin
|
||||
from zerver.lib.actions import (
|
||||
from zerver.actions.invites import (
|
||||
do_create_multiuse_invite_link,
|
||||
do_get_invites_controlled_by_user,
|
||||
do_invite_users,
|
||||
|
@ -14,6 +13,7 @@ from zerver.lib.actions import (
|
|||
do_revoke_multi_use_invite,
|
||||
do_revoke_user_invite,
|
||||
)
|
||||
from zerver.decorator import require_member_or_admin, require_realm_admin
|
||||
from zerver.lib.exceptions import JsonableError, OrganizationOwnerRequired
|
||||
from zerver.lib.request import REQ, has_request_variables
|
||||
from zerver.lib.response import json_success
|
||||
|
|
|
@ -46,10 +46,10 @@ from django.utils.translation import override as override_language
|
|||
from sentry_sdk import add_breadcrumb, configure_scope
|
||||
from zulip_bots.lib import extract_query_without_mention
|
||||
|
||||
from zerver.actions.invites import do_send_confirmation_email
|
||||
from zerver.context_processors import common_context
|
||||
from zerver.lib.actions import (
|
||||
do_mark_stream_messages_as_read,
|
||||
do_send_confirmation_email,
|
||||
do_update_embedded_data,
|
||||
do_update_user_activity,
|
||||
do_update_user_activity_interval,
|
||||
|
|
Loading…
Reference in New Issue