Files
upstream-zulip/zerver/actions/user_settings.py
upstream-mirror 64898b2c79
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
snapshot of https://github.com/zulip/zulip.git @ 48c69911c7dd4e03b20552eb3bd77ad8151c1357 (2026-09-15)
2026-09-15 15:05:30 +05:00

724 lines
27 KiB
Python

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),
)