From ca8d374e210f0f128fa6d11a21d59fa654b18aed Mon Sep 17 00:00:00 2001 From: Anders Kaseorg Date: Thu, 14 Apr 2022 14:36:07 -0700 Subject: [PATCH] actions: Split out zerver.actions.invites. Signed-off-by: Anders Kaseorg --- analytics/tests/test_counts.py | 8 +- analytics/tests/test_support_views.py | 7 +- zerver/actions/invites.py | 419 ++++++++++++++++++++++++++ zerver/lib/actions.py | 411 +------------------------ zerver/tests/test_auth_backends.py | 2 +- zerver/tests/test_events.py | 10 +- zerver/tests/test_signup.py | 8 +- zerver/tests/test_users.py | 3 +- zerver/views/invite.py | 4 +- zerver/worker/queue_processors.py | 2 +- 10 files changed, 446 insertions(+), 428 deletions(-) create mode 100644 zerver/actions/invites.py diff --git a/analytics/tests/test_counts.py b/analytics/tests/test_counts.py index a6a8f677dd..fd9836e01b 100644 --- a/analytics/tests/test_counts.py +++ b/analytics/tests/test_counts.py @@ -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, ) diff --git a/analytics/tests/test_support_views.py b/analytics/tests/test_support_views.py index 7e868aaa1e..303f7cfc73 100644 --- a/analytics/tests/test_support_views.py +++ b/analytics/tests/test_support_views.py @@ -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 ( diff --git a/zerver/actions/invites.py b/zerver/actions/invites.py new file mode 100644 index 0000000000..774c743398 --- /dev/null +++ b/zerver/actions/invites.py @@ -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) diff --git a/zerver/lib/actions.py b/zerver/lib/actions.py index a36d665e9e..c90479386a 100644 --- a/zerver/lib/actions.py +++ b/zerver/lib/actions.py @@ -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)) diff --git a/zerver/tests/test_auth_backends.py b/zerver/tests/test_auth_backends.py index 0f9ccf8500..a80a343903 100644 --- a/zerver/tests/test_auth_backends.py +++ b/zerver/tests/test_auth_backends.py @@ -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, diff --git a/zerver/tests/test_events.py b/zerver/tests/test_events.py index 2f6e4b0644..c951e22b23 100644 --- a/zerver/tests/test_events.py +++ b/zerver/tests/test_events.py @@ -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, diff --git a/zerver/tests/test_signup.py b/zerver/tests/test_signup.py index a4fdde133f..a0c2001eea 100644 --- a/zerver/tests/test_signup.py +++ b/zerver/tests/test_signup.py @@ -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, diff --git a/zerver/tests/test_users.py b/zerver/tests/test_users.py index a249337eb1..c8d5dcc348 100644 --- a/zerver/tests/test_users.py +++ b/zerver/tests/test_users.py @@ -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, diff --git a/zerver/views/invite.py b/zerver/views/invite.py index 55d49069a4..9b6621d734 100644 --- a/zerver/views/invite.py +++ b/zerver/views/invite.py @@ -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 diff --git a/zerver/worker/queue_processors.py b/zerver/worker/queue_processors.py index 25c8970dd1..6de7cdcb0d 100644 --- a/zerver/worker/queue_processors.py +++ b/zerver/worker/queue_processors.py @@ -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,