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:
723
zerver/actions/user_settings.py
Normal file
723
zerver/actions/user_settings.py
Normal file
@@ -0,0 +1,723 @@
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.db.models import F
|
||||
from django.utils.timezone import now as timezone_now
|
||||
|
||||
from confirmation.models import Confirmation, create_confirmation_link
|
||||
from confirmation.settings import STATUS_REVOKED, STATUS_USED
|
||||
from zerver.actions.message_send import send_user_profile_update_notification
|
||||
from zerver.actions.presence import do_update_user_presence
|
||||
from zerver.lib.avatar import avatar_url, generate_and_upload_jdenticon_avatar
|
||||
from zerver.lib.cache import (
|
||||
bulk_flush_users,
|
||||
cache_delete,
|
||||
delete_user_profile_caches,
|
||||
user_profile_by_api_key_cache_key,
|
||||
)
|
||||
from zerver.lib.create_user import get_display_email_address
|
||||
from zerver.lib.i18n import get_language_name
|
||||
from zerver.lib.queue import queue_event_on_commit
|
||||
from zerver.lib.send_email import FromAddress, clear_scheduled_emails, send_email
|
||||
from zerver.lib.timezone import canonicalize_timezone
|
||||
from zerver.lib.types import UserProfileChangeDict
|
||||
from zerver.lib.upload import delete_avatar_image
|
||||
from zerver.lib.users import (
|
||||
can_access_delivery_email,
|
||||
check_bot_name_available,
|
||||
check_full_name,
|
||||
get_user_ids_who_can_access_user,
|
||||
get_users_with_access_to_real_email,
|
||||
user_access_restricted_in_realm,
|
||||
)
|
||||
from zerver.lib.utils import generate_api_key
|
||||
from zerver.models import (
|
||||
Device,
|
||||
Draft,
|
||||
EmailChangeStatus,
|
||||
Realm,
|
||||
RealmAuditLog,
|
||||
ScheduledEmail,
|
||||
ScheduledMessageNotificationEmail,
|
||||
UserPresence,
|
||||
UserProfile,
|
||||
)
|
||||
from zerver.models.clients import get_client
|
||||
from zerver.models.realm_audit_logs import AuditLogEventType
|
||||
from zerver.models.users import get_user_profile_by_id
|
||||
from zerver.tornado.django_api import send_event_on_commit
|
||||
|
||||
|
||||
def send_user_email_update_event(user_profile: UserProfile) -> None:
|
||||
payload = dict(user_id=user_profile.id, new_email=user_profile.email)
|
||||
event = dict(type="realm_user", op="update", person=payload)
|
||||
send_event_on_commit(
|
||||
user_profile.realm,
|
||||
event,
|
||||
get_user_ids_who_can_access_user(user_profile),
|
||||
)
|
||||
|
||||
|
||||
def send_delivery_email_update_events(
|
||||
user_profile: UserProfile, old_visibility_setting: int, visibility_setting: int
|
||||
) -> None:
|
||||
if not user_access_restricted_in_realm(user_profile):
|
||||
active_users = user_profile.realm.get_active_users()
|
||||
else:
|
||||
# The get_user_ids_who_can_access_user returns user IDs and not
|
||||
# user objects and we instead do one more query for UserProfile
|
||||
# objects. We need complete UserProfile objects only for a couple
|
||||
# of cases and it is not worth to query the whole UserProfile
|
||||
# objects in all the cases and it is fine to do the extra query
|
||||
# wherever needed.
|
||||
user_ids_who_can_access_user = get_user_ids_who_can_access_user(user_profile)
|
||||
active_users = UserProfile.objects.filter(
|
||||
id__in=user_ids_who_can_access_user, is_active=True
|
||||
)
|
||||
|
||||
delivery_email_now_visible_user_ids = []
|
||||
delivery_email_now_invisible_user_ids = []
|
||||
|
||||
for active_user in active_users:
|
||||
could_access_delivery_email_previously = can_access_delivery_email(
|
||||
active_user, user_profile.id, old_visibility_setting
|
||||
)
|
||||
can_access_delivery_email_now = can_access_delivery_email(
|
||||
active_user, user_profile.id, visibility_setting
|
||||
)
|
||||
|
||||
if could_access_delivery_email_previously != can_access_delivery_email_now:
|
||||
if can_access_delivery_email_now:
|
||||
delivery_email_now_visible_user_ids.append(active_user.id)
|
||||
else:
|
||||
delivery_email_now_invisible_user_ids.append(active_user.id)
|
||||
|
||||
if delivery_email_now_visible_user_ids:
|
||||
person = dict(user_id=user_profile.id, delivery_email=user_profile.delivery_email)
|
||||
event = dict(type="realm_user", op="update", person=person)
|
||||
send_event_on_commit(
|
||||
user_profile.realm,
|
||||
event,
|
||||
delivery_email_now_visible_user_ids,
|
||||
)
|
||||
if delivery_email_now_invisible_user_ids:
|
||||
person = dict(user_id=user_profile.id, delivery_email=None)
|
||||
event = dict(type="realm_user", op="update", person=person)
|
||||
send_event_on_commit(
|
||||
user_profile.realm,
|
||||
event,
|
||||
delivery_email_now_invisible_user_ids,
|
||||
)
|
||||
|
||||
|
||||
@transaction.atomic(savepoint=False)
|
||||
def do_change_user_delivery_email(
|
||||
user_profile: UserProfile, new_email: str, *, acting_user: UserProfile | None
|
||||
) -> None:
|
||||
delete_user_profile_caches([user_profile], user_profile.realm_id)
|
||||
|
||||
original_email = user_profile.delivery_email
|
||||
|
||||
user_profile.delivery_email = new_email
|
||||
if user_profile.email_address_is_realm_public():
|
||||
user_profile.email = new_email
|
||||
user_profile.save(update_fields=["email", "delivery_email"])
|
||||
else:
|
||||
user_profile.save(update_fields=["delivery_email"])
|
||||
|
||||
# We notify all the users who have access to delivery email.
|
||||
payload = dict(user_id=user_profile.id, delivery_email=new_email)
|
||||
event = dict(type="realm_user", op="update", person=payload)
|
||||
delivery_email_visible_user_ids = get_users_with_access_to_real_email(user_profile)
|
||||
|
||||
send_event_on_commit(user_profile.realm, event, delivery_email_visible_user_ids)
|
||||
|
||||
if user_profile.avatar_source == UserProfile.AVATAR_FROM_GRAVATAR:
|
||||
# If the user is using Gravatar to manage their email address,
|
||||
# their Gravatar just changed, and we need to notify other
|
||||
# clients.
|
||||
notify_avatar_url_change(user_profile)
|
||||
|
||||
if user_profile.email_address_is_realm_public():
|
||||
# Additionally, if we're also changing the publicly visible
|
||||
# email, we send a new_email event as well.
|
||||
send_user_email_update_event(user_profile)
|
||||
|
||||
event_time = timezone_now()
|
||||
RealmAuditLog.objects.create(
|
||||
realm=user_profile.realm,
|
||||
acting_user=acting_user,
|
||||
modified_user=user_profile,
|
||||
event_type=AuditLogEventType.USER_EMAIL_CHANGED,
|
||||
event_time=event_time,
|
||||
extra_data={RealmAuditLog.OLD_VALUE: original_email, RealmAuditLog.NEW_VALUE: new_email},
|
||||
)
|
||||
|
||||
|
||||
def do_start_email_change_process(user_profile: UserProfile, new_email: str) -> None:
|
||||
old_email = user_profile.delivery_email
|
||||
obj = EmailChangeStatus.objects.create(
|
||||
new_email=new_email,
|
||||
old_email=old_email,
|
||||
user_profile=user_profile,
|
||||
realm=user_profile.realm,
|
||||
)
|
||||
|
||||
# Deactivate existing email change requests
|
||||
EmailChangeStatus.objects.filter(realm=user_profile.realm, user_profile=user_profile).exclude(
|
||||
id=obj.id,
|
||||
).exclude(status=STATUS_USED).update(status=STATUS_REVOKED)
|
||||
|
||||
activation_url = create_confirmation_link(obj, Confirmation.EMAIL_CHANGE)
|
||||
from zerver.context_processors import common_context
|
||||
|
||||
context = common_context(user_profile)
|
||||
context.update(
|
||||
old_email=old_email,
|
||||
new_email=new_email,
|
||||
activate_url=activation_url,
|
||||
organization_host=user_profile.realm.host,
|
||||
)
|
||||
language = user_profile.default_language
|
||||
|
||||
email_template = "zerver/emails/confirm_new_email"
|
||||
|
||||
if old_email == "":
|
||||
# The assertions here are to help document the only circumstance under which
|
||||
# this condition should be possible.
|
||||
assert (
|
||||
user_profile.realm.demo_organization_scheduled_deletion_date is not None
|
||||
and user_profile.is_realm_owner
|
||||
)
|
||||
email_template = "zerver/emails/confirm_demo_organization_email"
|
||||
|
||||
send_email(
|
||||
template_prefix=email_template,
|
||||
to_emails=[new_email],
|
||||
from_name=FromAddress.security_email_from_name(language=language),
|
||||
from_address=FromAddress.tokenized_no_reply_address(),
|
||||
language=language,
|
||||
context=context,
|
||||
realm=user_profile.realm,
|
||||
)
|
||||
|
||||
|
||||
def do_change_password(user_profile: UserProfile, password: str, commit: bool = True) -> None:
|
||||
user_profile.set_password(password)
|
||||
if commit:
|
||||
user_profile.save(update_fields=["password"])
|
||||
|
||||
# Imported here to prevent import cycles
|
||||
from zproject.backends import RateLimitedAuthenticationByUsername
|
||||
|
||||
RateLimitedAuthenticationByUsername(user_profile.delivery_email).clear_history()
|
||||
event_time = timezone_now()
|
||||
RealmAuditLog.objects.create(
|
||||
realm=user_profile.realm,
|
||||
acting_user=user_profile,
|
||||
modified_user=user_profile,
|
||||
event_type=AuditLogEventType.USER_PASSWORD_CHANGED,
|
||||
event_time=event_time,
|
||||
)
|
||||
|
||||
|
||||
@transaction.atomic(savepoint=False)
|
||||
def do_change_full_name(
|
||||
user_profile: UserProfile, full_name: str, acting_user: UserProfile | None, notify: bool
|
||||
) -> None:
|
||||
old_name = user_profile.full_name
|
||||
if old_name == full_name:
|
||||
return
|
||||
|
||||
user_profile.full_name = full_name
|
||||
user_profile.save(update_fields=["full_name"])
|
||||
event_time = timezone_now()
|
||||
RealmAuditLog.objects.create(
|
||||
realm=user_profile.realm,
|
||||
acting_user=acting_user,
|
||||
modified_user=user_profile,
|
||||
event_type=AuditLogEventType.USER_FULL_NAME_CHANGED,
|
||||
event_time=event_time,
|
||||
extra_data={RealmAuditLog.OLD_VALUE: old_name, RealmAuditLog.NEW_VALUE: full_name},
|
||||
)
|
||||
payload = dict(user_id=user_profile.id, full_name=user_profile.full_name)
|
||||
send_event_on_commit(
|
||||
user_profile.realm,
|
||||
dict(type="realm_user", op="update", person=payload),
|
||||
get_user_ids_who_can_access_user(user_profile),
|
||||
)
|
||||
|
||||
if notify:
|
||||
changes: list[UserProfileChangeDict] = [
|
||||
UserProfileChangeDict(
|
||||
field_name="full name",
|
||||
old_value=old_name,
|
||||
new_value=full_name,
|
||||
)
|
||||
]
|
||||
|
||||
send_user_profile_update_notification(user_profile, acting_user, changes)
|
||||
|
||||
|
||||
def check_change_full_name(
|
||||
user_profile: UserProfile, full_name_raw: str, acting_user: UserProfile | None
|
||||
) -> str:
|
||||
"""Verifies that the user's proposed full name is valid. The caller
|
||||
is responsible for checking check permissions. Returns the new
|
||||
full name, which may differ from what was passed in (because this
|
||||
function strips whitespace)."""
|
||||
new_full_name = check_full_name(
|
||||
full_name_raw=full_name_raw, user_profile=user_profile, realm=user_profile.realm
|
||||
)
|
||||
do_change_full_name(user_profile, new_full_name, acting_user, notify=True)
|
||||
return new_full_name
|
||||
|
||||
|
||||
def check_change_bot_full_name(
|
||||
user_profile: UserProfile, full_name_raw: str, acting_user: UserProfile
|
||||
) -> None:
|
||||
new_full_name = check_full_name(
|
||||
full_name_raw=full_name_raw, user_profile=user_profile, realm=user_profile.realm
|
||||
)
|
||||
|
||||
if new_full_name == user_profile.full_name:
|
||||
# Our web app will try to patch full_name even if the user didn't
|
||||
# modify the name in the form. We just silently ignore those
|
||||
# situations.
|
||||
return
|
||||
|
||||
check_bot_name_available(
|
||||
realm_id=user_profile.realm_id,
|
||||
full_name=new_full_name,
|
||||
is_activation=False,
|
||||
)
|
||||
do_change_full_name(user_profile, new_full_name, acting_user, notify=False)
|
||||
|
||||
|
||||
@transaction.atomic(durable=True)
|
||||
def do_change_tos_version(user_profile: UserProfile, tos_version: str | None) -> None:
|
||||
old_value = user_profile.tos_version
|
||||
user_profile.tos_version = tos_version
|
||||
user_profile.save(update_fields=["tos_version"])
|
||||
event_time = timezone_now()
|
||||
RealmAuditLog.objects.create(
|
||||
realm=user_profile.realm,
|
||||
acting_user=user_profile,
|
||||
modified_user=user_profile,
|
||||
event_type=AuditLogEventType.USER_TERMS_OF_SERVICE_VERSION_CHANGED,
|
||||
event_time=event_time,
|
||||
extra_data={
|
||||
RealmAuditLog.OLD_VALUE: old_value,
|
||||
RealmAuditLog.NEW_VALUE: tos_version,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@transaction.atomic(durable=True)
|
||||
def do_regenerate_api_key(user_profile: UserProfile, acting_user: UserProfile) -> str:
|
||||
old_api_key = user_profile.api_key
|
||||
new_api_key = generate_api_key()
|
||||
user_profile.api_key = new_api_key
|
||||
user_profile.save(update_fields=["api_key"])
|
||||
|
||||
# We need to explicitly delete the old API key from our caches,
|
||||
# because the on-save handler for flushing the UserProfile object
|
||||
# in zerver/lib/cache.py only has access to the new API key.
|
||||
cache_delete(user_profile_by_api_key_cache_key(old_api_key))
|
||||
|
||||
event_time = timezone_now()
|
||||
RealmAuditLog.objects.create(
|
||||
realm=user_profile.realm,
|
||||
acting_user=acting_user,
|
||||
modified_user=user_profile,
|
||||
event_type=AuditLogEventType.USER_API_KEY_CHANGED,
|
||||
event_time=event_time,
|
||||
)
|
||||
|
||||
event = {"type": "clear_push_device_tokens", "user_profile_id": user_profile.id}
|
||||
queue_event_on_commit("deferred_work", event)
|
||||
|
||||
# Delete all of the user's Device records to stop sending E2EE push notifications.
|
||||
Device.objects.filter(user_id=user_profile.id).delete()
|
||||
|
||||
return new_api_key
|
||||
|
||||
|
||||
def bulk_regenerate_api_keys(user_profile_ids: Iterable[int]) -> None:
|
||||
for user_profile_id in user_profile_ids:
|
||||
user_profile = get_user_profile_by_id(user_profile_id)
|
||||
do_regenerate_api_key(user_profile, user_profile)
|
||||
|
||||
|
||||
def notify_avatar_url_change(user_profile: UserProfile) -> None:
|
||||
payload = dict(
|
||||
avatar_source=user_profile.avatar_source,
|
||||
avatar_url=avatar_url(user_profile),
|
||||
avatar_url_medium=avatar_url(user_profile, medium=True),
|
||||
avatar_version=user_profile.avatar_version,
|
||||
# Even clients using client_gravatar don't need the email,
|
||||
# since we're sending the URL anyway.
|
||||
user_id=user_profile.id,
|
||||
)
|
||||
|
||||
event = dict(type="realm_user", op="update", person=payload)
|
||||
send_event_on_commit(
|
||||
user_profile.realm,
|
||||
event,
|
||||
get_user_ids_who_can_access_user(user_profile),
|
||||
)
|
||||
|
||||
|
||||
@transaction.atomic(savepoint=False)
|
||||
def do_change_avatar_fields(
|
||||
user_profile: UserProfile,
|
||||
avatar_source: str,
|
||||
skip_notify: bool = False,
|
||||
*,
|
||||
acting_user: UserProfile | None,
|
||||
) -> None:
|
||||
user_profile.avatar_source = avatar_source
|
||||
user_profile.avatar_version += 1
|
||||
user_profile.save(update_fields=["avatar_source", "avatar_version"])
|
||||
event_time = timezone_now()
|
||||
RealmAuditLog.objects.create(
|
||||
realm=user_profile.realm,
|
||||
modified_user=user_profile,
|
||||
event_type=AuditLogEventType.USER_AVATAR_SOURCE_CHANGED,
|
||||
extra_data={"avatar_source": avatar_source},
|
||||
event_time=event_time,
|
||||
acting_user=acting_user,
|
||||
)
|
||||
|
||||
if not skip_notify:
|
||||
notify_avatar_url_change(user_profile)
|
||||
|
||||
|
||||
def do_scrub_avatar_images(user: UserProfile, *, acting_user: UserProfile | None) -> None:
|
||||
old_version = user.avatar_version
|
||||
# Note: while scrubbing, no need to generate and upload
|
||||
# avatar when default_avatar_source is jdenticon.
|
||||
do_change_avatar_fields(
|
||||
user, user.realm.default_avatar_source, skip_notify=True, acting_user=acting_user
|
||||
)
|
||||
for version in range(1, old_version + 1):
|
||||
delete_avatar_image(user, version)
|
||||
|
||||
|
||||
def set_avatar_to_default(user_profile: UserProfile, *, acting_user: UserProfile | None) -> None:
|
||||
default_avatar_source = user_profile.realm.default_avatar_source
|
||||
|
||||
if user_profile.avatar_source == default_avatar_source: # nocoverage
|
||||
return
|
||||
|
||||
if default_avatar_source == UserProfile.AVATAR_FROM_JDENTICON:
|
||||
generate_and_upload_jdenticon_avatar(
|
||||
user_profile, realm_uuid=str(user_profile.realm.uuid), future=True
|
||||
)
|
||||
do_change_avatar_fields(user_profile, default_avatar_source, acting_user=acting_user)
|
||||
|
||||
|
||||
def update_scheduled_email_notifications_time(
|
||||
user_profile: UserProfile, old_batching_period: int, new_batching_period: int
|
||||
) -> None:
|
||||
existing_scheduled_emails = ScheduledMessageNotificationEmail.objects.filter(
|
||||
user_profile=user_profile
|
||||
)
|
||||
|
||||
scheduled_timestamp_change = timedelta(seconds=new_batching_period) - timedelta(
|
||||
seconds=old_batching_period
|
||||
)
|
||||
|
||||
existing_scheduled_emails.update(
|
||||
scheduled_timestamp=F("scheduled_timestamp") + scheduled_timestamp_change
|
||||
)
|
||||
|
||||
|
||||
def do_change_user_setting(
|
||||
user_profile: UserProfile,
|
||||
setting_name: str,
|
||||
setting_value: bool | str | int | Enum,
|
||||
*,
|
||||
acting_user: UserProfile | None,
|
||||
) -> None:
|
||||
bulk_change_user_setting(
|
||||
user_profile.realm, [user_profile], setting_name, setting_value, acting_user=acting_user
|
||||
)
|
||||
|
||||
|
||||
@transaction.atomic(savepoint=False)
|
||||
def bulk_change_user_setting(
|
||||
realm: Realm,
|
||||
user_profiles: list[UserProfile],
|
||||
setting_name: str,
|
||||
setting_value: bool | str | int | Enum,
|
||||
*,
|
||||
acting_user: UserProfile | None,
|
||||
) -> None:
|
||||
event_time = timezone_now()
|
||||
audit_logs = []
|
||||
old_values = {}
|
||||
|
||||
if isinstance(setting_value, Enum):
|
||||
db_setting_value = setting_value.value
|
||||
event_value: bool | str | int = setting_value.name
|
||||
else:
|
||||
db_setting_value = setting_value
|
||||
event_value = db_setting_value
|
||||
|
||||
if setting_name == "timezone":
|
||||
assert isinstance(db_setting_value, str)
|
||||
assert isinstance(event_value, str)
|
||||
db_setting_value = canonicalize_timezone(db_setting_value)
|
||||
else:
|
||||
property_type = UserProfile.property_types[setting_name]
|
||||
if isinstance(setting_value, Enum):
|
||||
assert isinstance(setting_value, property_type)
|
||||
assert isinstance(event_value, str)
|
||||
else:
|
||||
assert isinstance(db_setting_value, property_type)
|
||||
assert isinstance(event_value, property_type)
|
||||
|
||||
for user in user_profiles:
|
||||
old_value = getattr(user, setting_name)
|
||||
old_values[user.id] = old_value
|
||||
|
||||
setattr(user, setting_name, db_setting_value)
|
||||
|
||||
audit_logs.append(
|
||||
RealmAuditLog(
|
||||
realm=user.realm,
|
||||
event_type=AuditLogEventType.USER_SETTING_CHANGED,
|
||||
event_time=event_time,
|
||||
acting_user=acting_user,
|
||||
modified_user=user,
|
||||
extra_data={
|
||||
RealmAuditLog.OLD_VALUE: old_value,
|
||||
RealmAuditLog.NEW_VALUE: db_setting_value,
|
||||
"property": setting_name,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
UserProfile.objects.bulk_update(user_profiles, [setting_name])
|
||||
RealmAuditLog.objects.bulk_create(audit_logs)
|
||||
|
||||
# Disabling digest emails should clear a user's email queue
|
||||
if setting_name == "enable_digest_emails" and not db_setting_value:
|
||||
assert len(user_profiles) == 1
|
||||
user_profile = user_profiles[0]
|
||||
clear_scheduled_emails([user_profile.id], ScheduledEmail.DIGEST)
|
||||
|
||||
if setting_name == "email_notifications_batching_period_seconds":
|
||||
for user_profile in user_profiles:
|
||||
old_value = old_values[user_profile.id]
|
||||
assert isinstance(old_value, int)
|
||||
assert isinstance(db_setting_value, int)
|
||||
update_scheduled_email_notifications_time(user_profile, old_value, db_setting_value)
|
||||
|
||||
event = {
|
||||
"type": "user_settings",
|
||||
"op": "update",
|
||||
"property": setting_name,
|
||||
"value": event_value,
|
||||
}
|
||||
|
||||
if setting_name == "default_language":
|
||||
assert isinstance(db_setting_value, str)
|
||||
event["language_name"] = get_language_name(db_setting_value)
|
||||
|
||||
transaction.on_commit(lambda: bulk_flush_users(user_profiles=user_profiles, realm=realm))
|
||||
|
||||
user_ids = [u.id for u in user_profiles]
|
||||
send_event_on_commit(realm, event, user_ids)
|
||||
|
||||
if setting_name == "allow_private_data_export":
|
||||
assert len(user_profiles) == 1
|
||||
user_profile = user_profiles[0]
|
||||
realm_export_event = {
|
||||
"type": "realm_export_consent",
|
||||
"user_id": user_profile.id,
|
||||
"consented": event_value,
|
||||
}
|
||||
send_event_on_commit(
|
||||
user_profile.realm,
|
||||
realm_export_event,
|
||||
list(realm.get_human_admin_users().values_list("id", flat=True)),
|
||||
)
|
||||
|
||||
# Updates to the time zone display setting are sent to all users
|
||||
if setting_name == "timezone":
|
||||
assert len(user_profiles) == 1
|
||||
user_profile = user_profiles[0]
|
||||
payload = dict(
|
||||
email=user_profile.email,
|
||||
user_id=user_profile.id,
|
||||
timezone=canonicalize_timezone(user_profile.timezone),
|
||||
)
|
||||
timezone_event = dict(type="realm_user", op="update", person=payload)
|
||||
send_event_on_commit(
|
||||
realm,
|
||||
timezone_event,
|
||||
get_user_ids_who_can_access_user(user_profile),
|
||||
)
|
||||
|
||||
if setting_name == "email_address_visibility":
|
||||
assert len(user_profiles) == 1
|
||||
user_profile = user_profiles[0]
|
||||
old_value = old_values[user_profile.id]
|
||||
send_delivery_email_update_events(
|
||||
user_profile, old_value, user_profile.email_address_visibility
|
||||
)
|
||||
|
||||
if UserProfile.EMAIL_ADDRESS_VISIBILITY_EVERYONE not in [old_value, db_setting_value]:
|
||||
# We use real email addresses on UserProfile.email only if
|
||||
# EMAIL_ADDRESS_VISIBILITY_EVERYONE is configured, so
|
||||
# changes between values that will not require changing
|
||||
# that field, so we can save work and return here.
|
||||
return
|
||||
|
||||
user_profile.email = get_display_email_address(user_profile)
|
||||
user_profile.save(update_fields=["email"])
|
||||
|
||||
send_user_email_update_event(user_profile)
|
||||
notify_avatar_url_change(user_profile)
|
||||
|
||||
if setting_name == "enable_drafts_synchronization" and db_setting_value is False:
|
||||
# Delete all of the drafts from the backend but don't send delete events
|
||||
# for them since all that's happened is that we stopped syncing changes,
|
||||
# not deleted every previously synced draft - to do that use the DELETE
|
||||
# endpoint.
|
||||
Draft.objects.filter(user_profile__in=user_profiles).delete()
|
||||
|
||||
if setting_name == "presence_enabled":
|
||||
# The presence_enabled setting's primary function is to stop
|
||||
# doing presence updates for the user altogether.
|
||||
#
|
||||
# When a user toggles the presence_enabled setting, we
|
||||
# immediately trigger a presence update, so all users see the
|
||||
# user's current presence state as consistent with the new
|
||||
# setting; not doing so can make it look like the settings
|
||||
# change didn't have any effect.
|
||||
|
||||
assert len(user_profiles) == 1
|
||||
user_profile = user_profiles[0]
|
||||
|
||||
if db_setting_value:
|
||||
status = UserPresence.LEGACY_STATUS_ACTIVE_INT
|
||||
presence_time = timezone_now()
|
||||
else:
|
||||
# We want to ensure the user's presence data is such that
|
||||
# they will be treated as offline by correct clients,
|
||||
# There are two cases:
|
||||
#
|
||||
# (1) If the user's presence was current, we backdate it
|
||||
# so that the user will, for as long as presence
|
||||
# remains disabled, appear to have been last online a
|
||||
# few minutes before they disabled presence.
|
||||
#
|
||||
# (2) If the user only has a presence older than what our
|
||||
# backdate would be, we keep the original value - it
|
||||
# already guarantees that the user will appear to be
|
||||
# offline.
|
||||
backdated_presence_time = timezone_now() - timedelta(
|
||||
# We add a small additional offset as a fudge factor in
|
||||
# case of clock skew.
|
||||
seconds=settings.OFFLINE_THRESHOLD_SECS
|
||||
+ settings.PRESENCE_UPDATE_MIN_FREQ_SECONDS
|
||||
+ 10
|
||||
)
|
||||
minimum_previous_presence_time = backdated_presence_time - timedelta(
|
||||
seconds=settings.PRESENCE_UPDATE_MIN_FREQ_SECONDS + 10
|
||||
)
|
||||
|
||||
try:
|
||||
presence: UserPresence | None = UserPresence.objects.get(user_profile=user_profile)
|
||||
assert presence is not None
|
||||
assert presence.last_connected_time is not None
|
||||
|
||||
original_last_active_time = presence.last_active_time
|
||||
if presence.last_connected_time <= backdated_presence_time:
|
||||
# This is case (2), so we don't need to do
|
||||
# anything. presence is already as intended.
|
||||
# last_active_time <= last_connected_time always holds, so
|
||||
# last_active_time <= backdated_presence_time is also ensured here.
|
||||
return
|
||||
|
||||
# do_update_user_presence will only send an event if
|
||||
# the presence event it receives is sufficiently newer
|
||||
# than whatever it had before, so we backdate the
|
||||
# existing presence data if necessary to ensure that
|
||||
# our new update event will be seen as new by clients.
|
||||
presence_time = backdated_presence_time
|
||||
presence.last_connected_time = min(
|
||||
minimum_previous_presence_time, presence.last_connected_time
|
||||
)
|
||||
update_fields = ["last_connected_time"]
|
||||
|
||||
if (
|
||||
original_last_active_time is None
|
||||
or original_last_active_time < backdated_presence_time
|
||||
):
|
||||
# If the user's last_active_time was old enough (or nonexistent), we don't want to perturb
|
||||
# it, as it's already in a correct state. Thus we only do_update_user_presence with an
|
||||
# IDLE status - which will only update the last_connected_time.
|
||||
status = UserPresence.LEGACY_STATUS_IDLE_INT
|
||||
else:
|
||||
assert presence.last_active_time is not None
|
||||
presence.last_active_time = min(
|
||||
minimum_previous_presence_time, presence.last_active_time
|
||||
)
|
||||
update_fields.append("last_active_time")
|
||||
status = UserPresence.LEGACY_STATUS_ACTIVE_INT
|
||||
presence.save(update_fields=update_fields)
|
||||
|
||||
except UserPresence.DoesNotExist:
|
||||
# If the user has no presence data at all, that should
|
||||
# logically get consistent treatment with having very
|
||||
# old presence data (case (2) above). We want to leave
|
||||
# their presence state intact - so just return without
|
||||
# doing anything.
|
||||
return
|
||||
|
||||
# do_update_user_presence doesn't allow being run inside another
|
||||
# transaction.atomic block, so we need to use on_commit here to ensure
|
||||
# it gets executed outside of our current transaction.
|
||||
transaction.on_commit(
|
||||
lambda: do_update_user_presence(
|
||||
user_profile,
|
||||
get_client("website"),
|
||||
presence_time,
|
||||
status,
|
||||
force_send_update=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@transaction.atomic(durable=True)
|
||||
def do_change_user_date_joined(user_profile: UserProfile, date_joined: datetime) -> None:
|
||||
old_date_joined = user_profile.date_joined
|
||||
user_profile.date_joined = date_joined
|
||||
user_profile.save(update_fields=["date_joined"])
|
||||
|
||||
event_time = timezone_now()
|
||||
RealmAuditLog.objects.create(
|
||||
realm=user_profile.realm,
|
||||
acting_user=user_profile,
|
||||
modified_user=user_profile,
|
||||
event_type=AuditLogEventType.USER_DATE_JOINED_CHANGED,
|
||||
event_time=event_time,
|
||||
extra_data={
|
||||
RealmAuditLog.OLD_VALUE: old_date_joined.isoformat(),
|
||||
RealmAuditLog.NEW_VALUE: date_joined.isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
payload = dict(user_id=user_profile.id, date_joined=date_joined.isoformat(timespec="minutes"))
|
||||
send_event_on_commit(
|
||||
user_profile.realm,
|
||||
dict(type="realm_user", op="update", person=payload),
|
||||
get_user_ids_who_can_access_user(user_profile),
|
||||
)
|
||||
Reference in New Issue
Block a user