snapshot of https://github.com/zulip/zulip.git @ 48c69911c7dd4e03b20552eb3bd77ad8151c1357 (2026-09-15)
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
This commit is contained in:
316
zerver/views/invite.py
Normal file
316
zerver/views/invite.py
Normal file
@@ -0,0 +1,316 @@
|
||||
import email.utils
|
||||
from typing import Annotated
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.utils.timezone import now as timezone_now
|
||||
from django.utils.translation import gettext as _
|
||||
from pydantic import Json, StringConstraints
|
||||
|
||||
from confirmation import settings as confirmation_settings
|
||||
from zerver.actions.invites import (
|
||||
do_create_multiuse_invite_link,
|
||||
do_get_invites_controlled_by_user,
|
||||
do_invite_users,
|
||||
do_revoke_multi_use_invite,
|
||||
do_revoke_user_invite,
|
||||
do_send_user_invite_email,
|
||||
)
|
||||
from zerver.decorator import require_human_non_guest_user
|
||||
from zerver.lib.exceptions import InvitationError, JsonableError, OrganizationOwnerRequiredError
|
||||
from zerver.lib.response import json_success
|
||||
from zerver.lib.streams import access_stream_by_id, get_streams_to_which_user_cannot_add_subscribers
|
||||
from zerver.lib.typed_endpoint import ApiParamConfig, PathOnly, typed_endpoint
|
||||
from zerver.lib.typed_endpoint_validators import check_int_in_validator
|
||||
from zerver.lib.types import Invitee
|
||||
from zerver.lib.user_groups import UserGroupMembershipDetails, access_user_group_for_update
|
||||
from zerver.models import (
|
||||
MultiuseInvite,
|
||||
NamedUserGroup,
|
||||
PreregistrationUser,
|
||||
Realm,
|
||||
Stream,
|
||||
UserProfile,
|
||||
)
|
||||
|
||||
# Convert INVITATION_LINK_VALIDITY_DAYS into minutes.
|
||||
# Because mypy fails to correctly infer the type of the validator, we want this constant
|
||||
# to be Optional[int] to avoid a mypy error when using it as the default value.
|
||||
# https://github.com/python/mypy/issues/13234
|
||||
INVITATION_LINK_VALIDITY_MINUTES: int | None = 24 * 60 * settings.INVITATION_LINK_VALIDITY_DAYS
|
||||
|
||||
|
||||
def check_role_based_permissions(
|
||||
invited_as: int, user_profile: UserProfile, *, require_admin: bool
|
||||
) -> None:
|
||||
if (
|
||||
invited_as == PreregistrationUser.INVITE_AS["REALM_OWNER"]
|
||||
and not user_profile.is_realm_owner
|
||||
):
|
||||
raise OrganizationOwnerRequiredError
|
||||
|
||||
if require_admin and not user_profile.is_realm_admin:
|
||||
raise JsonableError(_("Must be an organization administrator"))
|
||||
|
||||
|
||||
def access_invite_by_id(user_profile: UserProfile, invite_id: int) -> PreregistrationUser:
|
||||
try:
|
||||
prereg_user = PreregistrationUser.objects.get(id=invite_id)
|
||||
except PreregistrationUser.DoesNotExist:
|
||||
raise JsonableError(_("No such invitation"))
|
||||
|
||||
# Structurally, any invitation the user can actually access should
|
||||
# have a referred_by set for the user who created it.
|
||||
if prereg_user.referred_by is None or prereg_user.referred_by.realm != user_profile.realm:
|
||||
raise JsonableError(_("No such invitation"))
|
||||
|
||||
if prereg_user.status != 0:
|
||||
raise JsonableError(_("Invitation already used or deactivated."))
|
||||
|
||||
if prereg_user.referred_by_id != user_profile.id:
|
||||
check_role_based_permissions(prereg_user.invited_as, user_profile, require_admin=True)
|
||||
return prereg_user
|
||||
|
||||
|
||||
def access_multiuse_invite_by_id(user_profile: UserProfile, invite_id: int) -> MultiuseInvite:
|
||||
try:
|
||||
invite = MultiuseInvite.objects.get(id=invite_id)
|
||||
except MultiuseInvite.DoesNotExist:
|
||||
raise JsonableError(_("No such invitation"))
|
||||
|
||||
if invite.realm != user_profile.realm:
|
||||
raise JsonableError(_("No such invitation"))
|
||||
|
||||
if invite.referred_by_id != user_profile.id:
|
||||
check_role_based_permissions(invite.invited_as, user_profile, require_admin=True)
|
||||
|
||||
if invite.status == confirmation_settings.STATUS_REVOKED:
|
||||
raise JsonableError(_("Invitation has already been revoked"))
|
||||
return invite
|
||||
|
||||
|
||||
def access_streams_for_invite(stream_ids: list[int], user_profile: UserProfile) -> list[Stream]:
|
||||
streams: list[Stream] = []
|
||||
|
||||
for stream_id in stream_ids:
|
||||
try:
|
||||
(stream, _sub) = access_stream_by_id(user_profile, stream_id)
|
||||
except JsonableError:
|
||||
raise JsonableError(
|
||||
_("Invalid channel ID {channel_id}. No invites were sent.").format(
|
||||
channel_id=stream_id
|
||||
)
|
||||
)
|
||||
streams.append(stream)
|
||||
|
||||
user_group_membership_details = UserGroupMembershipDetails(user_recursive_group_ids=None)
|
||||
streams_to_which_user_cannot_add_subscribers = get_streams_to_which_user_cannot_add_subscribers(
|
||||
streams,
|
||||
user_profile,
|
||||
allow_default_streams=True,
|
||||
user_group_membership_details=user_group_membership_details,
|
||||
)
|
||||
if len(streams_to_which_user_cannot_add_subscribers) > 0:
|
||||
raise JsonableError(_("You do not have permission to subscribe other users to channels."))
|
||||
|
||||
return streams
|
||||
|
||||
|
||||
def access_user_groups_for_invite(
|
||||
group_ids: list[int] | None, user_profile: UserProfile
|
||||
) -> list[NamedUserGroup]:
|
||||
user_groups: list[NamedUserGroup] = []
|
||||
if group_ids:
|
||||
with transaction.atomic(durable=True):
|
||||
for group_id in group_ids:
|
||||
user_group = access_user_group_for_update(
|
||||
group_id, user_profile, permission_setting="can_add_members_group"
|
||||
)
|
||||
user_groups.append(user_group)
|
||||
|
||||
return user_groups
|
||||
|
||||
|
||||
@require_human_non_guest_user
|
||||
@typed_endpoint
|
||||
def invite_users_backend(
|
||||
request: HttpRequest,
|
||||
user_profile: UserProfile,
|
||||
*,
|
||||
group_ids: Json[list[int]] | None = None,
|
||||
include_realm_default_subscriptions: Json[bool] = False,
|
||||
invite_as: Annotated[
|
||||
Json[int],
|
||||
check_int_in_validator(list(PreregistrationUser.INVITE_AS.values())),
|
||||
] = PreregistrationUser.INVITE_AS["MEMBER"],
|
||||
invite_expires_in_minutes: Json[int | None] = INVITATION_LINK_VALIDITY_MINUTES,
|
||||
invitee_emails_raw: Annotated[str, ApiParamConfig("invitee_emails")],
|
||||
notify_referrer_on_join: Json[bool] = True,
|
||||
stream_ids: Json[list[int]],
|
||||
welcome_message_custom_text: Annotated[
|
||||
str | None,
|
||||
StringConstraints(
|
||||
max_length=Realm.MAX_REALM_WELCOME_MESSAGE_CUSTOM_TEXT_LENGTH,
|
||||
),
|
||||
] = None,
|
||||
) -> HttpResponse:
|
||||
if not user_profile.can_invite_users_by_email():
|
||||
# Guest users case will not be handled here as it will
|
||||
# be handled by the decorator above.
|
||||
raise JsonableError(_("Insufficient permission"))
|
||||
|
||||
require_admin = invite_as in [
|
||||
# Owners can only be invited by owners, checked by separate
|
||||
# logic in check_role_based_permissions.
|
||||
PreregistrationUser.INVITE_AS["REALM_OWNER"],
|
||||
PreregistrationUser.INVITE_AS["REALM_ADMIN"],
|
||||
PreregistrationUser.INVITE_AS["MODERATOR"],
|
||||
]
|
||||
check_role_based_permissions(invite_as, user_profile, require_admin=require_admin)
|
||||
|
||||
if not invitee_emails_raw:
|
||||
raise JsonableError(_("You must specify at least one email address."))
|
||||
|
||||
invitee_emails = get_invitees_set(invitee_emails_raw)
|
||||
|
||||
streams = access_streams_for_invite(stream_ids, user_profile)
|
||||
user_groups = access_user_groups_for_invite(group_ids, user_profile)
|
||||
|
||||
if welcome_message_custom_text is not None and not user_profile.is_realm_admin:
|
||||
raise JsonableError(_("Must be an organization administrator"))
|
||||
|
||||
skipped = do_invite_users(
|
||||
user_profile,
|
||||
invitee_emails,
|
||||
streams,
|
||||
notify_referrer_on_join,
|
||||
user_groups,
|
||||
invite_expires_in_minutes=invite_expires_in_minutes,
|
||||
include_realm_default_subscriptions=include_realm_default_subscriptions,
|
||||
invite_as=invite_as,
|
||||
welcome_message_custom_text=welcome_message_custom_text,
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
return json_success(request)
|
||||
|
||||
|
||||
def get_invitee_emails_set(invitee_emails_raw: str) -> set[str]:
|
||||
return {
|
||||
email_addr
|
||||
for name, email_addr in email.utils.getaddresses(
|
||||
invitee_emails_raw.split("\n"), strict=False
|
||||
)
|
||||
} - {""}
|
||||
|
||||
|
||||
def get_invitees_set(invitee_emails_raw: str) -> set[Invitee]:
|
||||
return {
|
||||
Invitee(email=email, full_name=full_name)
|
||||
for full_name, email in email.utils.getaddresses(
|
||||
invitee_emails_raw.split("\n"), strict=False
|
||||
)
|
||||
if email
|
||||
}
|
||||
|
||||
|
||||
@require_human_non_guest_user
|
||||
def get_user_invites(request: HttpRequest, user_profile: UserProfile) -> HttpResponse:
|
||||
all_users = do_get_invites_controlled_by_user(user_profile)
|
||||
return json_success(request, data={"invites": all_users})
|
||||
|
||||
|
||||
@require_human_non_guest_user
|
||||
@typed_endpoint
|
||||
def revoke_user_invite(
|
||||
request: HttpRequest, user_profile: UserProfile, *, invite_id: PathOnly[int]
|
||||
) -> HttpResponse:
|
||||
prereg_user = access_invite_by_id(user_profile, invite_id)
|
||||
do_revoke_user_invite(prereg_user, acting_user=user_profile)
|
||||
return json_success(request)
|
||||
|
||||
|
||||
@require_human_non_guest_user
|
||||
@typed_endpoint
|
||||
def revoke_multiuse_invite(
|
||||
request: HttpRequest, user_profile: UserProfile, *, invite_id: PathOnly[int]
|
||||
) -> HttpResponse:
|
||||
invite = access_multiuse_invite_by_id(user_profile, invite_id)
|
||||
do_revoke_multi_use_invite(invite, acting_user=user_profile)
|
||||
return json_success(request)
|
||||
|
||||
|
||||
@require_human_non_guest_user
|
||||
@typed_endpoint
|
||||
def resend_user_invite_email(
|
||||
request: HttpRequest, user_profile: UserProfile, *, invite_id: PathOnly[int]
|
||||
) -> HttpResponse:
|
||||
prereg_user = access_invite_by_id(user_profile, invite_id)
|
||||
do_send_user_invite_email(prereg_user, event_time=timezone_now())
|
||||
return json_success(request)
|
||||
|
||||
|
||||
@require_human_non_guest_user
|
||||
@typed_endpoint
|
||||
def generate_multiuse_invite_backend(
|
||||
request: HttpRequest,
|
||||
user_profile: UserProfile,
|
||||
*,
|
||||
group_ids: Json[list[int]] | None = None,
|
||||
include_realm_default_subscriptions: Json[bool] = False,
|
||||
invite_as: Annotated[
|
||||
Json[int],
|
||||
check_int_in_validator(list(PreregistrationUser.INVITE_AS.values())),
|
||||
] = PreregistrationUser.INVITE_AS["MEMBER"],
|
||||
invite_expires_in_minutes: Json[int | None] = INVITATION_LINK_VALIDITY_MINUTES,
|
||||
stream_ids: Json[list[int]] | None = None,
|
||||
welcome_message_custom_text: Annotated[
|
||||
str | None,
|
||||
StringConstraints(
|
||||
max_length=Realm.MAX_REALM_WELCOME_MESSAGE_CUSTOM_TEXT_LENGTH,
|
||||
),
|
||||
] = None,
|
||||
) -> HttpResponse:
|
||||
if stream_ids is None:
|
||||
stream_ids = []
|
||||
if not user_profile.can_create_multiuse_invite_to_realm():
|
||||
# Guest users case will not be handled here as it will
|
||||
# be handled by the decorator above.
|
||||
raise JsonableError(_("Insufficient permission"))
|
||||
|
||||
require_admin = invite_as in [
|
||||
# Owners can only be invited by owners, checked by separate
|
||||
# logic in check_role_based_permissions.
|
||||
PreregistrationUser.INVITE_AS["REALM_OWNER"],
|
||||
PreregistrationUser.INVITE_AS["REALM_ADMIN"],
|
||||
PreregistrationUser.INVITE_AS["MODERATOR"],
|
||||
]
|
||||
check_role_based_permissions(invite_as, user_profile, require_admin=require_admin)
|
||||
|
||||
streams = access_streams_for_invite(stream_ids, user_profile)
|
||||
user_groups = access_user_groups_for_invite(group_ids, user_profile)
|
||||
|
||||
if welcome_message_custom_text is not None and not user_profile.is_realm_admin:
|
||||
raise JsonableError(_("Must be an organization administrator"))
|
||||
|
||||
invite_link = do_create_multiuse_invite_link(
|
||||
user_profile,
|
||||
invite_as,
|
||||
invite_expires_in_minutes,
|
||||
include_realm_default_subscriptions,
|
||||
streams,
|
||||
user_groups,
|
||||
welcome_message_custom_text,
|
||||
)
|
||||
return json_success(request, data={"invite_link": invite_link})
|
||||
Reference in New Issue
Block a user