zulip/zerver/models/users.py

1193 lines
46 KiB
Python

from email.headerregistry import Address
from typing import TYPE_CHECKING, Any, Optional
from uuid import uuid4
from django.conf import settings
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, UserManager
from django.db import models
from django.db.models import CASCADE, F, Q, QuerySet
from django.db.models.functions import Upper
from django.db.models.signals import post_save
from django.utils.timezone import now as timezone_now
from django.utils.translation import gettext_lazy
from typing_extensions import override
from zerver.lib.cache import (
active_non_guest_user_ids_cache_key,
active_user_ids_cache_key,
bot_dict_fields,
bot_dicts_in_realm_cache_key,
bot_profile_cache_key,
cache_with_key,
flush_user_profile,
realm_user_dict_fields,
realm_user_dicts_cache_key,
user_profile_by_api_key_cache_key,
user_profile_by_id_cache_key,
user_profile_cache_key,
)
from zerver.lib.types import ProfileData, RawUserDict
from zerver.lib.utils import generate_api_key
from zerver.models.constants import MAX_LANGUAGE_ID_LENGTH
if TYPE_CHECKING:
from zerver.models import Realm
class UserBaseSettings(models.Model):
"""This abstract class is the container for all preferences/personal
settings for users that control the behavior of the application.
It was extracted from UserProfile to support the RealmUserDefault
model (i.e. allow individual realms to configure the default
values of these preferences for new users in their organization).
Changing the default value for a field declared here likely
requires a migration to update all RealmUserDefault rows that had
the old default value to have the new default value. Otherwise,
the default change will only affect new users joining Realms
created after the change.
"""
### Generic UI settings
enter_sends = models.BooleanField(default=False)
### Preferences. ###
# left_side_userlist was removed from the UI in Zulip 6.0; the
# database model is being temporarily preserved in case we want to
# restore a version of the setting, preserving who had it enabled.
left_side_userlist = models.BooleanField(default=False)
default_language = models.CharField(default="en", max_length=MAX_LANGUAGE_ID_LENGTH)
# This setting controls which view is rendered first when Zulip loads.
# Values for it are URL suffix after `#`.
web_home_view = models.TextField(default="inbox")
web_escape_navigates_to_home_view = models.BooleanField(default=True)
fluid_layout_width = models.BooleanField(default=False)
high_contrast_mode = models.BooleanField(default=False)
translate_emoticons = models.BooleanField(default=False)
display_emoji_reaction_users = models.BooleanField(default=True)
twenty_four_hour_time = models.BooleanField(default=False)
starred_message_counts = models.BooleanField(default=True)
COLOR_SCHEME_AUTOMATIC = 1
COLOR_SCHEME_DARK = 2
COLOR_SCHEME_LIGHT = 3
COLOR_SCHEME_CHOICES = [COLOR_SCHEME_AUTOMATIC, COLOR_SCHEME_DARK, COLOR_SCHEME_LIGHT]
color_scheme = models.PositiveSmallIntegerField(default=COLOR_SCHEME_AUTOMATIC)
# Information density is established through
# adjustments to the font size and line height.
WEB_FONT_SIZE_PX_COMPACT = 14
WEB_FONT_SIZE_PX_DEFAULT = 16
WEB_LINE_HEIGHT_PERCENT_COMPACT = 122
WEB_LINE_HEIGHT_PERCENT_DEFAULT = 140
dense_mode = models.BooleanField(default=False)
web_font_size_px = models.PositiveSmallIntegerField(default=WEB_FONT_SIZE_PX_DEFAULT)
web_line_height_percent = models.PositiveSmallIntegerField(
default=WEB_LINE_HEIGHT_PERCENT_DEFAULT
)
# UI setting to control how animated images are played.
web_animate_image_previews = models.TextField(default="on_hover")
# UI setting controlling Zulip's behavior of demoting in the sort
# order and graying out streams with no recent traffic. The
# default behavior, automatic, enables this behavior once a user
# is subscribed to 30+ streams in the web app.
DEMOTE_STREAMS_AUTOMATIC = 1
DEMOTE_STREAMS_ALWAYS = 2
DEMOTE_STREAMS_NEVER = 3
DEMOTE_STREAMS_CHOICES = [
DEMOTE_STREAMS_AUTOMATIC,
DEMOTE_STREAMS_ALWAYS,
DEMOTE_STREAMS_NEVER,
]
demote_inactive_streams = models.PositiveSmallIntegerField(default=DEMOTE_STREAMS_AUTOMATIC)
# UI setting controlling whether or not the Zulip web app will
# mark messages as read as it scrolls through the feed.
MARK_READ_ON_SCROLL_ALWAYS = 1
MARK_READ_ON_SCROLL_CONVERSATION_ONLY = 2
MARK_READ_ON_SCROLL_NEVER = 3
WEB_MARK_READ_ON_SCROLL_POLICY_CHOICES = [
MARK_READ_ON_SCROLL_ALWAYS,
MARK_READ_ON_SCROLL_CONVERSATION_ONLY,
MARK_READ_ON_SCROLL_NEVER,
]
web_mark_read_on_scroll_policy = models.SmallIntegerField(default=MARK_READ_ON_SCROLL_ALWAYS)
# UI setting controlling if clicking on a channel link should open
# the channel feed (interleaved view) or narrow to the first topic
# in the channel.
WEB_CHANNEL_DEFAULT_VIEW_FIRST_TOPIC = 1
WEB_CHANNEL_DEFAULT_VIEW_CHANNEL_FEED = 2
WEB_CHANNEL_DEFAULT_VIEW_CHOICES = [
WEB_CHANNEL_DEFAULT_VIEW_FIRST_TOPIC,
WEB_CHANNEL_DEFAULT_VIEW_CHANNEL_FEED,
]
web_channel_default_view = models.SmallIntegerField(
default=WEB_CHANNEL_DEFAULT_VIEW_FIRST_TOPIC,
db_default=WEB_CHANNEL_DEFAULT_VIEW_FIRST_TOPIC,
)
# Emoji sets
GOOGLE_EMOJISET = "google"
GOOGLE_BLOB_EMOJISET = "google-blob"
TEXT_EMOJISET = "text"
TWITTER_EMOJISET = "twitter"
EMOJISET_CHOICES = (
(GOOGLE_EMOJISET, "Google"),
(TWITTER_EMOJISET, "Twitter"),
(TEXT_EMOJISET, "Plain text"),
(GOOGLE_BLOB_EMOJISET, "Google blobs"),
)
emojiset = models.CharField(default=GOOGLE_EMOJISET, choices=EMOJISET_CHOICES, max_length=20)
# User list style
USER_LIST_STYLE_COMPACT = 1
USER_LIST_STYLE_WITH_STATUS = 2
USER_LIST_STYLE_WITH_AVATAR = 3
USER_LIST_STYLE_CHOICES = [
USER_LIST_STYLE_COMPACT,
USER_LIST_STYLE_WITH_STATUS,
USER_LIST_STYLE_WITH_AVATAR,
]
user_list_style = models.PositiveSmallIntegerField(default=USER_LIST_STYLE_WITH_STATUS)
# Show unread counts for
WEB_STREAM_UNREADS_COUNT_DISPLAY_POLICY_ALL_STREAMS = 1
WEB_STREAM_UNREADS_COUNT_DISPLAY_POLICY_UNMUTED_STREAMS = 2
WEB_STREAM_UNREADS_COUNT_DISPLAY_POLICY_NO_STREAMS = 3
WEB_STREAM_UNREADS_COUNT_DISPLAY_POLICY_CHOICES = [
WEB_STREAM_UNREADS_COUNT_DISPLAY_POLICY_ALL_STREAMS,
WEB_STREAM_UNREADS_COUNT_DISPLAY_POLICY_UNMUTED_STREAMS,
WEB_STREAM_UNREADS_COUNT_DISPLAY_POLICY_NO_STREAMS,
]
web_stream_unreads_count_display_policy = models.PositiveSmallIntegerField(
default=WEB_STREAM_UNREADS_COUNT_DISPLAY_POLICY_UNMUTED_STREAMS
)
# Setting to control whether to automatically go to the
# conversation where message was sent.
web_navigate_to_sent_message = models.BooleanField(default=True)
### Notifications settings. ###
email_notifications_batching_period_seconds = models.IntegerField(default=120)
# Stream notifications.
enable_stream_desktop_notifications = models.BooleanField(default=False)
enable_stream_email_notifications = models.BooleanField(default=False)
enable_stream_push_notifications = models.BooleanField(default=False)
enable_stream_audible_notifications = models.BooleanField(default=False)
notification_sound = models.CharField(max_length=20, default="zulip")
wildcard_mentions_notify = models.BooleanField(default=True)
# Followed Topics notifications.
enable_followed_topic_desktop_notifications = models.BooleanField(default=True)
enable_followed_topic_email_notifications = models.BooleanField(default=True)
enable_followed_topic_push_notifications = models.BooleanField(default=True)
enable_followed_topic_audible_notifications = models.BooleanField(default=True)
enable_followed_topic_wildcard_mentions_notify = models.BooleanField(default=True)
# Direct message + @-mention notifications.
enable_desktop_notifications = models.BooleanField(default=True)
pm_content_in_desktop_notifications = models.BooleanField(default=True)
enable_sounds = models.BooleanField(default=True)
enable_offline_email_notifications = models.BooleanField(default=True)
message_content_in_email_notifications = models.BooleanField(default=True)
enable_offline_push_notifications = models.BooleanField(default=True)
enable_online_push_notifications = models.BooleanField(default=True)
DESKTOP_ICON_COUNT_DISPLAY_MESSAGES = 1
DESKTOP_ICON_COUNT_DISPLAY_DM_MENTION_FOLLOWED_TOPIC = 2
DESKTOP_ICON_COUNT_DISPLAY_DM_MENTION = 3
DESKTOP_ICON_COUNT_DISPLAY_NONE = 4
DESKTOP_ICON_COUNT_DISPLAY_CHOICES = [
DESKTOP_ICON_COUNT_DISPLAY_MESSAGES,
DESKTOP_ICON_COUNT_DISPLAY_DM_MENTION,
DESKTOP_ICON_COUNT_DISPLAY_DM_MENTION_FOLLOWED_TOPIC,
DESKTOP_ICON_COUNT_DISPLAY_NONE,
]
desktop_icon_count_display = models.PositiveSmallIntegerField(
default=DESKTOP_ICON_COUNT_DISPLAY_MESSAGES
)
enable_digest_emails = models.BooleanField(default=True)
enable_login_emails = models.BooleanField(default=True)
enable_marketing_emails = models.BooleanField(default=True)
presence_enabled = models.BooleanField(default=True)
REALM_NAME_IN_EMAIL_NOTIFICATIONS_POLICY_AUTOMATIC = 1
REALM_NAME_IN_EMAIL_NOTIFICATIONS_POLICY_ALWAYS = 2
REALM_NAME_IN_EMAIL_NOTIFICATIONS_POLICY_NEVER = 3
REALM_NAME_IN_EMAIL_NOTIFICATIONS_POLICY_CHOICES = [
REALM_NAME_IN_EMAIL_NOTIFICATIONS_POLICY_AUTOMATIC,
REALM_NAME_IN_EMAIL_NOTIFICATIONS_POLICY_ALWAYS,
REALM_NAME_IN_EMAIL_NOTIFICATIONS_POLICY_NEVER,
]
realm_name_in_email_notifications_policy = models.PositiveSmallIntegerField(
default=REALM_NAME_IN_EMAIL_NOTIFICATIONS_POLICY_AUTOMATIC
)
# The following two settings control which topics to automatically
# 'follow' or 'unmute in a muted stream', respectively.
# Follow or unmute a topic automatically on:
# - PARTICIPATION: Send a message, React to a message, Participate in a poll or Edit a TO-DO list.
# - SEND: Send a message.
# - INITIATION: Send the first message in the topic.
# - NEVER: Never automatically follow or unmute a topic.
AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_PARTICIPATION = 1
AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_SEND = 2
AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_INITIATION = 3
AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_NEVER = 4
AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_CHOICES = [
AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_PARTICIPATION,
AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_SEND,
AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_INITIATION,
AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_NEVER,
]
automatically_follow_topics_policy = models.PositiveSmallIntegerField(
default=AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_INITIATION,
)
automatically_unmute_topics_in_muted_streams_policy = models.PositiveSmallIntegerField(
default=AUTOMATICALLY_CHANGE_VISIBILITY_POLICY_ON_SEND,
)
automatically_follow_topics_where_mentioned = models.BooleanField(default=True)
# Whether or not the user wants to sync their drafts.
enable_drafts_synchronization = models.BooleanField(default=True)
# Privacy settings
send_stream_typing_notifications = models.BooleanField(default=True)
send_private_typing_notifications = models.BooleanField(default=True)
send_read_receipts = models.BooleanField(default=True)
allow_private_data_export = models.BooleanField(default=False)
# Whether the user wants to see typing notifications.
receives_typing_notifications = models.BooleanField(default=True)
# Who in the organization has access to users' actual email
# addresses. Controls whether the UserProfile.email field is
# the same as UserProfile.delivery_email, or is instead a fake
# generated value encoding the user ID and realm hostname.
EMAIL_ADDRESS_VISIBILITY_EVERYONE = 1
EMAIL_ADDRESS_VISIBILITY_MEMBERS = 2
EMAIL_ADDRESS_VISIBILITY_ADMINS = 3
EMAIL_ADDRESS_VISIBILITY_NOBODY = 4
EMAIL_ADDRESS_VISIBILITY_MODERATORS = 5
email_address_visibility = models.PositiveSmallIntegerField(
default=EMAIL_ADDRESS_VISIBILITY_EVERYONE,
)
EMAIL_ADDRESS_VISIBILITY_ID_TO_NAME_MAP = {
EMAIL_ADDRESS_VISIBILITY_EVERYONE: gettext_lazy("Admins, moderators, members and guests"),
EMAIL_ADDRESS_VISIBILITY_MEMBERS: gettext_lazy("Admins, moderators and members"),
EMAIL_ADDRESS_VISIBILITY_MODERATORS: gettext_lazy("Admins and moderators"),
EMAIL_ADDRESS_VISIBILITY_ADMINS: gettext_lazy("Admins only"),
EMAIL_ADDRESS_VISIBILITY_NOBODY: gettext_lazy("Nobody"),
}
EMAIL_ADDRESS_VISIBILITY_TYPES = list(EMAIL_ADDRESS_VISIBILITY_ID_TO_NAME_MAP.keys())
display_settings_legacy = dict(
# Don't add anything new to this legacy dict.
# Instead, see `modern_settings` below.
color_scheme=int,
default_language=str,
web_home_view=str,
demote_inactive_streams=int,
dense_mode=bool,
emojiset=str,
enable_drafts_synchronization=bool,
enter_sends=bool,
fluid_layout_width=bool,
high_contrast_mode=bool,
left_side_userlist=bool,
starred_message_counts=bool,
translate_emoticons=bool,
twenty_four_hour_time=bool,
)
notification_settings_legacy = dict(
# Don't add anything new to this legacy dict.
# Instead, see `modern_notification_settings` below.
desktop_icon_count_display=int,
email_notifications_batching_period_seconds=int,
enable_desktop_notifications=bool,
enable_digest_emails=bool,
enable_login_emails=bool,
enable_marketing_emails=bool,
enable_offline_email_notifications=bool,
enable_offline_push_notifications=bool,
enable_online_push_notifications=bool,
enable_sounds=bool,
enable_stream_audible_notifications=bool,
enable_stream_desktop_notifications=bool,
enable_stream_email_notifications=bool,
enable_stream_push_notifications=bool,
message_content_in_email_notifications=bool,
notification_sound=str,
pm_content_in_desktop_notifications=bool,
presence_enabled=bool,
realm_name_in_email_notifications_policy=int,
wildcard_mentions_notify=bool,
)
modern_settings = dict(
# Add new general settings here.
display_emoji_reaction_users=bool,
email_address_visibility=int,
web_escape_navigates_to_home_view=bool,
receives_typing_notifications=bool,
send_private_typing_notifications=bool,
send_read_receipts=bool,
send_stream_typing_notifications=bool,
allow_private_data_export=bool,
web_mark_read_on_scroll_policy=int,
web_channel_default_view=int,
user_list_style=int,
web_animate_image_previews=str,
web_stream_unreads_count_display_policy=int,
web_font_size_px=int,
web_line_height_percent=int,
web_navigate_to_sent_message=bool,
)
modern_notification_settings: dict[str, Any] = dict(
# Add new notification settings here.
enable_followed_topic_desktop_notifications=bool,
enable_followed_topic_email_notifications=bool,
enable_followed_topic_push_notifications=bool,
enable_followed_topic_audible_notifications=bool,
enable_followed_topic_wildcard_mentions_notify=bool,
automatically_follow_topics_policy=int,
automatically_unmute_topics_in_muted_streams_policy=int,
automatically_follow_topics_where_mentioned=bool,
)
notification_setting_types = {
**notification_settings_legacy,
**modern_notification_settings,
}
# Define the types of the various automatically managed properties
property_types = {
**display_settings_legacy,
**notification_setting_types,
**modern_settings,
}
class Meta:
abstract = True
@staticmethod
def emojiset_choices() -> list[dict[str, str]]:
return [
dict(key=emojiset[0], text=emojiset[1]) for emojiset in UserProfile.EMOJISET_CHOICES
]
class RealmUserDefault(UserBaseSettings):
"""This table stores realm-level default values for user preferences
like notification settings, used when creating a new user account.
"""
realm = models.OneToOneField("zerver.Realm", on_delete=CASCADE)
class UserProfile(AbstractBaseUser, PermissionsMixin, UserBaseSettings):
USERNAME_FIELD = "email"
MAX_NAME_LENGTH = 100
MIN_NAME_LENGTH = 2
API_KEY_LENGTH = 32
NAME_INVALID_CHARS = ["*", "`", "\\", ">", '"', "@"]
DEFAULT_BOT = 1
"""
Incoming webhook bots are limited to only sending messages via webhooks.
Thus, it is less of a security risk to expose their API keys to third-party services,
since they can't be used to read messages.
"""
INCOMING_WEBHOOK_BOT = 2
# This value is also being used in web/src/settings_bots.js.
# On updating it here, update it there as well.
OUTGOING_WEBHOOK_BOT = 3
"""
Embedded bots run within the Zulip server itself; events are added to the
embedded_bots queue and then handled by a QueueProcessingWorker.
"""
EMBEDDED_BOT = 4
BOT_TYPES = {
DEFAULT_BOT: "Generic bot",
INCOMING_WEBHOOK_BOT: "Incoming webhook",
OUTGOING_WEBHOOK_BOT: "Outgoing webhook",
EMBEDDED_BOT: "Embedded bot",
}
SERVICE_BOT_TYPES = [
OUTGOING_WEBHOOK_BOT,
EMBEDDED_BOT,
]
id = models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")
# For historical reasons, Zulip has two email fields. The
# `delivery_email` field is the user's email address, where all
# email notifications will be sent, and is used for all
# authentication use cases.
#
# The `email` field is the same as delivery_email in organizations
# with EMAIL_ADDRESS_VISIBILITY_EVERYONE. For other
# organizations, it will be a unique value of the form
# user1234@example.com. This field exists for backwards
# compatibility in Zulip APIs where users are referred to by their
# email address, not their ID; it should be used in all API use cases.
#
# Both fields are unique within a realm (in a case-insensitive
# fashion). Since Django's unique_together is case sensitive, this
# is enforced via SQL indexes created by
# zerver/migrations/0295_case_insensitive_email_indexes.py.
delivery_email = models.EmailField(blank=False, db_index=True)
email = models.EmailField(blank=False, db_index=True)
realm = models.ForeignKey("zerver.Realm", on_delete=CASCADE)
# Foreign key to the Recipient object for PERSONAL type messages to this user.
recipient = models.ForeignKey("zerver.Recipient", null=True, on_delete=models.SET_NULL)
INACCESSIBLE_USER_NAME = gettext_lazy("Unknown user")
# The user's name. We prefer the model of a full_name
# over first+last because cultures vary on how many
# names one has, whether the family name is first or last, etc.
# It also allows organizations to encode a bit of non-name data in
# the "name" attribute if desired, like gender pronouns,
# graduation year, etc.
full_name = models.CharField(max_length=MAX_NAME_LENGTH)
date_joined = models.DateTimeField(default=timezone_now)
# Terms of Service version number that this user has accepted. We
# use the special value TOS_VERSION_BEFORE_FIRST_LOGIN for users
# whose account was created without direct user interaction (via
# the API or a data import), and null for users whose account is
# fully created on servers that do not have a configured ToS.
TOS_VERSION_BEFORE_FIRST_LOGIN = "-1"
tos_version = models.CharField(null=True, max_length=10)
api_key = models.CharField(max_length=API_KEY_LENGTH, default=generate_api_key, unique=True)
# A UUID generated on user creation. Introduced primarily to
# provide a unique key for a user for the mobile push
# notifications bouncer that will not have collisions after doing
# a data export and then import.
uuid = models.UUIDField(default=uuid4, unique=True)
# Whether the user has access to server-level administrator pages, like /activity
is_staff = models.BooleanField(default=False)
# For a normal user, this is True unless the user or an admin has
# deactivated their account. The name comes from Django; this field
# isn't related to presence or to whether the user has recently used Zulip.
#
# See also `long_term_idle`.
is_active = models.BooleanField(default=True, db_index=True)
is_billing_admin = models.BooleanField(default=False, db_index=True)
is_bot = models.BooleanField(default=False, db_index=True)
bot_type = models.PositiveSmallIntegerField(null=True, db_index=True)
bot_owner = models.ForeignKey("self", null=True, on_delete=models.SET_NULL)
# Each role has a superset of the permissions of the next higher
# numbered role. When adding new roles, leave enough space for
# future roles to be inserted between currently adjacent
# roles. These constants appear in RealmAuditLog.extra_data, so
# changes to them will require a migration of RealmAuditLog.
ROLE_REALM_OWNER = 100
ROLE_REALM_ADMINISTRATOR = 200
ROLE_MODERATOR = 300
ROLE_MEMBER = 400
ROLE_GUEST = 600
role = models.PositiveSmallIntegerField(default=ROLE_MEMBER, db_index=True)
ROLE_TYPES = [
ROLE_REALM_OWNER,
ROLE_REALM_ADMINISTRATOR,
ROLE_MODERATOR,
ROLE_MEMBER,
ROLE_GUEST,
]
# Maps: user_profile.role -> which email_address_visibility values
# allow user_profile to see their email address.
ROLE_TO_ACCESSIBLE_EMAIL_ADDRESS_VISIBILITY_IDS = {
ROLE_REALM_OWNER: [
UserBaseSettings.EMAIL_ADDRESS_VISIBILITY_ADMINS,
UserBaseSettings.EMAIL_ADDRESS_VISIBILITY_MODERATORS,
UserBaseSettings.EMAIL_ADDRESS_VISIBILITY_MEMBERS,
UserBaseSettings.EMAIL_ADDRESS_VISIBILITY_EVERYONE,
],
ROLE_REALM_ADMINISTRATOR: [
UserBaseSettings.EMAIL_ADDRESS_VISIBILITY_ADMINS,
UserBaseSettings.EMAIL_ADDRESS_VISIBILITY_MODERATORS,
UserBaseSettings.EMAIL_ADDRESS_VISIBILITY_MEMBERS,
UserBaseSettings.EMAIL_ADDRESS_VISIBILITY_EVERYONE,
],
ROLE_MODERATOR: [
UserBaseSettings.EMAIL_ADDRESS_VISIBILITY_MODERATORS,
UserBaseSettings.EMAIL_ADDRESS_VISIBILITY_MEMBERS,
UserBaseSettings.EMAIL_ADDRESS_VISIBILITY_EVERYONE,
],
ROLE_MEMBER: [
UserBaseSettings.EMAIL_ADDRESS_VISIBILITY_MEMBERS,
UserBaseSettings.EMAIL_ADDRESS_VISIBILITY_EVERYONE,
],
ROLE_GUEST: [
UserBaseSettings.EMAIL_ADDRESS_VISIBILITY_EVERYONE,
],
}
# Whether the user has been "soft-deactivated" due to weeks of inactivity.
# For these users we avoid doing UserMessage table work, as an optimization
# for large Zulip organizations with lots of single-visit users.
long_term_idle = models.BooleanField(default=False, db_index=True)
# When we last added basic UserMessage rows for a long_term_idle user.
last_active_message_id = models.IntegerField(null=True)
# Mirror dummies are fake (!is_active) users used to provide
# message senders in our cross-protocol Zephyr<->Zulip content
# mirroring integration, so that we can display mirrored content
# like native Zulip messages (with a name + avatar, etc.).
is_mirror_dummy = models.BooleanField(default=False)
# Users with this flag set are allowed to forge messages as sent by another
# user and to send to private streams; also used for Zephyr/Jabber mirroring.
can_forge_sender = models.BooleanField(default=False, db_index=True)
# Users with this flag set can create other users via API.
can_create_users = models.BooleanField(default=False, db_index=True)
# Users with this flag can change email addresses of users in the realm via the API.
can_change_user_emails = models.BooleanField(default=False, db_index=True)
# Used for rate-limiting certain automated messages generated by bots
last_reminder = models.DateTimeField(default=None, null=True)
# Minutes to wait before warning a bot owner that their bot sent a message
# to a nonexistent stream
BOT_OWNER_STREAM_ALERT_WAITPERIOD = 1
# API rate limits, formatted as a comma-separated list of range:max pairs
rate_limits = models.CharField(default="", max_length=100)
# Default streams for some deprecated/legacy classes of bot users.
default_sending_stream = models.ForeignKey(
"zerver.Stream",
null=True,
related_name="+",
on_delete=models.SET_NULL,
)
default_events_register_stream = models.ForeignKey(
"zerver.Stream",
null=True,
related_name="+",
on_delete=models.SET_NULL,
)
default_all_public_streams = models.BooleanField(default=False)
# A time zone name from the `tzdata` database, as found in zoneinfo.available_timezones().
#
# The longest existing name is 32 characters long, so max_length=40 seems
# like a safe choice.
#
# In Django, the convention is to use an empty string instead of NULL/None
# for text-based fields. For more information, see
# https://docs.djangoproject.com/en/5.0/ref/models/fields/#django.db.models.Field.null.
timezone = models.CharField(max_length=40, default="")
AVATAR_FROM_GRAVATAR = "G"
AVATAR_FROM_USER = "U"
AVATAR_SOURCES = (
(AVATAR_FROM_GRAVATAR, "Hosted by Gravatar"),
(AVATAR_FROM_USER, "Uploaded by user"),
)
avatar_source = models.CharField(
default=AVATAR_FROM_GRAVATAR, choices=AVATAR_SOURCES, max_length=1
)
avatar_version = models.PositiveSmallIntegerField(default=1)
# This is only used for LDAP-provided avatars; it contains the
# SHA256 hex digest of most recent raw contents that LDAP provided
# us, pre-thumbnailing.
avatar_hash = models.CharField(null=True, max_length=64)
zoom_token = models.JSONField(default=None, null=True)
objects = UserManager()
ROLE_ID_TO_NAME_MAP = {
ROLE_REALM_OWNER: gettext_lazy("Organization owner"),
ROLE_REALM_ADMINISTRATOR: gettext_lazy("Organization administrator"),
ROLE_MODERATOR: gettext_lazy("Moderator"),
ROLE_MEMBER: gettext_lazy("Member"),
ROLE_GUEST: gettext_lazy("Guest"),
}
# Mapping of role ids to simple string identifiers for the roles,
# to be used in API contexts such as SCIM provisioning.
ROLE_ID_TO_API_NAME = {
ROLE_REALM_OWNER: "owner",
ROLE_REALM_ADMINISTRATOR: "administrator",
ROLE_MODERATOR: "moderator",
ROLE_MEMBER: "member",
ROLE_GUEST: "guest",
}
ROLE_API_NAME_TO_ID = {v: k for k, v in ROLE_ID_TO_API_NAME.items()}
class Meta:
constraints = [
models.UniqueConstraint(
"realm",
Upper(F("email")),
name="zerver_userprofile_realm_id_email_uniq",
),
models.UniqueConstraint(
"realm",
Upper(F("delivery_email")),
name="zerver_userprofile_realm_id_delivery_email_uniq",
),
]
indexes = [
models.Index(Upper("email"), name="upper_userprofile_email_idx"),
]
@override
def __str__(self) -> str:
return f"{self.email} {self.realm!r}"
def get_role_name(self) -> str:
return str(self.ROLE_ID_TO_NAME_MAP[self.role])
def profile_data(self) -> ProfileData:
from zerver.models import CustomProfileFieldValue
from zerver.models.custom_profile_fields import custom_profile_fields_for_realm
values = CustomProfileFieldValue.objects.filter(user_profile=self)
user_data = {
v.field_id: {"value": v.value, "rendered_value": v.rendered_value} for v in values
}
data: ProfileData = []
for field in custom_profile_fields_for_realm(self.realm_id):
field_values = user_data.get(field.id, None)
if field_values:
value, rendered_value = (
field_values.get("value"),
field_values.get("rendered_value"),
)
else:
value, rendered_value = None, None
field_type = field.field_type
if value is not None:
converter = field.FIELD_CONVERTERS[field_type]
value = converter(value)
field_data = field.as_dict()
data.append(
{
"id": field_data["id"],
"name": field_data["name"],
"type": field_data["type"],
"hint": field_data["hint"],
"field_data": field_data["field_data"],
"order": field_data["order"],
"value": value,
"rendered_value": rendered_value,
}
)
return data
def can_admin_user(self, target_user: "UserProfile") -> bool:
"""Returns whether this user has permission to modify target_user"""
if target_user.bot_owner_id == self.id:
return True
elif self.is_realm_admin and self.realm == target_user.realm:
return True
else:
return False
@property
def is_provisional_member(self) -> bool:
if self.is_moderator:
return False
diff = (timezone_now() - self.date_joined).days
if diff < self.realm.waiting_period_threshold:
return True
return False
@property
def is_realm_admin(self) -> bool:
return self.role in (UserProfile.ROLE_REALM_ADMINISTRATOR, UserProfile.ROLE_REALM_OWNER)
@is_realm_admin.setter
def is_realm_admin(self, value: bool) -> None:
if value:
self.role = UserProfile.ROLE_REALM_ADMINISTRATOR
elif self.role == UserProfile.ROLE_REALM_ADMINISTRATOR:
# We need to be careful to not accidentally change
# ROLE_GUEST to ROLE_MEMBER here.
self.role = UserProfile.ROLE_MEMBER
@property
def has_billing_access(self) -> bool:
return self.is_realm_owner or self.is_billing_admin
@property
def is_realm_owner(self) -> bool:
return self.role == UserProfile.ROLE_REALM_OWNER
@is_realm_owner.setter
def is_realm_owner(self, value: bool) -> None:
if value:
self.role = UserProfile.ROLE_REALM_OWNER
elif self.role == UserProfile.ROLE_REALM_OWNER:
# We need to be careful to not accidentally change
# ROLE_GUEST to ROLE_MEMBER here.
self.role = UserProfile.ROLE_MEMBER
@property
def is_guest(self) -> bool:
return self.role == UserProfile.ROLE_GUEST
@is_guest.setter
def is_guest(self, value: bool) -> None:
if value:
self.role = UserProfile.ROLE_GUEST
elif self.role == UserProfile.ROLE_GUEST:
# We need to be careful to not accidentally change
# ROLE_REALM_ADMINISTRATOR to ROLE_MEMBER here.
self.role = UserProfile.ROLE_MEMBER
@property
def is_moderator(self) -> bool:
return self.role == UserProfile.ROLE_MODERATOR
@is_moderator.setter
def is_moderator(self, value: bool) -> None:
if value:
self.role = UserProfile.ROLE_MODERATOR
elif self.role == UserProfile.ROLE_MODERATOR:
# We need to be careful to not accidentally change
# ROLE_GUEST to ROLE_MEMBER here.
self.role = UserProfile.ROLE_MEMBER
@property
def is_incoming_webhook(self) -> bool:
return self.bot_type == UserProfile.INCOMING_WEBHOOK_BOT
@property
def allowed_bot_types(self) -> list[int]:
from zerver.models.realms import BotCreationPolicyEnum
allowed_bot_types = []
if (
self.is_realm_admin
or self.realm.bot_creation_policy != BotCreationPolicyEnum.LIMIT_GENERIC_BOTS
):
allowed_bot_types.append(UserProfile.DEFAULT_BOT)
allowed_bot_types += [
UserProfile.INCOMING_WEBHOOK_BOT,
UserProfile.OUTGOING_WEBHOOK_BOT,
]
if settings.EMBEDDED_BOTS_ENABLED:
allowed_bot_types.append(UserProfile.EMBEDDED_BOT)
return allowed_bot_types
def email_address_is_realm_public(self) -> bool:
# Bots always have EMAIL_ADDRESS_VISIBILITY_EVERYONE.
if self.email_address_visibility == UserProfile.EMAIL_ADDRESS_VISIBILITY_EVERYONE:
return True
return False
def has_permission(self, policy_name: str, realm: Optional["Realm"] = None) -> bool:
from zerver.lib.user_groups import user_has_permission_for_group_setting
from zerver.models import Realm
if policy_name not in Realm.REALM_PERMISSION_GROUP_SETTINGS and policy_name not in [
"invite_to_stream_policy",
"invite_to_realm_policy",
]:
raise AssertionError("Invalid policy")
if policy_name in Realm.REALM_PERMISSION_GROUP_SETTINGS:
if realm is None:
# realm is passed by the caller only when we optimize
# the number of database queries by fetching the group
# setting fields using select_related.
realm = self.realm
allowed_user_group = getattr(realm, policy_name)
setting_config = Realm.REALM_PERMISSION_GROUP_SETTINGS[policy_name]
return user_has_permission_for_group_setting(allowed_user_group, self, setting_config)
policy_value = getattr(self.realm, policy_name)
if policy_value == Realm.POLICY_NOBODY:
return False
if policy_value == Realm.POLICY_EVERYONE:
return True
if self.is_realm_owner:
return True
if policy_value == Realm.POLICY_OWNERS_ONLY:
return False
if self.is_realm_admin:
return True
if policy_value == Realm.POLICY_ADMINS_ONLY:
return False
if self.is_moderator:
return True
if policy_value == Realm.POLICY_MODERATORS_ONLY:
return False
if self.is_guest:
return False
if policy_value == Realm.POLICY_MEMBERS_ONLY:
return True
assert policy_value == Realm.POLICY_FULL_MEMBERS_ONLY
return not self.is_provisional_member
def can_create_public_streams(self, realm: Optional["Realm"] = None) -> bool:
return self.has_permission("can_create_public_channel_group", realm)
def can_create_private_streams(self, realm: Optional["Realm"] = None) -> bool:
return self.has_permission("can_create_private_channel_group", realm)
def can_create_web_public_streams(self) -> bool:
if not self.realm.web_public_streams_enabled():
return False
return self.has_permission("can_create_web_public_channel_group")
def can_subscribe_other_users(self) -> bool:
return self.has_permission("invite_to_stream_policy")
def can_invite_users_by_email(self) -> bool:
return self.has_permission("invite_to_realm_policy")
def can_create_multiuse_invite_to_realm(self) -> bool:
return self.has_permission("create_multiuse_invite_group")
def can_move_messages_between_streams(self) -> bool:
return self.has_permission("can_move_messages_between_channels_group")
def can_create_user_groups(self) -> bool:
return self.has_permission("can_create_groups")
def can_manage_all_groups(self) -> bool:
return self.has_permission("can_manage_all_groups")
def can_move_messages_to_another_topic(self) -> bool:
return self.has_permission("can_move_messages_between_topics_group")
def can_add_custom_emoji(self) -> bool:
return self.has_permission("can_add_custom_emoji_group")
def can_delete_any_message(self) -> bool:
return self.has_permission("can_delete_any_message_group")
def can_delete_own_message(self) -> bool:
return self.has_permission("can_delete_own_message_group")
def can_access_public_streams(self) -> bool:
return not (self.is_guest or self.realm.is_zephyr_mirror_realm)
def major_tos_version(self) -> int:
if self.tos_version is not None:
return int(self.tos_version.split(".")[0])
else:
return -1
def format_requester_for_logs(self) -> str:
return "{}@{}".format(self.id, self.realm.string_id or "root")
@override
def set_password(self, password: str | None) -> None:
if password is None:
self.set_unusable_password()
return
from zproject.backends import check_password_strength
if not check_password_strength(password):
raise PasswordTooWeakError
super().set_password(password)
class PasswordTooWeakError(Exception):
pass
def remote_user_to_email(remote_user: str) -> str:
if settings.SSO_APPEND_DOMAIN is not None:
return Address(username=remote_user, domain=settings.SSO_APPEND_DOMAIN).addr_spec
return remote_user
# Make sure we flush the UserProfile object from our remote cache
# whenever we save it.
post_save.connect(flush_user_profile, sender=UserProfile)
@cache_with_key(user_profile_by_id_cache_key, timeout=3600 * 24 * 7)
def get_user_profile_by_id(user_profile_id: int) -> UserProfile:
return UserProfile.objects.select_related(
"realm",
"realm__can_access_all_users_group",
"realm__can_access_all_users_group__named_user_group",
"realm__direct_message_initiator_group",
"realm__direct_message_initiator_group__named_user_group",
"realm__direct_message_permission_group",
"realm__direct_message_permission_group__named_user_group",
"bot_owner",
).get(id=user_profile_id)
def get_user_profile_by_email(email: str) -> UserProfile:
"""This function is intended to be used for
manual manage.py shell work; robust code must use get_user or
get_user_by_delivery_email instead, because Zulip supports
multiple users with a given (delivery) email address existing on a
single server (in different realms).
"""
return UserProfile.objects.select_related("realm").get(delivery_email__iexact=email.strip())
@cache_with_key(user_profile_by_api_key_cache_key, timeout=3600 * 24 * 7)
def maybe_get_user_profile_by_api_key(api_key: str) -> UserProfile | None:
try:
return UserProfile.objects.select_related(
"realm",
"realm__can_access_all_users_group",
"realm__can_access_all_users_group__named_user_group",
"bot_owner",
).get(api_key=api_key)
except UserProfile.DoesNotExist:
# We will cache failed lookups with None. The
# use case here is that broken API clients may
# continually ask for the same wrong API key, and
# we want to handle that as quickly as possible.
return None
def get_user_profile_by_api_key(api_key: str) -> UserProfile:
user_profile = maybe_get_user_profile_by_api_key(api_key)
if user_profile is None:
raise UserProfile.DoesNotExist
return user_profile
def get_user_by_delivery_email(email: str, realm: "Realm") -> UserProfile:
"""Fetches a user given their delivery email. For use in
authentication/registration contexts. Do not use for user-facing
views (e.g. Zulip API endpoints) as doing so would violate the
EMAIL_ADDRESS_VISIBILITY_ADMINS security model. Use get_user in
those code paths.
"""
return UserProfile.objects.select_related(
"realm",
"realm__can_access_all_users_group",
"realm__can_access_all_users_group__named_user_group",
"realm__direct_message_initiator_group",
"realm__direct_message_initiator_group__named_user_group",
"realm__direct_message_permission_group",
"realm__direct_message_permission_group__named_user_group",
"bot_owner",
).get(delivery_email__iexact=email.strip(), realm=realm)
def get_users_by_delivery_email(emails: set[str], realm: "Realm") -> QuerySet[UserProfile]:
"""This is similar to get_user_by_delivery_email, and
it has the same security caveats. It gets multiple
users and returns a QuerySet, since most callers
will only need two or three fields.
If you are using this to get large UserProfile objects, you are
probably making a mistake, but if you must,
then use `select_related`.
"""
"""
Django doesn't support delivery_email__iexact__in, so
we simply OR all the filters that we'd do for the
one-email case.
"""
email_filter = Q()
for email in emails:
email_filter |= Q(delivery_email__iexact=email.strip())
return UserProfile.objects.filter(realm=realm).filter(email_filter)
@cache_with_key(user_profile_cache_key, timeout=3600 * 24 * 7)
def get_user(email: str, realm: "Realm") -> UserProfile:
"""Fetches the user by its visible-to-other users username (in the
`email` field). For use in API contexts; do not use in
authentication/registration contexts as doing so will break
authentication in organizations using
EMAIL_ADDRESS_VISIBILITY_ADMINS. In those code paths, use
get_user_by_delivery_email.
"""
return UserProfile.objects.select_related(
"realm",
"realm__can_access_all_users_group",
"realm__can_access_all_users_group__named_user_group",
"realm__direct_message_initiator_group",
"realm__direct_message_initiator_group__named_user_group",
"realm__direct_message_permission_group",
"realm__direct_message_permission_group__named_user_group",
"bot_owner",
).get(email__iexact=email.strip(), realm=realm)
def get_active_user(email: str, realm: "Realm") -> UserProfile:
"""Variant of get_user_by_email that excludes deactivated users.
See get_user docstring for important usage notes."""
user_profile = get_user(email, realm)
if not user_profile.is_active:
raise UserProfile.DoesNotExist
return user_profile
def get_user_profile_by_id_in_realm(uid: int, realm: "Realm") -> UserProfile:
return UserProfile.objects.select_related(
"realm",
"realm__can_access_all_users_group",
"realm__can_access_all_users_group__named_user_group",
"bot_owner",
).get(id=uid, realm=realm)
def get_active_user_profile_by_id_in_realm(uid: int, realm: "Realm") -> UserProfile:
user_profile = get_user_profile_by_id_in_realm(uid, realm)
if not user_profile.is_active:
raise UserProfile.DoesNotExist
return user_profile
def get_user_including_cross_realm(email: str, realm: "Realm") -> UserProfile:
if is_cross_realm_bot_email(email):
return get_system_bot(email, realm.id)
assert realm is not None
return get_user(email, realm)
@cache_with_key(bot_profile_cache_key, timeout=3600 * 24 * 7)
def get_system_bot(email: str, realm_id: int) -> UserProfile:
"""
This function doesn't use the realm_id argument yet, but requires
passing it as preparation for adding system bots to each realm instead
of having them all in a separate system bot realm.
If you're calling this function, use the id of the realm in which the system
bot will be after that migration. If the bot is supposed to send a message,
the same realm as the one *to* which the message will be sent should be used - because
cross-realm messages will be eliminated as part of the migration.
"""
return UserProfile.objects.select_related("realm").get(email__iexact=email.strip())
def get_user_by_id_in_realm_including_cross_realm(
uid: int,
realm: Optional["Realm"],
) -> UserProfile:
user_profile = get_user_profile_by_id(uid)
if user_profile.realm == realm:
return user_profile
# Note: This doesn't validate whether the `realm` passed in is
# None/invalid for the is_cross_realm_bot_email case.
if is_cross_realm_bot_email(user_profile.delivery_email):
return user_profile
raise UserProfile.DoesNotExist
@cache_with_key(realm_user_dicts_cache_key, timeout=3600 * 24 * 7)
def get_realm_user_dicts(realm_id: int) -> list[RawUserDict]:
return list(
UserProfile.objects.filter(
realm_id=realm_id,
).values(*realm_user_dict_fields)
)
@cache_with_key(active_user_ids_cache_key, timeout=3600 * 24 * 7)
def active_user_ids(realm_id: int) -> list[int]:
query = UserProfile.objects.filter(
realm_id=realm_id,
is_active=True,
).values_list("id", flat=True)
return list(query)
@cache_with_key(active_non_guest_user_ids_cache_key, timeout=3600 * 24 * 7)
def active_non_guest_user_ids(realm_id: int) -> list[int]:
query = (
UserProfile.objects.filter(
realm_id=realm_id,
is_active=True,
)
.exclude(
role=UserProfile.ROLE_GUEST,
)
.values_list("id", flat=True)
)
return list(query)
def bot_owner_user_ids(user_profile: UserProfile) -> set[int]:
is_private_bot = (
user_profile.default_sending_stream and user_profile.default_sending_stream.invite_only
) or (
user_profile.default_events_register_stream
and user_profile.default_events_register_stream.invite_only
)
if is_private_bot:
if user_profile.bot_owner_id is not None:
return {user_profile.bot_owner_id}
return set()
else:
users = {user.id for user in user_profile.realm.get_human_admin_users()}
if user_profile.bot_owner_id is not None:
users.add(user_profile.bot_owner_id)
return users
def get_source_profile(email: str, realm_id: int) -> UserProfile | None:
from zerver.models import Realm
from zerver.models.realms import get_realm_by_id
try:
return get_user_by_delivery_email(email, get_realm_by_id(realm_id))
except (Realm.DoesNotExist, UserProfile.DoesNotExist):
return None
@cache_with_key(lambda realm: bot_dicts_in_realm_cache_key(realm.id), timeout=3600 * 24 * 7)
def get_bot_dicts_in_realm(realm: "Realm") -> list[dict[str, Any]]:
return list(UserProfile.objects.filter(realm=realm, is_bot=True).values(*bot_dict_fields))
def is_cross_realm_bot_email(email: str) -> bool:
return email.lower() in settings.CROSS_REALM_BOT_EMAILS